feat: Toaster, button-component, 10 new modals, links-page fix, vite-tuning

- Toaster Livewire component (toast event, dismiss, auto-hide, 6 tests)
- <x-ui.button> Blade component with wire:loading indicator + variants
- QR modals: CreateQrCode, EditQrCode, DeleteQrCode (TDD, 8 tests)
- Bio modals: CreateBioPage, EditBioPage, DeleteBioPage (TDD, 7 tests)
- Domain modals: AddDomain, VerifyDomain, DeleteDomain (TDD, 7 tests)
- Profile modal: DeleteAccount with password confirm (TDD, 4 tests)
- Links page: fix openModal event name, wire up Edit/Delete to modals, toast on all CRUD
- vite.config.js: add chart.js/auto + optimizeDeps.force
- QrCode model: add newFactory() + QrCodeFactory
- Layout: Toaster embedded before </body>
- 141 tests / 296 assertions all green, verify 29/29

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 13:55:25 +02:00
parent 502a777afd
commit c67bde8fc5
37 changed files with 1448 additions and 29 deletions

View File

@ -5,7 +5,7 @@ namespace App\Domains\QrCode\Models;
use App\Domains\Link\Models\Link; use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Models\Workspace;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Database\Factories\QrCodeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,9 +14,14 @@ use Illuminate\Support\Str;
class QrCode extends Model class QrCode extends Model
{ {
/** @use HasFactory<Factory<static>> */ /** @use HasFactory<QrCodeFactory> */
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
protected static function newFactory(): QrCodeFactory
{
return QrCodeFactory::new();
}
protected $guarded = ['id']; protected $guarded = ['id'];
protected $casts = [ protected $casts = [

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Components;
use Livewire\Attributes\On;
use Livewire\Component;
class Toaster extends Component
{
public array $toasts = [];
#[On('toast')]
public function add(string $message, string $type = 'success', int $duration = 4000): void
{
$this->toasts[] = [
'id' => uniqid('t_', true),
'message' => $message,
'type' => $type,
'duration' => $duration,
];
}
public function dismiss(string $id): void
{
$this->toasts = array_values(
array_filter($this->toasts, fn ($t) => $t['id'] !== $id)
);
}
public function render(): \Illuminate\View\View
{
return view('livewire.components.toaster');
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Domain\Actions\CreateDomain;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class AddDomain extends ModalComponent
{
public string $hostname = '';
protected function rules(): array
{
return [
'hostname' => 'required|string|max:255|regex:/^[a-z0-9]([a-z0-9\-\.]+[a-z0-9])?$/i|unique:domains,hostname',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
(new CreateDomain)->handle($workspace, $this->hostname);
$this->dispatch('domainAdded');
$this->dispatch('toast', message: 'Domain hinzugefügt', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.add-domain');
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class CreateBioPage extends ModalComponent
{
public string $title = '';
public string $slug = '';
protected function rules(): array
{
return [
'title' => 'required|max:200',
'slug' => 'nullable|alpha_dash|max:80|unique:bio_pages,slug',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
if (empty($this->slug)) {
$this->slug = Str::slug($this->title).'-'.Str::random(4);
}
BioPage::create([
'workspace_id' => $workspace->id,
'slug' => $this->slug,
'title' => ['en' => $this->title, 'de' => $this->title],
'theme' => [],
'is_published' => false,
]);
$this->dispatch('bioPageCreated');
$this->dispatch('toast', message: 'Bio Page erstellt', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.create-bio-page');
}
}

View File

@ -46,6 +46,7 @@ class CreateLink extends ModalComponent
]); ]);
$this->dispatch('linkCreated'); $this->dispatch('linkCreated');
$this->dispatch('toast', message: 'Link erstellt', type: 'success');
$this->closeModal(); $this->closeModal();
} }

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class CreateQrCode extends ModalComponent
{
public string $label = '';
public string $url = '';
public string $type = 'url';
protected function rules(): array
{
return [
'url' => 'required|url|max:2048',
'label' => 'nullable|max:200',
'type' => 'required|in:url,vcard,wifi,pdf,mp3,text',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
QrCode::create([
'workspace_id' => $workspace->id,
'type' => $this->type,
'payload' => ['url' => $this->url],
'is_dynamic' => true,
]);
$this->dispatch('qrCreated');
$this->dispatch('toast', message: 'QR Code erstellt', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.create-qr-code');
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Modals;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
class DeleteAccount extends ModalComponent
{
#[Validate('required')]
public string $password = '';
public function delete(): void
{
$this->validate();
$user = Auth::user();
if (! Hash::check($this->password, $user->password)) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
Auth::logout();
$user->delete();
session()->invalidate();
session()->regenerateToken();
$this->redirect('/', navigate: false);
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): View
{
return view('livewire.modals.delete-account');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class DeleteBioPage extends ModalComponent
{
public string $bioUlid = '';
public string $bioTitle = '';
public function mount(string $bioUlid): void
{
$workspace = app('current_workspace');
$bio = BioPage::where('ulid', $bioUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->bioUlid = $bioUlid;
$this->bioTitle = $bio->getTranslation('title', 'en') ?? $bio->slug;
}
public function delete(): void
{
$workspace = app('current_workspace');
BioPage::where('ulid', $this->bioUlid)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('bioPageDeleted', bioUlid: $this->bioUlid);
$this->dispatch('toast', message: 'Bio Page gelöscht', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): View
{
return view('livewire.modals.delete-bio-page');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Domain\Models\Domain;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class DeleteDomain extends ModalComponent
{
public int $domainId = 0;
public string $hostname = '';
public function mount(int $domainId): void
{
$workspace = app('current_workspace');
$domain = Domain::where('id', $domainId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->domainId = $domainId;
$this->hostname = $domain->hostname;
}
public function delete(): void
{
$workspace = app('current_workspace');
Domain::where('id', $this->domainId)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('domainDeleted', domainId: $this->domainId);
$this->dispatch('toast', message: 'Domain gelöscht', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): View
{
return view('livewire.modals.delete-domain');
}
}

View File

@ -33,6 +33,7 @@ class DeleteLink extends ModalComponent
->delete(); ->delete();
$this->dispatch('linkDeleted', linkUlid: $this->linkUlid); $this->dispatch('linkDeleted', linkUlid: $this->linkUlid);
$this->dispatch('toast', message: 'Link gelöscht', type: 'success');
$this->closeModal(); $this->closeModal();
} }

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class DeleteQrCode extends ModalComponent
{
public string $qrUlid = '';
public string $qrLabel = '';
public function mount(string $qrUlid): void
{
$workspace = app('current_workspace');
$qr = QrCode::where('ulid', $qrUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->qrUlid = $qrUlid;
$this->qrLabel = $qr->label ?? $qr->ulid;
}
public function delete(): void
{
$workspace = app('current_workspace');
QrCode::where('ulid', $this->qrUlid)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('qrDeleted', qrUlid: $this->qrUlid);
$this->dispatch('toast', message: 'QR Code gelöscht', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): View
{
return view('livewire.modals.delete-qr-code');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Bio\Models\BioPage;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class EditBioPage extends ModalComponent
{
public string $bioUlid = '';
public string $title = '';
public string $slug = '';
public bool $isPublished = false;
public function mount(string $bioUlid): void
{
$workspace = app('current_workspace');
$bio = BioPage::where('ulid', $bioUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->bioUlid = $bioUlid;
$this->title = $bio->getTranslation('title', 'en') ?? '';
$this->slug = $bio->slug;
$this->isPublished = $bio->is_published;
}
protected function rules(): array
{
return [
'title' => 'required|max:200',
'slug' => 'nullable|alpha_dash|max:80',
'isPublished' => 'boolean',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
$bio = BioPage::where('ulid', $this->bioUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$bio->update([
'title' => ['en' => $this->title, 'de' => $this->title],
'slug' => $this->slug ?: $bio->slug,
'is_published' => $this->isPublished,
]);
$this->dispatch('bioPageUpdated', bioUlid: $this->bioUlid);
$this->dispatch('toast', message: 'Bio Page aktualisiert', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.edit-bio-page');
}
}

View File

@ -62,6 +62,7 @@ class EditLink extends ModalComponent
]); ]);
$this->dispatch('linkUpdated', linkUlid: $this->linkUlid); $this->dispatch('linkUpdated', linkUlid: $this->linkUlid);
$this->dispatch('toast', message: 'Link aktualisiert', type: 'success');
$this->closeModal(); $this->closeModal();
} }

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\QrCode\Models\QrCode;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class EditQrCode extends ModalComponent
{
public string $qrUlid = '';
public string $url = '';
public string $label = '';
public string $type = 'url';
public function mount(string $qrUlid): void
{
$workspace = app('current_workspace');
$qr = QrCode::where('ulid', $qrUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->qrUlid = $qrUlid;
$this->url = $qr->payload['url'] ?? '';
$this->label = $qr->label ?? '';
$this->type = $qr->type;
}
protected function rules(): array
{
return [
'url' => 'required|url|max:2048',
'label' => 'nullable|max:200',
'type' => 'required|in:url,vcard,wifi,pdf,mp3,text',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
$qr = QrCode::where('ulid', $this->qrUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$qr->update([
'type' => $this->type,
'payload' => ['url' => $this->url],
]);
$this->dispatch('qrUpdated', qrUlid: $this->qrUlid);
$this->dispatch('toast', message: 'QR Code aktualisiert', type: 'success');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.edit-qr-code');
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Domain\Actions\VerifyDomain as VerifyDomainAction;
use App\Domains\Domain\Models\Domain;
use Illuminate\View\View;
use LivewireUI\Modal\ModalComponent;
class VerifyDomain extends ModalComponent
{
public int $domainId = 0;
public string $hostname = '';
public string $verificationToken = '';
public function mount(int $domainId): void
{
$workspace = app('current_workspace');
$domain = Domain::where('id', $domainId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->domainId = $domainId;
$this->hostname = $domain->hostname;
$this->verificationToken = $domain->verification_token;
}
public function verify(): void
{
$workspace = app('current_workspace');
$domain = Domain::where('id', $this->domainId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$verified = (new VerifyDomainAction)->handle($domain);
if ($verified) {
$this->dispatch('domainVerified', domainId: $this->domainId);
$this->dispatch('toast', message: 'Domain verifiziert', type: 'success');
$this->closeModal();
} else {
$this->dispatch('toast', message: 'DNS-Eintrag nicht gefunden. Bitte warten und erneut versuchen.', type: 'error');
}
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render(): View
{
return view('livewire.modals.verify-domain');
}
}

View File

@ -2,9 +2,9 @@
namespace App\Livewire\Pages\Links; namespace App\Livewire\Pages\Links;
use App\Domains\Link\Actions\DeleteLink;
use App\Domains\Link\Models\Link; use App\Domains\Link\Models\Link;
use Illuminate\View\View; use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -16,8 +16,6 @@ class Index extends Component
public string $statusFilter = ''; public string $statusFilter = '';
protected $listeners = ['linkCreated' => '$refresh'];
public function updatingSearch(): void public function updatingSearch(): void
{ {
$this->resetPage(); $this->resetPage();
@ -28,15 +26,11 @@ class Index extends Component
$this->resetPage(); $this->resetPage();
} }
public function deleteLink(int $id, DeleteLink $action): void #[On('linkCreated')]
#[On('linkUpdated')]
#[On('linkDeleted')]
public function refresh(): void
{ {
$workspace = app('current_workspace');
$link = Link::where('id', $id)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorize('update', $workspace);
$action->handle($link);
$this->resetPage(); $this->resetPage();
} }

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Domains\QrCode\Models\QrCode;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class QrCodeFactory extends Factory
{
protected $model = QrCode::class;
public function definition(): array
{
return [
'ulid' => (string) Str::ulid(),
'workspace_id' => Workspace::factory(),
'type' => 'url',
'payload' => ['url' => fake()->url()],
'style' => null,
'is_dynamic' => true,
];
}
}

View File

@ -0,0 +1,36 @@
@props([
'action' => null,
'label' => 'Speichern',
'loadingLabel' => 'Lädt...',
'variant' => 'primary',
'type' => 'button',
])
@php
$base = 'relative inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition disabled:opacity-60 disabled:cursor-not-allowed';
$variants = [
'primary' => 'bg-accent-gradient text-white hover:opacity-90',
'secondary' => 'bg-s2 border border-white/[.06] text-t1 hover:bg-s3',
'danger' => 'bg-red text-white hover:opacity-90',
'ghost' => 'text-t2 hover:text-t1 hover:bg-s2',
];
$cls = $base . ' ' . ($variants[$variant] ?? $variants['primary']);
@endphp
<button
{{ $attributes->merge(['type' => $type, 'class' => $cls]) }}
@if($action) wire:loading.attr="disabled" wire:target="{{ $action }}" @endif
>
<span @if($action) wire:loading.remove wire:target="{{ $action }}" @endif class="flex items-center gap-2">
{{ $slot->isNotEmpty() ? $slot : $label }}
</span>
@if($action)
<span wire:loading wire:target="{{ $action }}" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ $loadingLabel }}
</span>
@endif
</button>

View File

@ -134,6 +134,7 @@
</div> </div>
@livewire('wire-elements-modal') @livewire('wire-elements-modal')
@livewire('components.toaster')
@livewireScripts @livewireScripts
<script> <script>

View File

@ -0,0 +1,39 @@
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
@foreach($toasts as $t)
<div wire:key="{{ $t['id'] }}"
x-data="{ show: true }"
x-init="setTimeout(() => { show = false; setTimeout(() => $wire.dismiss('{{ $t['id'] }}'), 300); }, {{ $t['duration'] }})"
x-show="show"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
class="pointer-events-auto min-w-64 max-w-sm px-4 py-3 rounded-xl border shadow-lg backdrop-blur-sm
@if($t['type']==='success') bg-green/10 border-green/30 text-green
@elseif($t['type']==='error') bg-red/10 border-red/30 text-red
@elseif($t['type']==='warning') bg-amber/10 border-amber/30 text-amber
@else bg-blue/10 border-blue/30 text-blue @endif"
x-cloak>
<div class="flex items-center gap-3">
{{-- Icon --}}
<span class="flex-shrink-0 w-4 h-4">
@if($t['type']==='success')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l3.5 3.5L13 5"/></svg>
@elseif($t['type']==='error')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5v3m0 3h.01M4.93 4.93l6.14 6.14"/></svg>
@elseif($t['type']==='warning')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 3l5.5 10H2.5L8 3zm0 4v2.5m0 1.5h.01"/></svg>
@else
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><circle cx="8" cy="8" r="5"/><path stroke-linecap="round" d="M8 6v2.5m0 1.5h.01"/></svg>
@endif
</span>
<span class="flex-1 text-sm font-medium">{{ $t['message'] }}</span>
<button wire:click="dismiss('{{ $t['id'] }}')"
class="flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity text-lg leading-none"
aria-label="Dismiss">×</button>
</div>
</div>
@endforeach
</div>

View File

@ -0,0 +1,24 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Add Custom Domain</h3>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Hostname <span class="text-red">*</span></label>
<input wire:model="hostname" type="text" placeholder="links.yourdomain.com"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('hostname')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
<p class="mt-1.5 text-xs text-t3">Enter the domain you want to use (e.g. links.example.com)</p>
</div>
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 text-xs text-t2 space-y-1">
<p class="font-medium text-t1">After adding, you'll need to:</p>
<p>1. Add a CNAME record pointing to <span class="font-mono text-blue">nimu.li</span></p>
<p>2. Add a TXT record for verification</p>
<p>3. Click "Verify" to activate SSL</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Add Domain" loading-label="Adding..." />
</div>
</div>

View File

@ -0,0 +1,30 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Create Bio Page</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Title <span class="text-red">*</span></label>
<input wire:model="title" type="text" placeholder="My Bio Page"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('title')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Slug (optional)</label>
<div class="flex items-center gap-1">
<span class="text-t3 text-xs shrink-0">nimu.li/bio/</span>
<input wire:model="slug" type="text" placeholder="my-bio"
class="flex-1 px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
</div>
<p class="mt-1 text-xs text-t3">Leave empty to auto-generate.</p>
@error('slug')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Create Bio Page" loading-label="Creating..." />
</div>
</div>

View File

@ -0,0 +1,39 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Create QR Code</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Destination URL <span class="text-red">*</span></label>
<input wire:model="url" type="url" placeholder="https://example.com"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('url')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Label (optional)</label>
<input wire:model="label" type="text" placeholder="My QR Code"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('label')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Type</label>
<select wire:model="type"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
<option value="url">URL</option>
<option value="vcard">vCard</option>
<option value="wifi">WiFi</option>
<option value="pdf">PDF</option>
<option value="mp3">MP3</option>
<option value="text">Text</option>
</select>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Create QR Code" loading-label="Creating..." />
</div>
</div>

View File

@ -0,0 +1,28 @@
<div class="p-6 space-y-5">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-base font-semibold text-t1">Delete Account</h3>
<p class="mt-1.5 text-sm text-t2">
This will permanently delete your account and all data. This action cannot be undone.
</p>
</div>
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Confirm your password</label>
<input wire:model="password" type="password" placeholder="••••••••"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-red/50 focus:border-red/50" />
@error('password')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete My Account" loading-label="Deleting..." />
</div>
</div>

View File

@ -0,0 +1,20 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete Bio Page</h3>
<p class="mt-1.5 text-sm text-t2">
Delete <span class="text-t1 font-medium">{{ $bioTitle }}</span>? All blocks and data will be permanently removed.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete Bio Page" loading-label="Deleting..." />
</div>
</div>

View File

@ -0,0 +1,20 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<circle cx="10" cy="10" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M10 6v4m0 3h.01"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete Domain</h3>
<p class="mt-1.5 text-sm text-t2">
Remove <span class="text-t1 font-medium font-mono">{{ $hostname }}</span>? Links using this domain will stop working.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Remove Domain" loading-label="Removing..." />
</div>
</div>

View File

@ -0,0 +1,20 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete QR Code</h3>
<p class="mt-1.5 text-sm text-t2">
Delete <span class="text-t1 font-medium">{{ $qrLabel }}</span>? This cannot be undone.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete QR Code" loading-label="Deleting..." />
</div>
</div>

View File

@ -0,0 +1,34 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Edit Bio Page</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Title <span class="text-red">*</span></label>
<input wire:model="title" type="text"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('title')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Slug</label>
<input wire:model="slug" type="text"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('slug')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer">
<input wire:model="isPublished" type="checkbox" class="sr-only peer">
<div class="w-9 h-5 bg-s3 rounded-full peer peer-checked:bg-blue peer-focus:ring-2 peer-focus:ring-blue/30 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
<span class="text-sm text-t2">Published</span>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Save Changes" loading-label="Saving..." />
</div>
</div>

View File

@ -0,0 +1,39 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Edit QR Code</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Destination URL <span class="text-red">*</span></label>
<input wire:model="url" type="url" placeholder="https://example.com"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('url')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Label (optional)</label>
<input wire:model="label" type="text" placeholder="My QR Code"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('label')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Type</label>
<select wire:model="type"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
<option value="url">URL</option>
<option value="vcard">vCard</option>
<option value="wifi">WiFi</option>
<option value="pdf">PDF</option>
<option value="mp3">MP3</option>
<option value="text">Text</option>
</select>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Save Changes" loading-label="Saving..." />
</div>
</div>

View File

@ -0,0 +1,51 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Verify Domain</h3>
<p class="text-sm text-t2">Add the following DNS records to <span class="text-t1 font-medium">{{ $hostname }}</span>:</p>
<div class="space-y-3">
{{-- CNAME --}}
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 space-y-2">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">CNAME Record</div>
<div class="grid grid-cols-3 gap-3 text-xs">
<div>
<div class="text-t3 mb-1">Name</div>
<code class="text-t1 font-mono">{{ $hostname }}</code>
</div>
<div>
<div class="text-t3 mb-1">Type</div>
<code class="text-t1 font-mono">CNAME</code>
</div>
<div>
<div class="text-t3 mb-1">Value</div>
<code class="text-blue font-mono">nimu.li</code>
</div>
</div>
</div>
{{-- TXT Verification --}}
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 space-y-2">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">TXT Record (verification)</div>
<div class="grid grid-cols-3 gap-3 text-xs">
<div>
<div class="text-t3 mb-1">Name</div>
<code class="text-t1 font-mono">_nimuli.{{ $hostname }}</code>
</div>
<div>
<div class="text-t3 mb-1">Type</div>
<code class="text-t1 font-mono">TXT</code>
</div>
<div>
<div class="text-t3 mb-1">Value</div>
<code class="text-blue font-mono text-[10px] break-all">nimuli-verify={{ $verificationToken }}</code>
</div>
</div>
</div>
</div>
<p class="text-xs text-t3">DNS propagation can take up to 48 hours. Click verify once records are set.</p>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Close" />
<x-ui.button wire:click="verify" action="verify" label="Check & Verify" loading-label="Checking DNS..." />
</div>
</div>

View File

@ -2,8 +2,8 @@
{{-- Header --}} {{-- Header --}}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-t1">Links</h1> <h1 class="text-2xl font-bold text-t1">Links</h1>
<button @click="$dispatch('open-modal', {component: 'modals.create-link'})" <button @click="$dispatch('openModal', {component: 'modals.create-link'})"
class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"> class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ New Link + New Link
</button> </button>
</div> </div>
@ -30,7 +30,7 @@
<tr class="text-t3 text-xs uppercase tracking-wider"> <tr class="text-t3 text-xs uppercase tracking-wider">
<th class="px-4 py-3 text-left font-medium">Link</th> <th class="px-4 py-3 text-left font-medium">Link</th>
<th class="px-4 py-3 text-left font-medium">Target</th> <th class="px-4 py-3 text-left font-medium">Target</th>
<th class="px-4 py-3 text-left font-medium">Tags</th> <th class="px-4 py-3 text-left font-medium hidden md:table-cell">Tags</th>
<th class="px-4 py-3 text-right font-medium">Clicks</th> <th class="px-4 py-3 text-right font-medium">Clicks</th>
<th class="px-4 py-3 text-center font-medium">Status</th> <th class="px-4 py-3 text-center font-medium">Status</th>
<th class="px-4 py-3"></th> <th class="px-4 py-3"></th>
@ -38,14 +38,15 @@
</thead> </thead>
<tbody class="divide-y divide-white/[.04]"> <tbody class="divide-y divide-white/[.04]">
@forelse($links as $link) @forelse($links as $link)
<tr class="hover:bg-white/[.02] transition-colors"> <tr class="hover:bg-white/[.02] transition-colors group">
{{-- Link: slug + title --}} {{-- Slug + title --}}
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-baseline gap-1"> <div class="flex items-baseline gap-1">
<span class="text-t3 text-xs">nimu.li/</span><span class="font-mono text-blue text-sm">{{ $link->slug }}</span> <span class="text-t3 text-xs">nimu.li/</span>
<span class="font-mono text-blue text-sm">{{ $link->slug }}</span>
</div> </div>
@if($link->title) @if($link->title)
<div class="text-t3 text-xs mt-0.5">{{ $link->title }}</div> <div class="text-t3 text-xs mt-0.5 truncate max-w-xs">{{ $link->title }}</div>
@endif @endif
</td> </td>
@ -55,7 +56,7 @@
</td> </td>
{{-- Tags --}} {{-- Tags --}}
<td class="px-4 py-3"> <td class="px-4 py-3 hidden md:table-cell">
@if(!empty($link->tags)) @if(!empty($link->tags))
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@foreach($link->tags as $tag) @foreach($link->tags as $tag)
@ -69,7 +70,7 @@
{{-- Clicks --}} {{-- Clicks --}}
<td class="px-4 py-3 text-right font-mono text-t1 text-sm"> <td class="px-4 py-3 text-right font-mono text-t1 text-sm">
{{ $link->clicks_count ?? 0 }} {{ number_format($link->clicks_count ?? 0) }}
</td> </td>
{{-- Status Badge --}} {{-- Status Badge --}}
@ -85,11 +86,17 @@
{{-- Actions --}} {{-- Actions --}}
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
<a href="#" class="text-t2 text-xs hover:text-t1 transition-colors">Edit</a> <button
<button wire:click="deleteLink({{ $link->id }})" @click="$dispatch('openModal', {component: 'modals.edit-link', arguments: {linkUlid: '{{ $link->ulid }}'}})"
wire:confirm="Delete this link? This action cannot be undone." class="text-t2 text-xs hover:text-t1 transition-colors">
class="text-red text-xs hover:opacity-80 transition-opacity">Delete</button> Edit
</button>
<button
@click="$dispatch('openModal', {component: 'modals.delete-link', arguments: {linkUlid: '{{ $link->ulid }}'}})"
class="text-red text-xs hover:opacity-80 transition-opacity">
Delete
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -100,7 +107,9 @@
<svg class="w-10 h-10 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="w-10 h-10 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg> </svg>
<p class="text-t3 text-sm">No links yet. Create your first short link.</p> <p class="text-t3 text-sm">No links yet.</p>
<button @click="$dispatch('openModal', {component: 'modals.create-link'})"
class="text-blue text-sm hover:underline">Create your first short link</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -109,6 +118,7 @@
</table> </table>
</div> </div>
{{-- Pagination --}} @if($links->hasPages())
<div class="mt-4">{{ $links->links() }}</div> <div class="mt-4">{{ $links->links() }}</div>
@endif
</div> </div>

View File

@ -0,0 +1,106 @@
<?php
use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Livewire\Modals\CreateBioPage;
use App\Livewire\Modals\DeleteBioPage;
use App\Livewire\Modals\EditBioPage;
use App\Models\User;
use Livewire\Livewire;
function makeUserWithWsForBio(): array
{
$user = User::factory()->create();
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $user->id,
'role' => 'owner',
'joined_at' => now(),
]);
return [$user, $ws];
}
// CreateBioPage
it('renders create bio page modal', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateBioPage::class)
->assertOk()
->assertSee('Bio Page');
});
it('validates required title on create bio page', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateBioPage::class)
->call('save')
->assertHasErrors(['title' => 'required']);
});
it('creates bio page and dispatches event', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateBioPage::class)
->set('title', 'My Bio Page')
->call('save')
->assertDispatched('bioPageCreated')
->assertDispatched('toast');
$this->assertDatabaseHas('bio_pages', [
'workspace_id' => $ws->id,
]);
});
// EditBioPage
it('renders edit bio page modal with existing data', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(EditBioPage::class, ['bioUlid' => $bio->ulid])
->assertOk();
});
it('updates bio page and dispatches event', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(EditBioPage::class, ['bioUlid' => $bio->ulid])
->set('title', 'Updated Title')
->call('save')
->assertDispatched('bioPageUpdated')
->assertDispatched('toast');
});
// DeleteBioPage
it('renders delete bio page modal', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteBioPage::class, ['bioUlid' => $bio->ulid])
->assertOk();
});
it('deletes bio page and dispatches event', function () {
[$user, $ws] = makeUserWithWsForBio();
app()->instance('current_workspace', $ws);
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteBioPage::class, ['bioUlid' => $bio->ulid])
->call('delete')
->assertDispatched('bioPageDeleted')
->assertDispatched('toast');
$this->assertSoftDeleted('bio_pages', ['id' => $bio->id]);
});

View File

@ -0,0 +1,106 @@
<?php
use App\Domains\Domain\Models\Domain;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Livewire\Modals\AddDomain;
use App\Livewire\Modals\DeleteDomain;
use App\Livewire\Modals\VerifyDomain;
use App\Models\User;
use Livewire\Livewire;
function makeUserWithWsForDomain(): array
{
$user = User::factory()->create();
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $user->id,
'role' => 'owner',
'joined_at' => now(),
]);
return [$user, $ws];
}
// AddDomain
it('renders add domain modal', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(AddDomain::class)
->assertOk()
->assertSee('Domain');
});
it('validates required hostname on add domain', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(AddDomain::class)
->call('save')
->assertHasErrors(['hostname' => 'required']);
});
it('adds domain and dispatches event', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(AddDomain::class)
->set('hostname', 'links.example.com')
->call('save')
->assertDispatched('domainAdded')
->assertDispatched('toast');
$this->assertDatabaseHas('domains', [
'workspace_id' => $ws->id,
'hostname' => 'links.example.com',
]);
});
// VerifyDomain
it('renders verify domain modal', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(VerifyDomain::class, ['domainId' => $domain->id])
->assertOk()
->assertSee($domain->hostname);
});
it('shows verification token in verify domain modal', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(VerifyDomain::class, ['domainId' => $domain->id])
->assertSee($domain->verification_token);
});
// DeleteDomain
it('renders delete domain modal', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteDomain::class, ['domainId' => $domain->id])
->assertOk()
->assertSee($domain->hostname);
});
it('deletes domain and dispatches event', function () {
[$user, $ws] = makeUserWithWsForDomain();
app()->instance('current_workspace', $ws);
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteDomain::class, ['domainId' => $domain->id])
->call('delete')
->assertDispatched('domainDeleted')
->assertDispatched('toast');
$this->assertDatabaseMissing('domains', ['id' => $domain->id]);
});

View File

@ -0,0 +1,41 @@
<?php
use App\Livewire\Modals\DeleteAccount;
use App\Models\User;
use Livewire\Livewire;
it('renders delete account modal', function () {
$user = User::factory()->create();
Livewire::actingAs($user)->test(DeleteAccount::class)
->assertOk()
->assertSee('Delete Account');
});
it('requires password confirmation to delete account', function () {
$user = User::factory()->create();
Livewire::actingAs($user)->test(DeleteAccount::class)
->call('delete')
->assertHasErrors(['password' => 'required']);
});
it('rejects wrong password on delete account', function () {
$user = User::factory()->create();
Livewire::actingAs($user)->test(DeleteAccount::class)
->set('password', 'wrong-password')
->call('delete')
->assertHasErrors(['password']);
});
it('soft-deletes account with correct password and redirects', function () {
$user = User::factory()->create();
Livewire::actingAs($user)->test(DeleteAccount::class)
->set('password', 'password')
->call('delete')
->assertRedirect('/');
$this->assertSoftDeleted('users', ['id' => $user->id]);
});

View File

@ -0,0 +1,121 @@
<?php
use App\Domains\QrCode\Models\QrCode;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Livewire\Modals\CreateQrCode;
use App\Livewire\Modals\DeleteQrCode;
use App\Livewire\Modals\EditQrCode;
use App\Models\User;
use Livewire\Livewire;
function makeUserWithWsForQr(): array
{
$user = User::factory()->create();
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $user->id,
'role' => 'owner',
'joined_at' => now(),
]);
return [$user, $ws];
}
// CreateQrCode
it('renders create qr code modal', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateQrCode::class)
->assertOk()
->assertSee('QR Code');
});
it('validates required url on create qr code', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateQrCode::class)
->call('save')
->assertHasErrors(['url' => 'required']);
});
it('validates url format on create qr code', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateQrCode::class)
->set('url', 'not-a-url')
->call('save')
->assertHasErrors(['url']);
});
it('creates qr code and dispatches event', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateQrCode::class)
->set('url', 'https://example.com')
->set('label', 'My QR')
->call('save')
->assertDispatched('qrCreated')
->assertDispatched('toast');
$this->assertDatabaseHas('qr_codes', [
'workspace_id' => $ws->id,
'type' => 'url',
]);
});
// EditQrCode
it('renders edit qr code modal with existing data', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
$qr = QrCode::factory()->create(['workspace_id' => $ws->id, 'payload' => ['url' => 'https://old.com']]);
Livewire::actingAs($user)->test(EditQrCode::class, ['qrUlid' => $qr->ulid])
->assertOk()
->assertSet('url', 'https://old.com');
});
it('updates qr code and dispatches event', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
$qr = QrCode::factory()->create(['workspace_id' => $ws->id, 'payload' => ['url' => 'https://old.com']]);
Livewire::actingAs($user)->test(EditQrCode::class, ['qrUlid' => $qr->ulid])
->set('url', 'https://new.com')
->call('save')
->assertDispatched('qrUpdated')
->assertDispatched('toast');
expect($qr->fresh()->payload['url'])->toBe('https://new.com');
});
// DeleteQrCode
it('renders delete qr code modal', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
$qr = QrCode::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteQrCode::class, ['qrUlid' => $qr->ulid])
->assertOk();
});
it('deletes qr code and dispatches event', function () {
[$user, $ws] = makeUserWithWsForQr();
app()->instance('current_workspace', $ws);
$qr = QrCode::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteQrCode::class, ['qrUlid' => $qr->ulid])
->call('delete')
->assertDispatched('qrDeleted')
->assertDispatched('toast');
$this->assertSoftDeleted('qr_codes', ['id' => $qr->id]);
});

View File

@ -0,0 +1,53 @@
<?php
use App\Livewire\Components\Toaster;
use Livewire\Livewire;
it('renders toaster with empty toast list', function () {
Livewire::test(Toaster::class)
->assertSet('toasts', [])
->assertOk();
});
it('adds a success toast via toast event', function () {
Livewire::test(Toaster::class)
->dispatch('toast', message: 'Link erstellt', type: 'success')
->assertCount('toasts', 1)
->assertSee('Link erstellt');
});
it('adds an error toast via toast event', function () {
Livewire::test(Toaster::class)
->dispatch('toast', message: 'Fehler beim Speichern', type: 'error')
->assertCount('toasts', 1)
->assertSee('Fehler beim Speichern');
});
it('adds multiple toasts', function () {
Livewire::test(Toaster::class)
->dispatch('toast', message: 'First', type: 'success')
->dispatch('toast', message: 'Second', type: 'error')
->assertCount('toasts', 2);
});
it('dismisses a toast by id', function () {
$toaster = Livewire::test(Toaster::class)
->dispatch('toast', message: 'Wird gelöscht', type: 'info');
$id = $toaster->get('toasts')[0]['id'];
$toaster->call('dismiss', $id)
->assertCount('toasts', 0);
});
it('toast has required fields', function () {
$toaster = Livewire::test(Toaster::class)
->dispatch('toast', message: 'Test', type: 'warning');
$toast = $toaster->get('toasts')[0];
expect($toast)->toHaveKeys(['id', 'message', 'type', 'duration'])
->and($toast['message'])->toBe('Test')
->and($toast['type'])->toBe('warning')
->and($toast['duration'])->toBeInt()->toBeGreaterThan(0);
});

View File

@ -22,7 +22,9 @@ export default defineConfig({
'laravel-echo', 'laravel-echo',
'pusher-js', 'pusher-js',
'chart.js', 'chart.js',
'chart.js/auto',
], ],
force: true,
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',