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',
];
public function getRouteKeyName(): string
{
return 'ulid';
}
/** @return BelongsTo<User, $this> */
public function owner(): BelongsTo
{

View File

@ -6,6 +6,7 @@ use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
use App\Livewire\Pages\Bio\Index;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
@ -19,12 +20,24 @@ class CreateBioPage extends ModalComponent
public string $slug = '';
/** @return array<string, string> */
/** @return array<string, mixed> */
protected function rules(): array
{
$wsId = $this->workspaceId;
if (! $wsId) {
$ws = current_workspace();
$wsId = $ws !== null ? $ws->id : 0;
}
return [
'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
{
return '4xl';
return '5xl';
}
public function render(): View

View File

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

View File

@ -16,6 +16,9 @@ window.Echo = new Echo({
enabledTransports: ['ws', 'wss'],
});
import QRCode from 'qrcode';
window.QRCode = QRCode;
import {
Chart,
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() {
var path = window.location.pathname;
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 + '/'));
el.classList.toggle('bg-blue/10', isActive);
el.classList.toggle('text-blue', isActive);

View File

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