fix(FIX-10): 6 bugs — route ULID, QR modal, bio unique, nav active, copy btn, analytics context

- Workspace.getRouteKeyName()='ulid' so route() generates ULID URLs not ID
- QR modal 4xl→5xl; import QRCode in @script→window.QRCode (no ESM in @script)
- Bio slug unique rule scoped to (workspace_id, domain_id) not global
- updateActiveNav uses URL.pathname not raw getAttribute('href') (absolute URL fix)
- Add resources/views/components/ui/copy-button.blade.php Alpine component
- Analytics Index.render/baseQuery use $workspaceId not require_workspace() — prevents RuntimeException on Livewire action calls without middleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 17:01:16 +02:00
parent 2d231fe6b8
commit 43ff6d6407
8 changed files with 65 additions and 13 deletions

View File

@ -43,6 +43,11 @@ class Workspace extends Model
'trial_ends_at' => 'datetime', 'trial_ends_at' => 'datetime',
]; ];
public function getRouteKeyName(): string
{
return 'ulid';
}
/** @return BelongsTo<User, $this> */ /** @return BelongsTo<User, $this> */
public function owner(): BelongsTo public function owner(): BelongsTo
{ {

View File

@ -6,6 +6,7 @@ use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Bio\Index; use App\Livewire\Pages\Bio\Index;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View; use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
@ -19,12 +20,24 @@ class CreateBioPage extends ModalComponent
public string $slug = ''; public string $slug = '';
/** @return array<string, string> */ /** @return array<string, mixed> */
protected function rules(): array protected function rules(): array
{ {
$wsId = $this->workspaceId;
if (! $wsId) {
$ws = current_workspace();
$wsId = $ws !== null ? $ws->id : 0;
}
return [ return [
'title' => 'required|max:200', 'title' => 'required|max:200',
'slug' => 'nullable|alpha_dash|max:80|unique:bio_pages,slug', 'slug' => [
'nullable', 'alpha_dash', 'max:80',
Rule::unique('bio_pages', 'slug')
->where('workspace_id', $wsId)
->whereNull('domain_id')
->whereNull('deleted_at'),
],
]; ];
} }

View File

@ -191,7 +191,7 @@ class CreateQrCode extends ModalComponent
public static function modalMaxWidth(): string public static function modalMaxWidth(): string
{ {
return '4xl'; return '5xl';
} }
public function render(): View public function render(): View

View File

@ -59,9 +59,9 @@ class Index extends Component
/** @return Builder<Click> */ /** @return Builder<Click> */
private function baseQuery(): Builder private function baseQuery(): Builder
{ {
$workspace = require_workspace(); $wsId = $this->workspaceId;
$q = Click::where('workspace_id', $workspace->id) $q = Click::where('workspace_id', $wsId)
->where('clicked_at', '>=', $this->rangeStart()); ->where('clicked_at', '>=', $this->rangeStart());
if ($this->country) { if ($this->country) {
@ -71,7 +71,7 @@ class Index extends Component
$q->where('device', $this->device); $q->where('device', $this->device);
} }
if ($this->linkId) { if ($this->linkId) {
$link = Link::where('workspace_id', $workspace->id)->where('ulid', $this->linkId)->first(); $link = Link::where('workspace_id', $wsId)->where('ulid', $this->linkId)->first();
if ($link) { if ($link) {
$q->where('link_id', $link->id); $q->where('link_id', $link->id);
} }
@ -91,11 +91,11 @@ class Index extends Component
public function render(): View public function render(): View
{ {
$workspace = require_workspace(); $wsId = $this->workspaceId;
$totalFiltered = $this->baseQuery()->count(); $totalFiltered = $this->baseQuery()->count();
$allTime = Click::where('workspace_id', $workspace->id)->count(); $allTime = Click::where('workspace_id', $wsId)->count();
$byCountry = (clone $this->baseQuery()) $byCountry = (clone $this->baseQuery())
->whereNotNull('country') ->whereNotNull('country')
@ -111,20 +111,20 @@ class Index extends Component
->orderByDesc('total') ->orderByDesc('total')
->get(); ->get();
$countries = Click::where('workspace_id', $workspace->id) $countries = Click::where('workspace_id', $wsId)
->whereNotNull('country') ->whereNotNull('country')
->distinct() ->distinct()
->pluck('country') ->pluck('country')
->sort() ->sort()
->values(); ->values();
$devices = Click::where('workspace_id', $workspace->id) $devices = Click::where('workspace_id', $wsId)
->whereNotNull('device') ->whereNotNull('device')
->distinct() ->distinct()
->pluck('device') ->pluck('device')
->values(); ->values();
$links = Link::where('workspace_id', $workspace->id) $links = Link::where('workspace_id', $wsId)
->orderBy('slug') ->orderBy('slug')
->get(['ulid', 'slug', 'title']); ->get(['ulid', 'slug', 'title']);

View File

@ -16,6 +16,9 @@ window.Echo = new Echo({
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
import QRCode from 'qrcode';
window.QRCode = QRCode;
import { import {
Chart, Chart,
LineController, LineElement, LineController, LineElement,

View File

@ -0,0 +1,31 @@
@props(['value', 'label' => 'Kopieren'])
<button
type="button"
x-data="{ copied: false }"
@click="navigator.clipboard.writeText(@js($value)).then(() => { copied = true; setTimeout(() => copied = false, 1500); }).catch(() => {
var ta = document.createElement('textarea');
ta.value = @js($value);
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copied = true;
setTimeout(() => copied = false, 1500);
})"
:class="copied ? 'text-green-400' : 'text-t2 hover:text-t1'"
class="inline-flex items-center gap-1 text-xs transition-colors"
>
<span x-show="!copied" class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<rect x="5" y="5" width="8" height="8" rx="1"/><path stroke-linecap="round" stroke-linejoin="round" d="M11 5V3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h2"/>
</svg>
<span>{{ $label }}</span>
</span>
<span x-show="copied" class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l3 3 7-7"/>
</svg>
<span>Kopiert</span>
</span>
</button>

View File

@ -153,7 +153,7 @@
function updateActiveNav() { function updateActiveNav() {
var path = window.location.pathname; var path = window.location.pathname;
document.querySelectorAll('[data-nav-link]').forEach(function (el) { document.querySelectorAll('[data-nav-link]').forEach(function (el) {
var href = el.getAttribute('href'); var href = new URL(el.href, location.origin).pathname;
var isActive = href && (path === href || path.startsWith(href + '/')); var isActive = href && (path === href || path.startsWith(href + '/'));
el.classList.toggle('bg-blue/10', isActive); el.classList.toggle('bg-blue/10', isActive);
el.classList.toggle('text-blue', isActive); el.classList.toggle('text-blue', isActive);

View File

@ -151,7 +151,7 @@
@script @script
<script> <script>
import QRCode from 'qrcode'; const QRCode = window.QRCode;
const canvas = document.getElementById('qr-canvas'); const canvas = document.getElementById('qr-canvas');
const placeholder = document.getElementById('qr-placeholder'); const placeholder = document.getElementById('qr-placeholder');