From 5e2f4a1fa7211e40fff3738d32e38fdc31056eeb Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sat, 12 Sep 2026 21:18:19 +0100 Subject: [PATCH] updates for admin area --- CRUD_NOTES.md | 32 ++ .../Controllers/Admin/ActionController.php | 42 +-- app/Http/Controllers/Admin/CrudController.php | 310 ++++++++++++++++++ .../Controllers/Admin/SectionController.php | 59 ---- .../Controllers/ContentPageController.php | 16 + app/Http/Controllers/LegalController.php | 47 +++ app/Models/AdminNotification.php | 15 + app/Models/AppSetting.php | 10 + app/Models/ContentPage.php | 15 + app/Models/LegalDocument.php | 15 + app/Models/Message.php | 25 ++ app/Models/Payment.php | 28 ++ app/Models/SavedReport.php | 15 + app/Models/SupportTicket.php | 18 + ..._000004_create_admin_management_tables.php | 107 ++++++ .../{app-B873fQy4.css => app-DbTvU8vz.css} | 2 +- public/build/manifest.json | 2 +- resources/css/app.css | 7 + resources/views/admin/crud/form.blade.php | 55 ++++ resources/views/admin/crud/index.blade.php | 62 ++++ resources/views/admin/dashboard.blade.php | 8 +- resources/views/admin/section.blade.php | 38 --- .../views/admin/sections/bookings.blade.php | 4 - .../views/admin/sections/clients.blade.php | 4 - .../views/admin/sections/content.blade.php | 4 - resources/views/admin/sections/djs.blade.php | 4 - .../views/admin/sections/messages.blade.php | 4 - .../admin/sections/notifications.blade.php | 4 - .../views/admin/sections/payments.blade.php | 4 - .../views/admin/sections/privacy.blade.php | 4 - .../views/admin/sections/reports.blade.php | 4 - .../views/admin/sections/reviews.blade.php | 4 - .../views/admin/sections/roles.blade.php | 4 - .../views/admin/sections/settings.blade.php | 4 - .../views/admin/sections/support.blade.php | 4 - .../views/admin/sections/users.blade.php | 4 - resources/views/content/show.blade.php | 9 + resources/views/layouts/admin.blade.php | 5 +- resources/views/legal/page.blade.php | 10 + routes/web.php | 23 +- tests/Feature/AccountAndAdminRoutesTest.php | 3 +- tests/Feature/AdminCrudTest.php | 70 ++++ 42 files changed, 894 insertions(+), 210 deletions(-) create mode 100644 CRUD_NOTES.md create mode 100644 app/Http/Controllers/Admin/CrudController.php delete mode 100644 app/Http/Controllers/Admin/SectionController.php create mode 100644 app/Http/Controllers/ContentPageController.php create mode 100644 app/Http/Controllers/LegalController.php create mode 100644 app/Models/AdminNotification.php create mode 100644 app/Models/AppSetting.php create mode 100644 app/Models/ContentPage.php create mode 100644 app/Models/LegalDocument.php create mode 100644 app/Models/Message.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/SavedReport.php create mode 100644 app/Models/SupportTicket.php create mode 100644 database/migrations/2026_09_12_000004_create_admin_management_tables.php rename public/build/assets/{app-B873fQy4.css => app-DbTvU8vz.css} (87%) create mode 100644 resources/views/admin/crud/form.blade.php create mode 100644 resources/views/admin/crud/index.blade.php delete mode 100644 resources/views/admin/section.blade.php delete mode 100644 resources/views/admin/sections/bookings.blade.php delete mode 100644 resources/views/admin/sections/clients.blade.php delete mode 100644 resources/views/admin/sections/content.blade.php delete mode 100644 resources/views/admin/sections/djs.blade.php delete mode 100644 resources/views/admin/sections/messages.blade.php delete mode 100644 resources/views/admin/sections/notifications.blade.php delete mode 100644 resources/views/admin/sections/payments.blade.php delete mode 100644 resources/views/admin/sections/privacy.blade.php delete mode 100644 resources/views/admin/sections/reports.blade.php delete mode 100644 resources/views/admin/sections/reviews.blade.php delete mode 100644 resources/views/admin/sections/roles.blade.php delete mode 100644 resources/views/admin/sections/settings.blade.php delete mode 100644 resources/views/admin/sections/support.blade.php delete mode 100644 resources/views/admin/sections/users.blade.php create mode 100644 resources/views/content/show.blade.php create mode 100644 resources/views/legal/page.blade.php create mode 100644 tests/Feature/AdminCrudTest.php diff --git a/CRUD_NOTES.md b/CRUD_NOTES.md new file mode 100644 index 0000000..cd16f3a --- /dev/null +++ b/CRUD_NOTES.md @@ -0,0 +1,32 @@ +# BeatBook Admin CRUD + +This revision replaces the read-only/placeholder administration sections with persistent CRUD management. + +## CRUD resources + +- Users +- Clients +- DJs +- Businesses +- Bookings +- Payments +- Reviews +- Messages +- Notifications +- Support tickets +- Saved reports +- Content pages +- Roles +- Permissions +- Privacy/legal documents +- Application settings + +## New persistence + +Migration `2026_09_12_000004_create_admin_management_tables.php` adds tables for payments, messages, admin notifications, support tickets, saved reports, content pages, legal documents and application settings. + +Run `php artisan migrate` on an existing installation. Do not use `migrate:fresh` unless you intentionally want to erase existing data. + +Published Privacy, Cookie and Terms records are used by the corresponding public legal pages. Published Content records are available at `/pages/{slug}`. + +Notification records are currently managed content; creating a record does not yet dispatch email/push notifications. Saved reports are definitions; a later reporting engine can execute/export them. diff --git a/app/Http/Controllers/Admin/ActionController.php b/app/Http/Controllers/Admin/ActionController.php index 749623b..c59af06 100644 --- a/app/Http/Controllers/Admin/ActionController.php +++ b/app/Http/Controllers/Admin/ActionController.php @@ -3,53 +3,17 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\DjProfile; -use App\Models\Review; -use App\Models\User; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; -use Spatie\Permission\Models\Role; -use Spatie\Permission\PermissionRegistrar; class ActionController extends Controller { - public function userRole(Request $request, User $user): RedirectResponse - { - $data = $request->validate(['role' => ['required', 'in:super-admin,admin,dj,client,moderator,support']]); - $role = Role::firstOrCreate(['name' => $data['role'], 'guard_name' => 'web']); - $user->syncRoles([$role]); - app(PermissionRegistrar::class)->forgetCachedPermissions(); - - return back()->with('success', "{$user->name}'s role has been updated."); - } - - public function toggleDjPublished(DjProfile $djProfile): RedirectResponse - { - $djProfile->update(['is_published' => ! $djProfile->is_published]); - - return back()->with('success', 'DJ publication status updated.'); - } - - public function toggleDjFeatured(DjProfile $djProfile): RedirectResponse - { - $djProfile->update(['is_featured' => ! $djProfile->is_featured]); - - return back()->with('success', 'DJ featured status updated.'); - } - - public function reviewStatus(Request $request, Review $review): RedirectResponse - { - $data = $request->validate(['status' => ['required', 'in:pending,published,rejected']]); - $review->update($data); - - return back()->with('success', 'Review moderation status updated.'); - } - public function clearCaches(): RedirectResponse { Cache::flush(); + Artisan::call('optimize:clear'); - return back()->with('success', 'Application cache cleared.'); + return back()->with('success', 'Application caches cleared.'); } } diff --git a/app/Http/Controllers/Admin/CrudController.php b/app/Http/Controllers/Admin/CrudController.php new file mode 100644 index 0000000..5a64740 --- /dev/null +++ b/app/Http/Controllers/Admin/CrudController.php @@ -0,0 +1,310 @@ +definition($resource); + $query = $this->query($resource, $definition); + + if ($search = trim((string) $request->query('q'))) { + $query->where(function (Builder $query) use ($definition, $search) { + foreach ($definition['search'] ?? [] as $column) { + $query->orWhere($column, 'like', '%'.$search.'%'); + } + }); + } + + $records = $query->latest($definition['order_by'] ?? 'created_at')->paginate(20)->withQueryString(); + + return view('admin.crud.index', compact('resource', 'definition', 'records')); + } + + public function create(string $resource): View + { + $definition = $this->definition($resource); + $record = null; + $options = $this->options($resource); + + return view('admin.crud.form', compact('resource', 'definition', 'record', 'options')); + } + + public function store(Request $request, string $resource): RedirectResponse + { + $definition = $this->definition($resource); + $data = $request->validate($this->rules($resource, null)); + $record = $this->persist($resource, new $definition['model'], $data, true); + + return redirect()->route('admin.resources.index', $resource) + ->with('success', $definition['singular'].' created successfully.'); + } + + public function edit(string $resource, string $id): View + { + $definition = $this->definition($resource); + $record = $this->find($resource, $definition, $id); + $options = $this->options($resource); + + return view('admin.crud.form', compact('resource', 'definition', 'record', 'options')); + } + + public function update(Request $request, string $resource, string $id): RedirectResponse + { + $definition = $this->definition($resource); + $record = $this->find($resource, $definition, $id); + $data = $request->validate($this->rules($resource, $record)); + $this->persist($resource, $record, $data, false); + + return redirect()->route('admin.resources.index', $resource) + ->with('success', $definition['singular'].' updated successfully.'); + } + + public function destroy(Request $request, string $resource, string $id): RedirectResponse + { + $definition = $this->definition($resource); + $record = $this->find($resource, $definition, $id); + + if ($resource === 'users' && $record->is($request->user())) { + return back()->with('error', 'You cannot delete your own administrator account.'); + } + + if ($resource === 'roles' && in_array($record->name, ['admin', 'super-admin'], true)) { + return back()->with('error', 'The core administrator roles cannot be deleted.'); + } + + $record->delete(); + + return redirect()->route('admin.resources.index', $resource) + ->with('success', $definition['singular'].' deleted.'); + } + + private function definition(string $resource): array + { + $definitions = [ + 'users' => [ + 'title' => 'Users', 'singular' => 'User', 'model' => User::class, 'search' => ['name', 'email'], 'with' => ['roles'], + 'columns' => ['name' => 'Name', 'email' => 'Email', 'roles_text' => 'Role', 'created_at' => 'Joined'], + 'fields' => [ + 'name' => ['label' => 'Name', 'type' => 'text'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'], + 'role' => ['label' => 'Role', 'type' => 'select', 'options' => 'roles', 'nullable' => true], 'password' => ['label' => 'Password', 'type' => 'password', 'help' => 'Leave blank when editing to keep the current password.'], + 'marketing_opt_in' => ['label' => 'Marketing opt-in', 'type' => 'checkbox'], + ], + ], + 'clients' => [ + 'title' => 'Clients', 'singular' => 'Client', 'model' => User::class, 'search' => ['name', 'email'], 'with' => ['roles'], 'scope' => 'client', + 'columns' => ['name' => 'Name', 'email' => 'Email', 'phone' => 'Phone', 'created_at' => 'Joined'], + 'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'], 'password' => ['label' => 'Password', 'type' => 'password'], 'marketing_opt_in' => ['label' => 'Marketing opt-in', 'type' => 'checkbox']], + ], + 'djs' => [ + 'title' => 'DJs', 'singular' => 'DJ profile', 'model' => DjProfile::class, 'search' => ['stage_name', 'city', 'headline'], 'with' => ['user', 'business'], + 'columns' => ['stage_name' => 'Stage name', 'user.name' => 'Account', 'city' => 'City', 'price_from' => 'From', 'is_published' => 'Published', 'is_featured' => 'Featured'], + 'fields' => ['user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users'], 'business_id' => ['label' => 'Business', 'type' => 'select', 'options' => 'businesses', 'nullable' => true], 'stage_name' => ['label' => 'Stage name', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'headline' => ['label' => 'Headline', 'type' => 'text'], 'bio' => ['label' => 'Biography', 'type' => 'textarea'], 'city' => ['label' => 'City', 'type' => 'text'], 'service_radius_miles' => ['label' => 'Service radius (miles)', 'type' => 'number'], 'price_from' => ['label' => 'Price from (£)', 'type' => 'number', 'step' => '0.01'], 'is_published' => ['label' => 'Published', 'type' => 'checkbox'], 'is_featured' => ['label' => 'Featured', 'type' => 'checkbox']], + ], + 'businesses' => [ + 'title' => 'Businesses', 'singular' => 'Business', 'model' => Business::class, 'search' => ['name', 'email', 'status'], + 'columns' => ['name' => 'Name', 'email' => 'Email', 'phone' => 'Phone', 'status' => 'Status', 'verified_at' => 'Verified'], + 'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'], 'website' => ['label' => 'Website', 'type' => 'url'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['active' => 'Active', 'suspended' => 'Suspended', 'pending' => 'Pending']], 'verified_at' => ['label' => 'Verified at', 'type' => 'datetime-local']], + ], + 'bookings' => [ + 'title' => 'Bookings', 'singular' => 'Booking', 'model' => Booking::class, 'search' => ['status'], 'with' => ['client', 'djProfile'], 'order_by' => 'event_date', + 'columns' => ['client.name' => 'Client', 'djProfile.stage_name' => 'DJ', 'event_date' => 'Event date', 'total' => 'Total', 'deposit' => 'Deposit', 'status' => 'Status'], + 'fields' => ['client_id' => ['label' => 'Client', 'type' => 'select', 'options' => 'users'], 'dj_profile_id' => ['label' => 'DJ', 'type' => 'select', 'options' => 'djs'], 'event_date' => ['label' => 'Event date', 'type' => 'date'], 'total' => ['label' => 'Total (£)', 'type' => 'number', 'step' => '0.01'], 'deposit' => ['label' => 'Deposit (£)', 'type' => 'number', 'step' => '0.01'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'confirmed' => 'Confirmed', 'completed' => 'Completed', 'cancelled' => 'Cancelled']], 'paid_at' => ['label' => 'Paid at', 'type' => 'datetime-local']], + ], + 'payments' => [ + 'title' => 'Payments', 'singular' => 'Payment', 'model' => Payment::class, 'search' => ['reference', 'provider', 'status'], 'with' => ['user', 'booking'], + 'columns' => ['reference' => 'Reference', 'user.name' => 'User', 'amount' => 'Amount', 'provider' => 'Provider', 'status' => 'Status', 'paid_at' => 'Paid'], + 'fields' => ['booking_id' => ['label' => 'Booking', 'type' => 'select', 'options' => 'bookings', 'nullable' => true], 'user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'amount' => ['label' => 'Amount (£)', 'type' => 'number', 'step' => '0.01'], 'provider' => ['label' => 'Provider', 'type' => 'select', 'values' => ['manual' => 'Manual', 'stripe' => 'Stripe', 'bank' => 'Bank transfer']], 'reference' => ['label' => 'Reference', 'type' => 'text'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'paid' => 'Paid', 'failed' => 'Failed', 'refunded' => 'Refunded']], 'paid_at' => ['label' => 'Paid at', 'type' => 'datetime-local'], 'notes' => ['label' => 'Notes', 'type' => 'textarea']], + ], + 'reviews' => [ + 'title' => 'Reviews', 'singular' => 'Review', 'model' => Review::class, 'search' => ['title', 'body', 'status'], 'with' => ['user', 'djProfile'], + 'columns' => ['user.name' => 'User', 'djProfile.stage_name' => 'DJ', 'rating' => 'Rating', 'title' => 'Title', 'status' => 'Status'], + 'fields' => ['booking_id' => ['label' => 'Booking', 'type' => 'select', 'options' => 'bookings'], 'user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users'], 'dj_profile_id' => ['label' => 'DJ', 'type' => 'select', 'options' => 'djs'], 'rating' => ['label' => 'Rating', 'type' => 'number', 'min' => 1, 'max' => 5], 'title' => ['label' => 'Title', 'type' => 'text'], 'body' => ['label' => 'Review', 'type' => 'textarea'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'published' => 'Published', 'rejected' => 'Rejected']], 'dj_reply' => ['label' => 'DJ reply', 'type' => 'textarea']], + ], + 'messages' => [ + 'title' => 'Messages', 'singular' => 'Message', 'model' => Message::class, 'search' => ['subject', 'body', 'status'], 'with' => ['fromUser', 'toUser'], + 'columns' => ['subject' => 'Subject', 'fromUser.name' => 'From', 'toUser.name' => 'To', 'status' => 'Status', 'created_at' => 'Created'], + 'fields' => ['from_user_id' => ['label' => 'From user', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'to_user_id' => ['label' => 'To user', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'subject' => ['label' => 'Subject', 'type' => 'text'], 'body' => ['label' => 'Message', 'type' => 'textarea'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['unread' => 'Unread', 'read' => 'Read', 'archived' => 'Archived']], 'read_at' => ['label' => 'Read at', 'type' => 'datetime-local']], + ], + 'notifications' => [ + 'title' => 'Notifications', 'singular' => 'Notification', 'model' => AdminNotification::class, 'search' => ['title', 'body', 'audience', 'status'], + 'columns' => ['title' => 'Title', 'audience' => 'Audience', 'status' => 'Status', 'published_at' => 'Published'], + 'fields' => ['title' => ['label' => 'Title', 'type' => 'text'], 'body' => ['label' => 'Message', 'type' => 'textarea'], 'audience' => ['label' => 'Audience', 'type' => 'select', 'values' => ['all' => 'Everyone', 'clients' => 'Clients', 'djs' => 'DJs', 'admins' => 'Admins']], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']], + ], + 'support' => [ + 'title' => 'Support tickets', 'singular' => 'Support ticket', 'model' => SupportTicket::class, 'search' => ['subject', 'category', 'status', 'priority'], 'with' => ['user'], + 'columns' => ['subject' => 'Subject', 'user.name' => 'User', 'category' => 'Category', 'priority' => 'Priority', 'status' => 'Status'], + 'fields' => ['user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'subject' => ['label' => 'Subject', 'type' => 'text'], 'category' => ['label' => 'Category', 'type' => 'select', 'values' => ['general' => 'General', 'account' => 'Account', 'booking' => 'Booking', 'payment' => 'Payment', 'technical' => 'Technical']], 'priority' => ['label' => 'Priority', 'type' => 'select', 'values' => ['low' => 'Low', 'normal' => 'Normal', 'high' => 'High', 'urgent' => 'Urgent']], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['open' => 'Open', 'in-progress' => 'In progress', 'resolved' => 'Resolved', 'closed' => 'Closed']], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'resolution' => ['label' => 'Resolution', 'type' => 'textarea']], + ], + 'reports' => [ + 'title' => 'Saved reports', 'singular' => 'Report', 'model' => SavedReport::class, 'search' => ['name', 'type', 'description'], + 'columns' => ['name' => 'Name', 'type' => 'Type', 'is_active' => 'Active', 'updated_at' => 'Updated'], + 'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'type' => ['label' => 'Type', 'type' => 'select', 'values' => ['overview' => 'Overview', 'bookings' => 'Bookings', 'revenue' => 'Revenue', 'users' => 'Users', 'djs' => 'DJs', 'reviews' => 'Reviews']], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'filters' => ['label' => 'Filters (JSON)', 'type' => 'textarea'], 'is_active' => ['label' => 'Active', 'type' => 'checkbox']], + ], + 'content' => [ + 'title' => 'Content', 'singular' => 'Content page', 'model' => ContentPage::class, 'search' => ['title', 'slug', 'status'], + 'columns' => ['title' => 'Title', 'slug' => 'Slug', 'status' => 'Status', 'published_at' => 'Published'], + 'fields' => ['title' => ['label' => 'Title', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'excerpt' => ['label' => 'Excerpt', 'type' => 'textarea'], 'body' => ['label' => 'Body', 'type' => 'textarea', 'large' => true], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']], + ], + 'roles' => [ + 'title' => 'Roles & permissions', 'singular' => 'Role', 'model' => Role::class, 'search' => ['name'], 'with_count' => ['users'], + 'columns' => ['name' => 'Role', 'users_count' => 'Users', 'created_at' => 'Created'], + 'fields' => ['name' => ['label' => 'Role name', 'type' => 'text']], + ], + 'permissions' => [ + 'title' => 'Permissions', 'singular' => 'Permission', 'model' => Permission::class, 'search' => ['name'], + 'columns' => ['name' => 'Permission', 'created_at' => 'Created', 'updated_at' => 'Updated'], + 'fields' => ['name' => ['label' => 'Permission name', 'type' => 'text']], + ], + 'privacy' => [ + 'title' => 'Privacy & legal', 'singular' => 'Legal document', 'model' => LegalDocument::class, 'search' => ['type', 'title', 'version', 'status'], + 'columns' => ['type' => 'Type', 'title' => 'Title', 'version' => 'Version', 'status' => 'Status', 'published_at' => 'Published'], + 'fields' => ['type' => ['label' => 'Document type', 'type' => 'select', 'values' => ['privacy' => 'Privacy policy', 'cookies' => 'Cookie policy', 'terms' => 'Terms & conditions', 'dpa' => 'Data processing']], 'title' => ['label' => 'Title', 'type' => 'text'], 'version' => ['label' => 'Version', 'type' => 'text'], 'body' => ['label' => 'Document', 'type' => 'textarea', 'large' => true], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']], + ], + 'settings' => [ + 'title' => 'Settings', 'singular' => 'Setting', 'model' => AppSetting::class, 'search' => ['key', 'label', 'group'], + 'columns' => ['label' => 'Setting', 'key' => 'Key', 'value' => 'Value', 'group' => 'Group', 'type' => 'Type'], + 'fields' => ['label' => ['label' => 'Label', 'type' => 'text'], 'key' => ['label' => 'Key', 'type' => 'text'], 'value' => ['label' => 'Value', 'type' => 'textarea'], 'type' => ['label' => 'Type', 'type' => 'select', 'values' => ['text' => 'Text', 'number' => 'Number', 'boolean' => 'Boolean', 'email' => 'Email', 'url' => 'URL', 'json' => 'JSON']], 'group' => ['label' => 'Group', 'type' => 'text'], 'description' => ['label' => 'Description', 'type' => 'textarea']], + ], + ]; + + abort_unless(isset($definitions[$resource]), 404); + + return $definitions[$resource]; + } + + private function query(string $resource, array $definition): Builder + { + /** @var Builder $query */ + $query = $definition['model']::query(); + if (! empty($definition['with'])) { + $query->with($definition['with']); + } + if (! empty($definition['with_count'])) { + $query->withCount($definition['with_count']); + } + if (($definition['scope'] ?? null) === 'client') { + $query->whereHas('roles', fn (Builder $q) => $q->where('name', 'client')); + } + + return $query; + } + + private function find(string $resource, array $definition, string $id): Model + { + $query = $this->query($resource, $definition); + + return $query->findOrFail($id); + } + + private function rules(string $resource, ?Model $record): array + { + $id = $record?->getKey(); + + return match ($resource) { + 'users', 'clients' => ['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($id)], 'phone' => ['nullable', 'string', 'max:50'], 'password' => [$record ? 'nullable' : 'required', 'string', 'min:8'], 'role' => ['nullable', 'string', 'max:50'], 'marketing_opt_in' => ['nullable', 'boolean']], + 'djs' => ['user_id' => ['required', 'exists:users,id'], 'business_id' => ['nullable', 'exists:businesses,id'], 'stage_name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('dj_profiles')->ignore($id)], 'headline' => ['nullable', 'string', 'max:255'], 'bio' => ['nullable', 'string'], 'city' => ['required', 'string', 'max:255'], 'service_radius_miles' => ['required', 'integer', 'min:0', 'max:500'], 'price_from' => ['nullable', 'numeric', 'min:0'], 'is_published' => ['nullable', 'boolean'], 'is_featured' => ['nullable', 'boolean']], + 'businesses' => ['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('businesses')->ignore($id)], 'description' => ['nullable', 'string'], 'email' => ['nullable', 'email'], 'phone' => ['nullable', 'string', 'max:50'], 'website' => ['nullable', 'url'], 'status' => ['required', Rule::in(['active', 'suspended', 'pending'])], 'verified_at' => ['nullable', 'date']], + 'bookings' => ['client_id' => ['required', 'exists:users,id'], 'dj_profile_id' => ['required', 'exists:dj_profiles,id'], 'event_date' => ['required', 'date'], 'total' => ['required', 'numeric', 'min:0'], 'deposit' => ['required', 'numeric', 'min:0'], 'status' => ['required', Rule::in(['pending', 'confirmed', 'completed', 'cancelled'])], 'paid_at' => ['nullable', 'date']], + 'payments' => ['booking_id' => ['nullable', 'exists:bookings,id'], 'user_id' => ['nullable', 'exists:users,id'], 'amount' => ['required', 'numeric', 'min:0'], 'provider' => ['required', Rule::in(['manual', 'stripe', 'bank'])], 'reference' => ['nullable', 'string', 'max:255'], 'status' => ['required', Rule::in(['pending', 'paid', 'failed', 'refunded'])], 'paid_at' => ['nullable', 'date'], 'notes' => ['nullable', 'string']], + 'reviews' => ['booking_id' => ['required', 'exists:bookings,id', Rule::unique('reviews')->ignore($id)], 'user_id' => ['required', 'exists:users,id'], 'dj_profile_id' => ['required', 'exists:dj_profiles,id'], 'rating' => ['required', 'integer', 'between:1,5'], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['pending', 'published', 'rejected'])], 'dj_reply' => ['nullable', 'string']], + 'messages' => ['from_user_id' => ['nullable', 'exists:users,id'], 'to_user_id' => ['nullable', 'exists:users,id'], 'subject' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['unread', 'read', 'archived'])], 'read_at' => ['nullable', 'date']], + 'notifications' => ['title' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'audience' => ['required', Rule::in(['all', 'clients', 'djs', 'admins'])], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']], + 'support' => ['user_id' => ['nullable', 'exists:users,id'], 'subject' => ['required', 'string', 'max:255'], 'category' => ['required', 'string', 'max:50'], 'priority' => ['required', Rule::in(['low', 'normal', 'high', 'urgent'])], 'status' => ['required', Rule::in(['open', 'in-progress', 'resolved', 'closed'])], 'description' => ['required', 'string'], 'resolution' => ['nullable', 'string']], + 'reports' => ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'string', 'max:50'], 'description' => ['nullable', 'string'], 'filters' => ['nullable', 'json'], 'is_active' => ['nullable', 'boolean']], + 'content' => ['title' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('content_pages')->ignore($id)], 'excerpt' => ['nullable', 'string'], 'body' => ['nullable', 'string'], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']], + 'roles' => ['name' => ['required', 'string', 'max:125', Rule::unique('roles')->where('guard_name', 'web')->ignore($id)]], + 'permissions' => ['name' => ['required', 'string', 'max:125', Rule::unique('permissions')->where('guard_name', 'web')->ignore($id)]], + 'privacy' => ['type' => ['required', 'string', 'max:50'], 'title' => ['required', 'string', 'max:255'], 'version' => ['required', 'string', 'max:50'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']], + 'settings' => ['label' => ['required', 'string', 'max:255'], 'key' => ['required', 'string', 'max:255', Rule::unique('app_settings')->ignore($id)], 'value' => ['nullable', 'string'], 'type' => ['required', 'string', 'max:30'], 'group' => ['required', 'string', 'max:100'], 'description' => ['nullable', 'string']], + default => abort(404), + }; + } + + private function persist(string $resource, Model $record, array $data, bool $creating): Model + { + foreach (['marketing_opt_in', 'is_published', 'is_featured', 'is_active'] as $boolean) { + if (array_key_exists($boolean, $data)) { + $data[$boolean] = (bool) $data[$boolean]; + } + } + if (isset($data['slug']) && blank($data['slug'])) { + $data['slug'] = Str::slug($data['stage_name'] ?? $data['name'] ?? $data['title'] ?? Str::random(8)); + } + if ($resource === 'reports' && isset($data['filters'])) { + $data['filters'] = blank($data['filters']) ? null : json_decode($data['filters'], true, flags: JSON_THROW_ON_ERROR); + } + + if (in_array($resource, ['users', 'clients'], true)) { + $role = $resource === 'clients' ? 'client' : ($data['role'] ?? null); + unset($data['role']); + if (blank($data['password'] ?? null)) { + unset($data['password']); + } else { + $data['password'] = Hash::make($data['password']); + } + $record->fill($data)->save(); + if ($role) { + $roleModel = Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']); + $record->syncRoles([$roleModel]); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + return $record; + } + + if (in_array($resource, ['roles', 'permissions'], true)) { + $record->fill(['name' => $data['name'], 'guard_name' => 'web'])->save(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + return $record; + } + + $record->fill($data)->save(); + + return $record; + } + + private function options(string $resource): array + { + $roles = Role::query()->orderBy('name')->pluck('name', 'name')->all(); + foreach (['client', 'dj', 'support', 'moderator', 'admin', 'super-admin'] as $role) { + $roles[$role] ??= Str::headline($role); + } + + return [ + 'roles' => $roles, + 'users' => User::query()->orderBy('name')->pluck('name', 'id')->all(), + 'businesses' => Business::query()->orderBy('name')->pluck('name', 'id')->all(), + 'djs' => DjProfile::query()->orderBy('stage_name')->pluck('stage_name', 'id')->all(), + 'bookings' => Booking::query()->with(['client', 'djProfile'])->latest('event_date')->get()->mapWithKeys(fn (Booking $b) => [$b->id => ($b->client?->name ?? 'Client').' · '.($b->djProfile?->stage_name ?? 'DJ').' · '.$b->event_date?->format('j M Y')])->all(), + ]; + } +} diff --git a/app/Http/Controllers/Admin/SectionController.php b/app/Http/Controllers/Admin/SectionController.php deleted file mode 100644 index d4266b0..0000000 --- a/app/Http/Controllers/Admin/SectionController.php +++ /dev/null @@ -1,59 +0,0 @@ - $section, - 'counts' => [ - 'users' => User::count(), - 'djs' => DjProfile::count(), - 'businesses' => Business::count(), - 'clients' => User::whereHas('roles', fn ($query) => $query->where('name', 'client'))->count(), - 'bookings' => Booking::count(), - 'enquiries' => Enquiry::count(), - 'reviews' => Review::count(), - ], - ]; - - $data += match ($section) { - 'users' => ['users' => User::with('roles')->latest()->paginate(20)], - 'djs' => ['djs' => DjProfile::with(['user', 'business'])->latest()->paginate(20)], - 'clients' => ['clients' => User::whereHas('roles', fn ($query) => $query->where('name', 'client'))->withCount(['enquiries', 'bookings'])->latest()->paginate(20)], - 'bookings' => ['bookings' => Booking::with(['client', 'djProfile'])->latest('event_date')->paginate(20)], - 'payments' => ['bookings' => Booking::with(['client', 'djProfile'])->latest()->paginate(20)], - 'reviews' => ['reviews' => Review::with(['user', 'djProfile'])->latest()->paginate(20)], - 'roles' => ['roles' => Role::withCount('users')->orderBy('name')->get()], - 'reports' => ['report' => [ - 'booking_value' => Booking::sum('total'), - 'deposits' => Booking::sum('deposit'), - 'published_djs' => DjProfile::where('is_published', true)->count(), - 'pending_reviews' => Review::where('status', 'pending')->count(), - ]], - default => [], - }; - - return view("admin.sections.{$section}", $data); - } -} diff --git a/app/Http/Controllers/ContentPageController.php b/app/Http/Controllers/ContentPageController.php new file mode 100644 index 0000000..f9581b2 --- /dev/null +++ b/app/Http/Controllers/ContentPageController.php @@ -0,0 +1,16 @@ +status === 'published' && ($page->published_at === null || $page->published_at->isPast()), 404); + + return view('content.show', compact('page')); + } +} diff --git a/app/Http/Controllers/LegalController.php b/app/Http/Controllers/LegalController.php new file mode 100644 index 0000000..d8f214e --- /dev/null +++ b/app/Http/Controllers/LegalController.php @@ -0,0 +1,47 @@ +show('privacy'); + } + + public function cookies(): View + { + return $this->show('cookies'); + } + + public function terms(): View + { + return $this->show('terms'); + } + + private function show(string $type): View + { + abort_unless(in_array($type, ['privacy', 'cookies', 'terms'], true), 404); + + $document = LegalDocument::query() + ->where('type', $type) + ->where('status', 'published') + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->latest('published_at') + ->latest('updated_at') + ->first(); + + $fallbacks = [ + 'privacy' => ['Privacy Policy', 'BeatBook is designed for UK GDPR compliance, data minimisation, access/export and deletion workflows. A published privacy policy can be maintained from the administration area.'], + 'cookies' => ['Cookie Policy', 'Necessary cookies operate the service. Analytics and marketing cookies require consent. A published cookie policy can be maintained from the administration area.'], + 'terms' => ['Terms of Service', 'Marketplace, DJ, client, cancellation, payment, refund and acceptable-use terms can be maintained from the administration area.'], + ]; + + [$fallbackTitle, $fallbackBody] = $fallbacks[$type]; + + return view('legal.page', compact('document', 'fallbackTitle', 'fallbackBody', 'type')); + } +} diff --git a/app/Models/AdminNotification.php b/app/Models/AdminNotification.php new file mode 100644 index 0000000..008746d --- /dev/null +++ b/app/Models/AdminNotification.php @@ -0,0 +1,15 @@ + 'datetime']; + } +} diff --git a/app/Models/AppSetting.php b/app/Models/AppSetting.php new file mode 100644 index 0000000..eecb2ec --- /dev/null +++ b/app/Models/AppSetting.php @@ -0,0 +1,10 @@ + 'datetime']; + } +} diff --git a/app/Models/LegalDocument.php b/app/Models/LegalDocument.php new file mode 100644 index 0000000..a976a41 --- /dev/null +++ b/app/Models/LegalDocument.php @@ -0,0 +1,15 @@ + 'datetime']; + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..efba61a --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,25 @@ + 'datetime']; + } + + public function fromUser() + { + return $this->belongsTo(User::class, 'from_user_id'); + } + + public function toUser() + { + return $this->belongsTo(User::class, 'to_user_id'); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..1835de8 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,28 @@ + 'decimal:2', 'paid_at' => 'datetime']; + } + + public function booking() + { + return $this->belongsTo(Booking::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/SavedReport.php b/app/Models/SavedReport.php new file mode 100644 index 0000000..dbf1276 --- /dev/null +++ b/app/Models/SavedReport.php @@ -0,0 +1,15 @@ + 'array', 'is_active' => 'boolean']; + } +} diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php new file mode 100644 index 0000000..c86c752 --- /dev/null +++ b/app/Models/SupportTicket.php @@ -0,0 +1,18 @@ +belongsTo(User::class); + } +} diff --git a/database/migrations/2026_09_12_000004_create_admin_management_tables.php b/database/migrations/2026_09_12_000004_create_admin_management_tables.php new file mode 100644 index 0000000..75ee70a --- /dev/null +++ b/database/migrations/2026_09_12_000004_create_admin_management_tables.php @@ -0,0 +1,107 @@ +ulid('id')->primary(); + $table->foreignUlid('booking_id')->nullable()->constrained('bookings')->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->decimal('amount', 10, 2); + $table->string('provider')->default('manual'); + $table->string('reference')->nullable()->index(); + $table->string('status')->default('pending')->index(); + $table->timestamp('paid_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('messages', function (Blueprint $table) { + $table->id(); + $table->foreignId('from_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('to_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('subject'); + $table->text('body'); + $table->string('status')->default('unread')->index(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('admin_notifications', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('body'); + $table->string('audience')->default('all')->index(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('support_tickets', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('subject'); + $table->string('category')->default('general'); + $table->string('priority')->default('normal')->index(); + $table->string('status')->default('open')->index(); + $table->text('description'); + $table->text('resolution')->nullable(); + $table->timestamps(); + }); + + Schema::create('saved_reports', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('type')->default('overview'); + $table->text('description')->nullable(); + $table->json('filters')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('content_pages', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('body')->nullable(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('legal_documents', function (Blueprint $table) { + $table->id(); + $table->string('type')->index(); + $table->string('title'); + $table->string('version')->default('1.0'); + $table->longText('body'); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('app_settings', function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->string('label'); + $table->text('value')->nullable(); + $table->string('type')->default('text'); + $table->string('group')->default('general')->index(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + foreach (['app_settings', 'legal_documents', 'content_pages', 'saved_reports', 'support_tickets', 'admin_notifications', 'messages', 'payments'] as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/public/build/assets/app-B873fQy4.css b/public/build/assets/app-DbTvU8vz.css similarity index 87% rename from public/build/assets/app-B873fQy4.css rename to public/build/assets/app-DbTvU8vz.css index 1fdc030..c7b52a0 100644 --- a/public/build/assets/app-B873fQy4.css +++ b/public/build/assets/app-DbTvU8vz.css @@ -1 +1 @@ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.hidden{display:none}.table{display:table}.grow{flex-grow:1}.lowercase{text-transform:lowercase}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}:root{--bg:#070914;--panel:#101526;--panel-2:#151c31;--line:#26304a;--text:#f8fafc;--muted:#9aa8bd;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24;--white:#fff;--admin-bg:#f5f7fb;--admin-ink:#172033;--admin-muted:#65738a;--admin-line:#e4e9f1;--shadow:0 18px 55px #0000003d}*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}a{color:inherit}.shell{width:min(100% - 40px,1240px);margin-inline:auto}.muted{color:var(--muted)}.site-header{-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);z-index:40;background:#070914e0;border-bottom:1px solid #ffffff12;height:76px;position:sticky;top:0}.nav{align-items:center;gap:32px;height:100%;display:flex}.brand{letter-spacing:-.04em;white-space:nowrap;align-items:center;gap:8px;font-size:24px;font-weight:900;text-decoration:none;display:inline-flex}.brand b{color:var(--pink)}.nav-panel{align-items:center;gap:34px;margin-left:auto;display:flex}.navlinks{align-items:center;gap:26px;display:flex}.navlinks a,.text-button{color:#dce5f4;cursor:pointer;font-size:14px;font-weight:700;font:inherit;background:0 0;border:0;padding:8px 0;text-decoration:none}.navlinks a:hover,.text-button:hover{color:#fff}.nav-actions{align-items:center;gap:12px;display:flex}.nav-account-link{color:#fff;align-items:center;gap:8px;font-size:14px;font-weight:800;text-decoration:none;display:flex}.mini-avatar{background:linear-gradient(135deg,var(--purple),var(--pink));border-radius:50%;place-items:center;width:31px;height:31px;font-size:12px;display:grid}.mobile-menu-button{color:#fff;background:0 0;border:1px solid #34405d;border-radius:10px;width:42px;height:42px;margin-left:auto;font-size:21px;display:none}.inline-form{margin:0}.button{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;min-height:46px;font:inherit;cursor:pointer;border:0;border-radius:12px;justify-content:center;align-items:center;gap:8px;padding:0 19px;font-size:14px;font-weight:850;text-decoration:none;transition:transform .18s,filter .18s;display:inline-flex;box-shadow:0 9px 24px #7c3aed3d}.button:hover{filter:brightness(1.06);transform:translateY(-1px)}.button.ghost{box-shadow:none;background:0 0;border:1px solid #3a4663}.button.small{min-height:40px;padding:0 15px}.button.full{width:100%}.hero{background:url(https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2200&q=84) 50% 38%/cover no-repeat;align-items:center;min-height:640px;display:flex;position:relative}.hero-shade{background:linear-gradient(90deg,#070914fa,#070914e8 44%,#07091478,#0709142e);position:absolute;inset:0}.hero-layout{z-index:1;position:relative}.hero-copy{max-width:790px;padding:82px 0}.eyebrow{color:#67e8f9;letter-spacing:.18em;text-transform:uppercase;font-size:12px;font-weight:900}.hero h1{letter-spacing:-.06em;max-width:760px;margin:14px 0 22px;font-size:clamp(46px,6vw,78px);line-height:.97}.hero h1 span{color:#0000;background:linear-gradient(90deg,#c084fc,#f472b6,#67e8f9);-webkit-background-clip:text;background-clip:text}.hero-copy>p{color:#c3cede;max-width:650px;font-size:18px;line-height:1.65}.searchbox,.filterbar{background:#fff;border-radius:18px;gap:8px;padding:9px;display:grid;box-shadow:0 24px 70px #0000006b}.searchbox{grid-template-columns:1.2fr 1fr 1fr auto;margin-top:30px}.searchbox label,.filterbar label{background:#f6f8fb;border-radius:11px;gap:3px;padding:8px 12px;display:grid}.searchbox label span,.filterbar label span{color:#64748b;text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:900}.searchbox input,.searchbox select,.filterbar input,.filterbar select{color:#172033;width:100%;min-width:0;font:inherit;background:0 0;border:0;outline:0;font-size:14px}.hero-proof{color:#cbd5e1;flex-wrap:wrap;gap:22px;margin-top:18px;font-size:13px;font-weight:700;display:flex}.section{padding:78px 0}.section-alt{background:#0c1120;border-block:1px solid #ffffff0f}.section-head{justify-content:space-between;align-items:end;gap:24px;margin-bottom:28px;display:flex}.section-head h2,.section-intro h2,.split-feature h2,.pricing-box h2{letter-spacing:-.04em;margin:6px 0 8px;font-size:clamp(30px,4vw,44px)}.section-head p,.section-intro p,.split-feature .lead,.pricing-box p{color:var(--muted);line-height:1.65}.cards{gap:20px;display:grid}.dj-cards{grid-template-columns:repeat(4,minmax(0,1fr))}.card{background:var(--panel);border:1px solid var(--line);border-radius:18px;overflow:hidden;box-shadow:0 12px 34px #0000002e}.dj-card{min-width:0}.card-img{aspect-ratio:4/3;background-position:50%;background-size:cover;display:block}.card-body{padding:18px}.card-title-line{align-items:center;gap:8px;display:flex}.card-title-line h3{letter-spacing:-.02em;margin:0;font-size:19px}.verified{color:#fff;background:#2563eb;border-radius:50%;place-items:center;width:20px;height:20px;font-size:12px;font-weight:900;display:inline-grid}.verified.large{width:25px;height:25px}.stars{color:var(--gold);font-size:14px;font-weight:800}.stars span{color:var(--muted);font-weight:600}.card-body>p{min-height:42px;color:var(--muted);margin:10px 0;font-size:14px;line-height:1.5}.card-foot{border-top:1px solid var(--line);justify-content:space-between;align-items:center;gap:10px;padding-top:14px;display:flex}.card-foot strong{font-size:17px}.card-foot a{color:#a5f3fc;font-size:13px;font-weight:800;text-decoration:none}.steps{grid-template-columns:repeat(4,1fr);gap:18px;margin-top:30px;display:grid}.step-card{border:1px solid var(--line);background:linear-gradient(#11182b,#0e1425);border-radius:18px;padding:26px}.step-card>span{color:#c084fc;font-size:12px;font-weight:900}.step-card h3{margin:26px 0 8px;font-size:21px}.step-card p{color:var(--muted);margin:0;line-height:1.55}.split-feature{grid-template-columns:.9fr 1.1fr;align-items:center;gap:70px;display:grid}.feature-list{grid-template-columns:1fr 1fr;gap:14px;display:grid}.feature-list>div{background:var(--panel);border:1px solid var(--line);border-radius:16px;gap:6px;padding:22px;display:grid}.feature-list span{color:var(--muted);font-size:14px;line-height:1.5}.pricing-section{padding-top:0}.pricing-box{background:linear-gradient(135deg,#1b1141,#10223b);border:1px solid #3a3f68;border-radius:24px;justify-content:space-between;align-items:center;gap:40px;padding:42px;display:flex}.pricing-box>div{max-width:760px}.directory-page{padding:68px 0 82px}.directory-heading{justify-content:space-between;align-items:end;gap:20px;display:flex}.directory-heading h1{letter-spacing:-.05em;margin:6px 0;font-size:clamp(40px,6vw,62px)}.directory-heading p{color:var(--muted)}.results-count,.metric-chip{border:1px solid var(--line);color:#cbd5e1;background:#171e31;border-radius:999px;align-items:center;padding:8px 12px;font-size:12px;font-weight:800;display:inline-flex}.filterbar{grid-template-columns:1.1fr 1fr 1fr auto;margin:28px 0 34px}.directory-empty{grid-column:1/-1}.pagination-wrap{margin-top:32px}.pagination-wrap nav{color:#fff}.profile-hero{background:50%/cover;height:360px}.profile-shell{padding-bottom:80px}.profile-panel{color:#172033;background:#fff;border-radius:22px;grid-template-columns:126px 1fr 210px;align-items:center;gap:24px;margin-top:-82px;padding:26px;display:grid;position:relative;box-shadow:0 24px 70px #00000061}.avatar{object-fit:cover;border-radius:16px;width:126px;height:126px}.profile-title{align-items:center;gap:10px;display:flex}.profile-title h1{letter-spacing:-.04em;margin:0;font-size:36px}.profile-summary p{color:#64748b}.chips{flex-wrap:wrap;gap:7px;display:flex}.chip{color:#526078;background:#eef2f7;border-radius:999px;padding:6px 9px;font-size:11px;font-weight:800}.profile-price{border-left:1px solid #e2e8f0;flex-direction:column;align-items:flex-start;padding-left:22px;display:flex}.profile-price>span,.profile-price small{color:#64748b}.profile-price strong{font-size:36px;line-height:1.1}.profile-price .button{width:100%;margin-top:14px}.profile-grid{grid-template-columns:minmax(0,1fr) 370px;gap:34px;padding-top:38px;display:grid}.profile-section{padding:4px 0 24px}.profile-section h2,.enquiry-card h2{letter-spacing:-.03em;font-size:27px}.profile-bio{color:#aab6c8;font-size:16px;line-height:1.8}.review-card{border-top:1px solid var(--line);padding:20px 0}.review-card h3{margin:8px 0}.review-card p{color:var(--muted);line-height:1.65}.enquiry-card{background:var(--panel);border:1px solid var(--line);border-radius:19px;height:max-content;padding:24px;position:sticky;top:100px}.enquiry-card>p{color:var(--muted)}.enquiry-form,.settings-form{gap:14px;display:grid}.enquiry-form label,.settings-form label{gap:7px;display:grid}.enquiry-form label span,.settings-form label span{color:#b8c5d8;font-size:12px;font-weight:800}.enquiry-form input,.enquiry-form textarea,.settings-form input,.settings-form select{color:#fff;width:100%;font:inherit;background:#0b1020;border:1px solid #34405c;border-radius:10px;padding:12px 13px}.enquiry-form textarea{resize:vertical;min-height:120px}.flash{border-radius:12px;margin-top:14px;padding:13px 16px}.flash.success{background:#064e3b;border:1px solid #047857}.site-footer{background:#080b16;border-top:1px solid #1e293b;padding:56px 0 24px}.footer-grid{grid-template-columns:2fr 1fr 1fr;gap:50px;display:grid}.footer-grid>div{flex-direction:column;gap:10px;display:flex}.footer-grid p{max-width:470px;color:var(--muted);line-height:1.6}.footer-grid a:not(.brand){color:#9aa8bd;font-size:14px;text-decoration:none}.footer-bottom{color:#68768c;border-top:1px solid #1b2235;margin-top:40px;padding-top:22px;font-size:12px}.cookie{z-index:60;color:#172033;background:#fff;border-radius:16px;justify-content:space-between;align-items:center;gap:20px;max-width:720px;padding:18px;display:flex;position:fixed;bottom:20px;left:20px;right:20px;box-shadow:0 22px 70px #00000080}.cookie p{color:#64748b;margin:5px 0 0;font-size:13px}.cookie-actions{flex-shrink:0;gap:8px;display:flex}.cookie .button.ghost{color:#172033;border-color:#cbd5e1}.auth-page{background:radial-gradient(circle at 20% 15%,#7c3aed40,transparent 35%),radial-gradient(circle at 85% 35%,#22d3ee21,transparent 32%),var(--bg);min-height:calc(100vh - 76px);padding:64px 20px}.auth-card{background:var(--panel);border:1px solid var(--line);width:min(100%,510px);box-shadow:var(--shadow);border-radius:22px;margin:auto;padding:32px}.auth-card h1{margin:10px 0 8px;font-size:34px}.auth-form{gap:16px;margin-top:24px;display:grid}.auth-form label{color:#dbe4f2;gap:7px;font-weight:700;display:grid}.auth-form input{color:#fff;width:100%;font:inherit;background:#0a0f1d;border:1px solid #34405d;border-radius:11px;padding:13px 14px}.auth-form .check-row{align-items:center;gap:9px;font-weight:500;display:flex}.auth-form .check-row input{width:auto}.auth-links{color:var(--muted);flex-direction:column;gap:8px;margin-top:20px;display:flex}.auth-links a{color:#67e8f9;text-decoration:none}.form-errors{color:#fecaca;background:#450a0a;border:1px solid #7f1d1d;border-radius:10px;padding:12px 14px}.account-page{min-height:70vh;padding:54px 0 78px}.account-shell{grid-template-columns:240px minmax(0,1fr);gap:28px;display:grid}.account-sidebar{background:var(--panel);border:1px solid var(--line);border-radius:18px;height:max-content;position:sticky;top:100px;overflow:hidden}.account-person{border-bottom:1px solid var(--line);align-items:center;gap:11px;padding:18px;display:flex}.account-avatar{background:linear-gradient(135deg,var(--purple),var(--pink));border-radius:50%;place-items:center;width:42px;height:42px;font-weight:900;display:grid}.account-person div:last-child{min-width:0;display:grid}.account-person span{color:var(--muted);text-overflow:ellipsis;font-size:11px;overflow:hidden}.account-nav{padding:9px}.account-nav a{color:#bac6d8;border-radius:9px;padding:11px 12px;font-size:14px;font-weight:700;text-decoration:none;display:block}.account-nav a:hover,.account-nav a.active{color:#fff;background:#1b2440}.account-content{min-width:0}.page-heading{justify-content:space-between;align-items:flex-end;gap:22px;margin-bottom:24px;display:flex}.page-heading h1{letter-spacing:-.045em;margin:6px 0;font-size:clamp(34px,4vw,48px)}.page-heading p{color:var(--muted);margin:0}.account-stats{grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:20px;display:grid}.account-stat{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px}.account-stat span{color:var(--muted);font-size:12px;font-weight:800;display:block}.account-stat strong{margin-top:4px;font-size:30px;display:block}.dashboard-grid{grid-template-columns:1fr 1fr;gap:20px;display:grid}.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;min-width:0;padding:20px}.panel-head{justify-content:space-between;align-items:center;gap:14px;display:flex}.panel-head h2{margin:0;font-size:19px}.panel-head a,.panel-head span{color:#67e8f9;font-size:12px;font-weight:800;text-decoration:none}.list-row{border-top:1px solid var(--line);justify-content:space-between;align-items:center;gap:14px;padding:15px 0;text-decoration:none;display:flex}.list-row:first-of-type{margin-top:10px}.list-row div{gap:3px;display:grid}.list-row span{color:var(--muted);font-size:12px}.status-pill{color:#475569;white-space:nowrap;background:#e9eef6;border-radius:999px;align-items:center;width:max-content;padding:5px 8px;font-weight:800;display:inline-flex;font-size:11px!important}.empty-state{color:var(--muted);text-align:center;padding:28px 12px;line-height:1.6}.table-panel{padding:0;overflow:hidden}.responsive-table{width:100%;overflow-x:auto}.responsive-table table{border-collapse:collapse;width:100%;min-width:720px}.responsive-table th,.responsive-table td{text-align:left;vertical-align:middle;border-bottom:1px solid #ffffff14;padding:14px 16px;font-size:13px}.responsive-table th{color:#8f9db2;text-transform:uppercase;letter-spacing:.08em;font-size:10px}.responsive-table td small{color:var(--muted);margin-top:3px;display:block}.form-panel{max-width:720px}.settings-form input{background:#0b1020}.check-line{grid-template-columns:auto 1fr;align-items:center;display:flex!important}.check-line input{width:auto!important}.admin-body{background:var(--admin-bg);color:var(--admin-ink)}.admin-shell{grid-template-columns:248px minmax(0,1fr);min-height:100vh;display:grid}.admin-sidebar{color:#fff;z-index:80;background:#0b1020;flex-direction:column;height:100vh;padding:20px 14px;display:flex;position:sticky;top:0;overflow-y:auto}.admin-sidebar-head{justify-content:space-between;align-items:center;padding:0 8px 18px;display:flex}.admin-kicker{color:#66748c;text-transform:uppercase;letter-spacing:.12em;padding:0 10px 8px;font-size:10px;font-weight:900}.admin-nav{gap:2px;display:grid}.admin-nav a,.admin-sidebar-foot a,.admin-sidebar-foot button{color:#aab7ca;text-align:left;width:100%;font:inherit;cursor:pointer;background:0 0;border:0;border-radius:8px;padding:9px 10px;font-size:13px;font-weight:700;text-decoration:none;display:block}.admin-nav a:hover,.admin-nav a.active,.admin-sidebar-foot a:hover,.admin-sidebar-foot button:hover{color:#fff;background:#17203a}.admin-sidebar-foot{border-top:1px solid #202a42;margin-top:auto;padding-top:16px}.admin-content{min-width:0}.admin-topbar{border-bottom:1px solid var(--admin-line);z-index:30;background:#fff;justify-content:space-between;align-items:center;gap:15px;height:66px;padding:0 28px;display:flex;position:sticky;top:0}.admin-topbar>div{display:grid}.admin-topbar span{color:var(--admin-muted);font-size:11px}.admin-page{padding:30px}.admin-heading h1{color:var(--admin-ink)}.admin-heading p{color:var(--admin-muted)}.admin-stats{grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:18px;display:grid}.admin-stat{border:1px solid var(--admin-line);color:inherit;background:#fff;border-radius:14px;padding:20px;text-decoration:none;display:block;box-shadow:0 4px 18px #1f2d4b0a}.admin-stat span{color:var(--admin-muted);text-transform:uppercase;font-size:11px;font-weight:800;display:block}.admin-stat strong{letter-spacing:-.04em;margin:6px 0 4px;font-size:29px;display:block}.admin-stat small{color:#7c3aed;font-weight:800}.admin-grid{grid-template-columns:1fr 1fr;gap:16px;display:grid}.admin-card{border:1px solid var(--admin-line);background:#fff;border-radius:14px;min-width:0;padding:20px;box-shadow:0 4px 18px #1f2d4b0a}.admin-card-wide{grid-column:1/-1}.admin-card .panel-head h2{color:var(--admin-ink)}.admin-list-row{border-top:1px solid var(--admin-line);justify-content:space-between;align-items:center;gap:12px;padding:13px 0;font-size:12px;display:flex}.admin-list-row:first-of-type{margin-top:10px}.admin-list-row>div{display:grid}.admin-list-row span{color:var(--admin-muted)}.admin-card .responsive-table th,.admin-card .responsive-table td{border-color:var(--admin-line);color:var(--admin-ink)}.admin-card .responsive-table th{color:#7a879c}.table-card{padding:0;overflow:hidden}.admin-button,.toggle-button{color:#fff;min-height:33px;font:inherit;cursor:pointer;background:#172033;border:0;border-radius:8px;justify-content:center;align-items:center;padding:0 11px;font-size:11px;font-weight:800;text-decoration:none;display:inline-flex}.toggle-button{color:#5a677c;background:#e9eef6}.toggle-button.on{color:#166534;background:#dcfce7}.inline-controls{align-items:center;gap:7px;display:flex}.inline-controls select{color:#172033;min-width:110px;font:inherit;background:#fff;border:1px solid #d5dce7;border-radius:8px;padding:7px 9px;font-size:11px}.admin-muted{color:var(--admin-muted);line-height:1.6}.admin-flash{color:#166534;background:#dcfce7;border:1px solid #bbf7d0;border-radius:10px;margin:18px 30px 0;padding:12px 15px;font-size:13px}.settings-grid{grid-template-columns:repeat(2,1fr);gap:16px;display:grid}.settings-list>div{border-top:1px solid var(--admin-line);grid-template-columns:140px 1fr auto;gap:14px;padding:12px 0;font-size:12px;display:grid}.settings-list>div:first-child{margin-top:10px}.settings-list span{color:var(--admin-muted)}.action-card{color:inherit;text-decoration:none;display:block}.action-card:hover{border-color:#b8a5ff}.action-card h2{margin-top:0}.action-card p{color:var(--admin-muted);line-height:1.6}.action-card>span{color:#6d28d9;font-size:12px;font-weight:900}.role-grid{grid-template-columns:repeat(3,1fr);gap:16px;display:grid}.role-badge{color:#6d28d9;background:#ede9fe;border-radius:999px;padding:5px 8px;font-size:11px;font-weight:900;display:inline-flex}.role-count{margin-top:16px;font-size:30px;display:block}.feature-empty{text-align:center;padding:38px 15px}.feature-icon{color:#6d28d9;background:#ede9fe;border-radius:15px;place-items:center;width:54px;height:54px;margin:0 auto 14px;font-size:24px;font-weight:900;display:grid}.feature-empty p{max-width:560px;color:var(--admin-muted);margin:0 auto 20px;line-height:1.6}.icon-button{cursor:pointer;background:0 0;border:0;font-size:22px;display:none}.admin-overlay{display:none}@media(max-width:1100px){.dj-cards{grid-template-columns:repeat(3,minmax(0,1fr))}.steps{grid-template-columns:repeat(2,1fr)}.profile-grid{grid-template-columns:minmax(0,1fr) 330px}.admin-stats,.role-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:900px){.shell{width:min(100% - 28px,1240px)}.site-header,.nav{height:68px}.mobile-menu-button{display:block}.nav-panel{border:1px solid var(--line);box-shadow:var(--shadow);background:#0e1425;border-radius:14px;flex-direction:column;align-items:stretch;gap:12px;margin:0;padding:14px;display:none;position:absolute;top:68px;left:14px;right:14px}.nav-panel.open{display:flex}.navlinks,.nav-actions{flex-direction:column;align-items:stretch;gap:2px}.navlinks a,.nav-actions>a,.nav-actions .text-button{text-align:left;padding:11px 10px}.nav-actions{border-top:1px solid var(--line);padding-top:8px}.nav-actions .button{width:100%}.hero{min-height:auto}.hero-copy{padding:74px 0}.searchbox,.filterbar{grid-template-columns:1fr 1fr}.searchbox .button,.filterbar .button{grid-column:1/-1}.dj-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split-feature{grid-template-columns:1fr;gap:36px}.profile-panel{grid-template-columns:100px 1fr}.profile-price{border-top:1px solid #e2e8f0;border-left:0;grid-column:1/-1;grid-template-columns:auto auto 1fr auto;align-items:center;gap:8px;padding:18px 0 0;display:grid}.profile-price .button{margin:0}.profile-grid{grid-template-columns:1fr}.enquiry-card{position:static}.account-shell{grid-template-columns:1fr}.account-sidebar{position:static}.account-nav{gap:4px;display:flex;overflow-x:auto}.account-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}.admin-shell{display:block}.admin-sidebar{width:270px;transition:transform .2s;position:fixed;top:0;left:0;transform:translate(-105%)}.admin-sidebar.open{transform:none}.admin-overlay{z-index:70;background:#0b10208c;position:fixed;inset:0}.admin-overlay.open{display:block}.admin-menu,.admin-close{place-items:center;display:inline-grid}.admin-topbar{padding:0 18px}.admin-page{padding:22px 18px}.admin-flash{margin:14px 18px 0}.admin-grid,.settings-grid{grid-template-columns:1fr}}@media(max-width:640px){.shell{width:min(100% - 22px,1240px)}.brand{font-size:21px}.hero-shade{background:linear-gradient(#070914c7,#070914f7)}.hero-copy{padding:58px 0}.hero h1{font-size:44px}.hero-copy>p{font-size:16px}.searchbox,.filterbar{grid-template-columns:1fr;padding:8px}.searchbox .button,.filterbar .button{grid-column:auto}.hero-proof{gap:9px 16px;font-size:11px}.section{padding:58px 0}.section-head,.directory-heading,.page-heading,.pricing-box{flex-direction:column;align-items:flex-start}.section-head .button{width:100%}.dj-cards{grid-template-columns:1fr}.card-img{aspect-ratio:16/10}.steps,.feature-list{grid-template-columns:1fr}.pricing-box{padding:28px}.pricing-box .button{width:100%}.footer-grid{grid-template-columns:1fr;gap:28px}.cookie{flex-direction:column;align-items:stretch;bottom:10px;left:10px;right:10px}.cookie-actions{grid-template-columns:1fr 1fr;display:grid}.profile-hero{height:230px}.profile-shell{width:100%}.profile-panel{border-radius:17px;grid-template-columns:72px 1fr;gap:14px;margin:-34px 11px 0;padding:18px}.avatar{width:72px;height:72px}.profile-title h1{font-size:27px}.profile-summary p{font-size:13px}.profile-price{grid-template-columns:auto 1fr}.profile-price strong{font-size:30px}.profile-price small{display:none}.profile-price .button{grid-column:1/-1}.profile-grid{padding:28px 11px 0}.directory-page{padding:44px 0 60px}.directory-heading h1{font-size:42px}.account-page{padding:36px 0 58px}.account-person{display:none}.account-sidebar{border-radius:12px}.account-stats{grid-template-columns:1fr 1fr}.account-stat:last-child{grid-column:1/-1}.panel{padding:16px}.admin-topbar .button{display:none}.admin-page{padding:18px 12px}.admin-stats{grid-template-columns:1fr 1fr;gap:10px}.admin-stat{padding:15px}.admin-stat strong{font-size:24px}.settings-list>div,.role-grid{grid-template-columns:1fr}.inline-controls{flex-direction:column;align-items:stretch}.inline-controls select,.inline-controls .admin-button{width:100%}.auth-card{padding:23px}.auth-page{padding:38px 12px}}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.hidden{display:none}.table{display:table}.grow{flex-grow:1}.lowercase{text-transform:lowercase}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}:root{--bg:#070914;--panel:#101526;--panel-2:#151c31;--line:#26304a;--text:#f8fafc;--muted:#9aa8bd;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24;--white:#fff;--admin-bg:#f5f7fb;--admin-ink:#172033;--admin-muted:#65738a;--admin-line:#e4e9f1;--shadow:0 18px 55px #0000003d}*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}a{color:inherit}.shell{width:min(100% - 40px,1240px);margin-inline:auto}.muted{color:var(--muted)}.site-header{-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);z-index:40;background:#070914e0;border-bottom:1px solid #ffffff12;height:76px;position:sticky;top:0}.nav{align-items:center;gap:32px;height:100%;display:flex}.brand{letter-spacing:-.04em;white-space:nowrap;align-items:center;gap:8px;font-size:24px;font-weight:900;text-decoration:none;display:inline-flex}.brand b{color:var(--pink)}.nav-panel{align-items:center;gap:34px;margin-left:auto;display:flex}.navlinks{align-items:center;gap:26px;display:flex}.navlinks a,.text-button{color:#dce5f4;cursor:pointer;font-size:14px;font-weight:700;font:inherit;background:0 0;border:0;padding:8px 0;text-decoration:none}.navlinks a:hover,.text-button:hover{color:#fff}.nav-actions{align-items:center;gap:12px;display:flex}.nav-account-link{color:#fff;align-items:center;gap:8px;font-size:14px;font-weight:800;text-decoration:none;display:flex}.mini-avatar{background:linear-gradient(135deg,var(--purple),var(--pink));border-radius:50%;place-items:center;width:31px;height:31px;font-size:12px;display:grid}.mobile-menu-button{color:#fff;background:0 0;border:1px solid #34405d;border-radius:10px;width:42px;height:42px;margin-left:auto;font-size:21px;display:none}.inline-form{margin:0}.button{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;min-height:46px;font:inherit;cursor:pointer;border:0;border-radius:12px;justify-content:center;align-items:center;gap:8px;padding:0 19px;font-size:14px;font-weight:850;text-decoration:none;transition:transform .18s,filter .18s;display:inline-flex;box-shadow:0 9px 24px #7c3aed3d}.button:hover{filter:brightness(1.06);transform:translateY(-1px)}.button.ghost{box-shadow:none;background:0 0;border:1px solid #3a4663}.button.small{min-height:40px;padding:0 15px}.button.full{width:100%}.hero{background:url(https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2200&q=84) 50% 38%/cover no-repeat;align-items:center;min-height:640px;display:flex;position:relative}.hero-shade{background:linear-gradient(90deg,#070914fa,#070914e8 44%,#07091478,#0709142e);position:absolute;inset:0}.hero-layout{z-index:1;position:relative}.hero-copy{max-width:790px;padding:82px 0}.eyebrow{color:#67e8f9;letter-spacing:.18em;text-transform:uppercase;font-size:12px;font-weight:900}.hero h1{letter-spacing:-.06em;max-width:760px;margin:14px 0 22px;font-size:clamp(46px,6vw,78px);line-height:.97}.hero h1 span{color:#0000;background:linear-gradient(90deg,#c084fc,#f472b6,#67e8f9);-webkit-background-clip:text;background-clip:text}.hero-copy>p{color:#c3cede;max-width:650px;font-size:18px;line-height:1.65}.searchbox,.filterbar{background:#fff;border-radius:18px;gap:8px;padding:9px;display:grid;box-shadow:0 24px 70px #0000006b}.searchbox{grid-template-columns:1.2fr 1fr 1fr auto;margin-top:30px}.searchbox label,.filterbar label{background:#f6f8fb;border-radius:11px;gap:3px;padding:8px 12px;display:grid}.searchbox label span,.filterbar label span{color:#64748b;text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:900}.searchbox input,.searchbox select,.filterbar input,.filterbar select{color:#172033;width:100%;min-width:0;font:inherit;background:0 0;border:0;outline:0;font-size:14px}.hero-proof{color:#cbd5e1;flex-wrap:wrap;gap:22px;margin-top:18px;font-size:13px;font-weight:700;display:flex}.section{padding:78px 0}.section-alt{background:#0c1120;border-block:1px solid #ffffff0f}.section-head{justify-content:space-between;align-items:end;gap:24px;margin-bottom:28px;display:flex}.section-head h2,.section-intro h2,.split-feature h2,.pricing-box h2{letter-spacing:-.04em;margin:6px 0 8px;font-size:clamp(30px,4vw,44px)}.section-head p,.section-intro p,.split-feature .lead,.pricing-box p{color:var(--muted);line-height:1.65}.cards{gap:20px;display:grid}.dj-cards{grid-template-columns:repeat(4,minmax(0,1fr))}.card{background:var(--panel);border:1px solid var(--line);border-radius:18px;overflow:hidden;box-shadow:0 12px 34px #0000002e}.dj-card{min-width:0}.card-img{aspect-ratio:4/3;background-position:50%;background-size:cover;display:block}.card-body{padding:18px}.card-title-line{align-items:center;gap:8px;display:flex}.card-title-line h3{letter-spacing:-.02em;margin:0;font-size:19px}.verified{color:#fff;background:#2563eb;border-radius:50%;place-items:center;width:20px;height:20px;font-size:12px;font-weight:900;display:inline-grid}.verified.large{width:25px;height:25px}.stars{color:var(--gold);font-size:14px;font-weight:800}.stars span{color:var(--muted);font-weight:600}.card-body>p{min-height:42px;color:var(--muted);margin:10px 0;font-size:14px;line-height:1.5}.card-foot{border-top:1px solid var(--line);justify-content:space-between;align-items:center;gap:10px;padding-top:14px;display:flex}.card-foot strong{font-size:17px}.card-foot a{color:#a5f3fc;font-size:13px;font-weight:800;text-decoration:none}.steps{grid-template-columns:repeat(4,1fr);gap:18px;margin-top:30px;display:grid}.step-card{border:1px solid var(--line);background:linear-gradient(#11182b,#0e1425);border-radius:18px;padding:26px}.step-card>span{color:#c084fc;font-size:12px;font-weight:900}.step-card h3{margin:26px 0 8px;font-size:21px}.step-card p{color:var(--muted);margin:0;line-height:1.55}.split-feature{grid-template-columns:.9fr 1.1fr;align-items:center;gap:70px;display:grid}.feature-list{grid-template-columns:1fr 1fr;gap:14px;display:grid}.feature-list>div{background:var(--panel);border:1px solid var(--line);border-radius:16px;gap:6px;padding:22px;display:grid}.feature-list span{color:var(--muted);font-size:14px;line-height:1.5}.pricing-section{padding-top:0}.pricing-box{background:linear-gradient(135deg,#1b1141,#10223b);border:1px solid #3a3f68;border-radius:24px;justify-content:space-between;align-items:center;gap:40px;padding:42px;display:flex}.pricing-box>div{max-width:760px}.directory-page{padding:68px 0 82px}.directory-heading{justify-content:space-between;align-items:end;gap:20px;display:flex}.directory-heading h1{letter-spacing:-.05em;margin:6px 0;font-size:clamp(40px,6vw,62px)}.directory-heading p{color:var(--muted)}.results-count,.metric-chip{border:1px solid var(--line);color:#cbd5e1;background:#171e31;border-radius:999px;align-items:center;padding:8px 12px;font-size:12px;font-weight:800;display:inline-flex}.filterbar{grid-template-columns:1.1fr 1fr 1fr auto;margin:28px 0 34px}.directory-empty{grid-column:1/-1}.pagination-wrap{margin-top:32px}.pagination-wrap nav{color:#fff}.profile-hero{background:50%/cover;height:360px}.profile-shell{padding-bottom:80px}.profile-panel{color:#172033;background:#fff;border-radius:22px;grid-template-columns:126px 1fr 210px;align-items:center;gap:24px;margin-top:-82px;padding:26px;display:grid;position:relative;box-shadow:0 24px 70px #00000061}.avatar{object-fit:cover;border-radius:16px;width:126px;height:126px}.profile-title{align-items:center;gap:10px;display:flex}.profile-title h1{letter-spacing:-.04em;margin:0;font-size:36px}.profile-summary p{color:#64748b}.chips{flex-wrap:wrap;gap:7px;display:flex}.chip{color:#526078;background:#eef2f7;border-radius:999px;padding:6px 9px;font-size:11px;font-weight:800}.profile-price{border-left:1px solid #e2e8f0;flex-direction:column;align-items:flex-start;padding-left:22px;display:flex}.profile-price>span,.profile-price small{color:#64748b}.profile-price strong{font-size:36px;line-height:1.1}.profile-price .button{width:100%;margin-top:14px}.profile-grid{grid-template-columns:minmax(0,1fr) 370px;gap:34px;padding-top:38px;display:grid}.profile-section{padding:4px 0 24px}.profile-section h2,.enquiry-card h2{letter-spacing:-.03em;font-size:27px}.profile-bio{color:#aab6c8;font-size:16px;line-height:1.8}.review-card{border-top:1px solid var(--line);padding:20px 0}.review-card h3{margin:8px 0}.review-card p{color:var(--muted);line-height:1.65}.enquiry-card{background:var(--panel);border:1px solid var(--line);border-radius:19px;height:max-content;padding:24px;position:sticky;top:100px}.enquiry-card>p{color:var(--muted)}.enquiry-form,.settings-form{gap:14px;display:grid}.enquiry-form label,.settings-form label{gap:7px;display:grid}.enquiry-form label span,.settings-form label span{color:#b8c5d8;font-size:12px;font-weight:800}.enquiry-form input,.enquiry-form textarea,.settings-form input,.settings-form select{color:#fff;width:100%;font:inherit;background:#0b1020;border:1px solid #34405c;border-radius:10px;padding:12px 13px}.enquiry-form textarea{resize:vertical;min-height:120px}.flash{border-radius:12px;margin-top:14px;padding:13px 16px}.flash.success{background:#064e3b;border:1px solid #047857}.site-footer{background:#080b16;border-top:1px solid #1e293b;padding:56px 0 24px}.footer-grid{grid-template-columns:2fr 1fr 1fr;gap:50px;display:grid}.footer-grid>div{flex-direction:column;gap:10px;display:flex}.footer-grid p{max-width:470px;color:var(--muted);line-height:1.6}.footer-grid a:not(.brand){color:#9aa8bd;font-size:14px;text-decoration:none}.footer-bottom{color:#68768c;border-top:1px solid #1b2235;margin-top:40px;padding-top:22px;font-size:12px}.cookie{z-index:60;color:#172033;background:#fff;border-radius:16px;justify-content:space-between;align-items:center;gap:20px;max-width:720px;padding:18px;display:flex;position:fixed;bottom:20px;left:20px;right:20px;box-shadow:0 22px 70px #00000080}.cookie p{color:#64748b;margin:5px 0 0;font-size:13px}.cookie-actions{flex-shrink:0;gap:8px;display:flex}.cookie .button.ghost{color:#172033;border-color:#cbd5e1}.auth-page{background:radial-gradient(circle at 20% 15%,#7c3aed40,transparent 35%),radial-gradient(circle at 85% 35%,#22d3ee21,transparent 32%),var(--bg);min-height:calc(100vh - 76px);padding:64px 20px}.auth-card{background:var(--panel);border:1px solid var(--line);width:min(100%,510px);box-shadow:var(--shadow);border-radius:22px;margin:auto;padding:32px}.auth-card h1{margin:10px 0 8px;font-size:34px}.auth-form{gap:16px;margin-top:24px;display:grid}.auth-form label{color:#dbe4f2;gap:7px;font-weight:700;display:grid}.auth-form input{color:#fff;width:100%;font:inherit;background:#0a0f1d;border:1px solid #34405d;border-radius:11px;padding:13px 14px}.auth-form .check-row{align-items:center;gap:9px;font-weight:500;display:flex}.auth-form .check-row input{width:auto}.auth-links{color:var(--muted);flex-direction:column;gap:8px;margin-top:20px;display:flex}.auth-links a{color:#67e8f9;text-decoration:none}.form-errors{color:#fecaca;background:#450a0a;border:1px solid #7f1d1d;border-radius:10px;padding:12px 14px}.account-page{min-height:70vh;padding:54px 0 78px}.account-shell{grid-template-columns:240px minmax(0,1fr);gap:28px;display:grid}.account-sidebar{background:var(--panel);border:1px solid var(--line);border-radius:18px;height:max-content;position:sticky;top:100px;overflow:hidden}.account-person{border-bottom:1px solid var(--line);align-items:center;gap:11px;padding:18px;display:flex}.account-avatar{background:linear-gradient(135deg,var(--purple),var(--pink));border-radius:50%;place-items:center;width:42px;height:42px;font-weight:900;display:grid}.account-person div:last-child{min-width:0;display:grid}.account-person span{color:var(--muted);text-overflow:ellipsis;font-size:11px;overflow:hidden}.account-nav{padding:9px}.account-nav a{color:#bac6d8;border-radius:9px;padding:11px 12px;font-size:14px;font-weight:700;text-decoration:none;display:block}.account-nav a:hover,.account-nav a.active{color:#fff;background:#1b2440}.account-content{min-width:0}.page-heading{justify-content:space-between;align-items:flex-end;gap:22px;margin-bottom:24px;display:flex}.page-heading h1{letter-spacing:-.045em;margin:6px 0;font-size:clamp(34px,4vw,48px)}.page-heading p{color:var(--muted);margin:0}.account-stats{grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:20px;display:grid}.account-stat{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px}.account-stat span{color:var(--muted);font-size:12px;font-weight:800;display:block}.account-stat strong{margin-top:4px;font-size:30px;display:block}.dashboard-grid{grid-template-columns:1fr 1fr;gap:20px;display:grid}.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;min-width:0;padding:20px}.panel-head{justify-content:space-between;align-items:center;gap:14px;display:flex}.panel-head h2{margin:0;font-size:19px}.panel-head a,.panel-head span{color:#67e8f9;font-size:12px;font-weight:800;text-decoration:none}.list-row{border-top:1px solid var(--line);justify-content:space-between;align-items:center;gap:14px;padding:15px 0;text-decoration:none;display:flex}.list-row:first-of-type{margin-top:10px}.list-row div{gap:3px;display:grid}.list-row span{color:var(--muted);font-size:12px}.status-pill{color:#475569;white-space:nowrap;background:#e9eef6;border-radius:999px;align-items:center;width:max-content;padding:5px 8px;font-weight:800;display:inline-flex;font-size:11px!important}.empty-state{color:var(--muted);text-align:center;padding:28px 12px;line-height:1.6}.table-panel{padding:0;overflow:hidden}.responsive-table{width:100%;overflow-x:auto}.responsive-table table{border-collapse:collapse;width:100%;min-width:720px}.responsive-table th,.responsive-table td{text-align:left;vertical-align:middle;border-bottom:1px solid #ffffff14;padding:14px 16px;font-size:13px}.responsive-table th{color:#8f9db2;text-transform:uppercase;letter-spacing:.08em;font-size:10px}.responsive-table td small{color:var(--muted);margin-top:3px;display:block}.form-panel{max-width:720px}.settings-form input{background:#0b1020}.check-line{grid-template-columns:auto 1fr;align-items:center;display:flex!important}.check-line input{width:auto!important}.admin-body{background:var(--admin-bg);color:var(--admin-ink)}.admin-shell{grid-template-columns:248px minmax(0,1fr);min-height:100vh;display:grid}.admin-sidebar{color:#fff;z-index:80;background:#0b1020;flex-direction:column;height:100vh;padding:20px 14px;display:flex;position:sticky;top:0;overflow-y:auto}.admin-sidebar-head{justify-content:space-between;align-items:center;padding:0 8px 18px;display:flex}.admin-kicker{color:#66748c;text-transform:uppercase;letter-spacing:.12em;padding:0 10px 8px;font-size:10px;font-weight:900}.admin-nav{gap:2px;display:grid}.admin-nav a,.admin-sidebar-foot a,.admin-sidebar-foot button{color:#aab7ca;text-align:left;width:100%;font:inherit;cursor:pointer;background:0 0;border:0;border-radius:8px;padding:9px 10px;font-size:13px;font-weight:700;text-decoration:none;display:block}.admin-nav a:hover,.admin-nav a.active,.admin-sidebar-foot a:hover,.admin-sidebar-foot button:hover{color:#fff;background:#17203a}.admin-sidebar-foot{border-top:1px solid #202a42;margin-top:auto;padding-top:16px}.admin-content{min-width:0}.admin-topbar{border-bottom:1px solid var(--admin-line);z-index:30;background:#fff;justify-content:space-between;align-items:center;gap:15px;height:66px;padding:0 28px;display:flex;position:sticky;top:0}.admin-topbar>div{display:grid}.admin-topbar span{color:var(--admin-muted);font-size:11px}.admin-page{padding:30px}.admin-heading h1{color:var(--admin-ink)}.admin-heading p{color:var(--admin-muted)}.admin-stats{grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:18px;display:grid}.admin-stat{border:1px solid var(--admin-line);color:inherit;background:#fff;border-radius:14px;padding:20px;text-decoration:none;display:block;box-shadow:0 4px 18px #1f2d4b0a}.admin-stat span{color:var(--admin-muted);text-transform:uppercase;font-size:11px;font-weight:800;display:block}.admin-stat strong{letter-spacing:-.04em;margin:6px 0 4px;font-size:29px;display:block}.admin-stat small{color:#7c3aed;font-weight:800}.admin-grid{grid-template-columns:1fr 1fr;gap:16px;display:grid}.admin-card{border:1px solid var(--admin-line);background:#fff;border-radius:14px;min-width:0;padding:20px;box-shadow:0 4px 18px #1f2d4b0a}.admin-card-wide{grid-column:1/-1}.admin-card .panel-head h2{color:var(--admin-ink)}.admin-list-row{border-top:1px solid var(--admin-line);justify-content:space-between;align-items:center;gap:12px;padding:13px 0;font-size:12px;display:flex}.admin-list-row:first-of-type{margin-top:10px}.admin-list-row>div{display:grid}.admin-list-row span{color:var(--admin-muted)}.admin-card .responsive-table th,.admin-card .responsive-table td{border-color:var(--admin-line);color:var(--admin-ink)}.admin-card .responsive-table th{color:#7a879c}.table-card{padding:0;overflow:hidden}.admin-button,.toggle-button{color:#fff;min-height:33px;font:inherit;cursor:pointer;background:#172033;border:0;border-radius:8px;justify-content:center;align-items:center;padding:0 11px;font-size:11px;font-weight:800;text-decoration:none;display:inline-flex}.toggle-button{color:#5a677c;background:#e9eef6}.toggle-button.on{color:#166534;background:#dcfce7}.inline-controls{align-items:center;gap:7px;display:flex}.inline-controls select{color:#172033;min-width:110px;font:inherit;background:#fff;border:1px solid #d5dce7;border-radius:8px;padding:7px 9px;font-size:11px}.admin-muted{color:var(--admin-muted);line-height:1.6}.admin-flash{color:#166534;background:#dcfce7;border:1px solid #bbf7d0;border-radius:10px;margin:18px 30px 0;padding:12px 15px;font-size:13px}.settings-grid{grid-template-columns:repeat(2,1fr);gap:16px;display:grid}.settings-list>div{border-top:1px solid var(--admin-line);grid-template-columns:140px 1fr auto;gap:14px;padding:12px 0;font-size:12px;display:grid}.settings-list>div:first-child{margin-top:10px}.settings-list span{color:var(--admin-muted)}.action-card{color:inherit;text-decoration:none;display:block}.action-card:hover{border-color:#b8a5ff}.action-card h2{margin-top:0}.action-card p{color:var(--admin-muted);line-height:1.6}.action-card>span{color:#6d28d9;font-size:12px;font-weight:900}.role-grid{grid-template-columns:repeat(3,1fr);gap:16px;display:grid}.role-badge{color:#6d28d9;background:#ede9fe;border-radius:999px;padding:5px 8px;font-size:11px;font-weight:900;display:inline-flex}.role-count{margin-top:16px;font-size:30px;display:block}.feature-empty{text-align:center;padding:38px 15px}.feature-icon{color:#6d28d9;background:#ede9fe;border-radius:15px;place-items:center;width:54px;height:54px;margin:0 auto 14px;font-size:24px;font-weight:900;display:grid}.feature-empty p{max-width:560px;color:var(--admin-muted);margin:0 auto 20px;line-height:1.6}.icon-button{cursor:pointer;background:0 0;border:0;font-size:22px;display:none}.admin-overlay{display:none}@media(max-width:1100px){.dj-cards{grid-template-columns:repeat(3,minmax(0,1fr))}.steps{grid-template-columns:repeat(2,1fr)}.profile-grid{grid-template-columns:minmax(0,1fr) 330px}.admin-stats,.role-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:900px){.shell{width:min(100% - 28px,1240px)}.site-header,.nav{height:68px}.mobile-menu-button{display:block}.nav-panel{border:1px solid var(--line);box-shadow:var(--shadow);background:#0e1425;border-radius:14px;flex-direction:column;align-items:stretch;gap:12px;margin:0;padding:14px;display:none;position:absolute;top:68px;left:14px;right:14px}.nav-panel.open{display:flex}.navlinks,.nav-actions{flex-direction:column;align-items:stretch;gap:2px}.navlinks a,.nav-actions>a,.nav-actions .text-button{text-align:left;padding:11px 10px}.nav-actions{border-top:1px solid var(--line);padding-top:8px}.nav-actions .button{width:100%}.hero{min-height:auto}.hero-copy{padding:74px 0}.searchbox,.filterbar{grid-template-columns:1fr 1fr}.searchbox .button,.filterbar .button{grid-column:1/-1}.dj-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split-feature{grid-template-columns:1fr;gap:36px}.profile-panel{grid-template-columns:100px 1fr}.profile-price{border-top:1px solid #e2e8f0;border-left:0;grid-column:1/-1;grid-template-columns:auto auto 1fr auto;align-items:center;gap:8px;padding:18px 0 0;display:grid}.profile-price .button{margin:0}.profile-grid{grid-template-columns:1fr}.enquiry-card{position:static}.account-shell{grid-template-columns:1fr}.account-sidebar{position:static}.account-nav{gap:4px;display:flex;overflow-x:auto}.account-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}.admin-shell{display:block}.admin-sidebar{width:270px;transition:transform .2s;position:fixed;top:0;left:0;transform:translate(-105%)}.admin-sidebar.open{transform:none}.admin-overlay{z-index:70;background:#0b10208c;position:fixed;inset:0}.admin-overlay.open{display:block}.admin-menu,.admin-close{place-items:center;display:inline-grid}.admin-topbar{padding:0 18px}.admin-page{padding:22px 18px}.admin-flash{margin:14px 18px 0}.admin-grid,.settings-grid{grid-template-columns:1fr}}@media(max-width:640px){.shell{width:min(100% - 22px,1240px)}.brand{font-size:21px}.hero-shade{background:linear-gradient(#070914c7,#070914f7)}.hero-copy{padding:58px 0}.hero h1{font-size:44px}.hero-copy>p{font-size:16px}.searchbox,.filterbar{grid-template-columns:1fr;padding:8px}.searchbox .button,.filterbar .button{grid-column:auto}.hero-proof{gap:9px 16px;font-size:11px}.section{padding:58px 0}.section-head,.directory-heading,.page-heading,.pricing-box{flex-direction:column;align-items:flex-start}.section-head .button{width:100%}.dj-cards{grid-template-columns:1fr}.card-img{aspect-ratio:16/10}.steps,.feature-list{grid-template-columns:1fr}.pricing-box{padding:28px}.pricing-box .button{width:100%}.footer-grid{grid-template-columns:1fr;gap:28px}.cookie{flex-direction:column;align-items:stretch;bottom:10px;left:10px;right:10px}.cookie-actions{grid-template-columns:1fr 1fr;display:grid}.profile-hero{height:230px}.profile-shell{width:100%}.profile-panel{border-radius:17px;grid-template-columns:72px 1fr;gap:14px;margin:-34px 11px 0;padding:18px}.avatar{width:72px;height:72px}.profile-title h1{font-size:27px}.profile-summary p{font-size:13px}.profile-price{grid-template-columns:auto 1fr}.profile-price strong{font-size:30px}.profile-price small{display:none}.profile-price .button{grid-column:1/-1}.profile-grid{padding:28px 11px 0}.directory-page{padding:44px 0 60px}.directory-heading h1{font-size:42px}.account-page{padding:36px 0 58px}.account-person{display:none}.account-sidebar{border-radius:12px}.account-stats{grid-template-columns:1fr 1fr}.account-stat:last-child{grid-column:1/-1}.panel{padding:16px}.admin-topbar .button{display:none}.admin-page{padding:18px 12px}.admin-stats{grid-template-columns:1fr 1fr;gap:10px}.admin-stat{padding:15px}.admin-stat strong{font-size:24px}.settings-list>div,.role-grid{grid-template-columns:1fr}.inline-controls{flex-direction:column;align-items:stretch}.inline-controls select,.inline-controls .admin-button{width:100%}.auth-card{padding:23px}.auth-page{padding:38px 12px}}.crud-heading{gap:18px}.crud-heading .admin-button{flex:none}.crud-toolbar{justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px;padding:14px 16px;display:flex}.crud-search{flex:1;align-items:center;gap:8px;display:flex}.crud-search input{color:#172033;width:min(420px,100%);height:38px;font:inherit;background:#fff;border:1px solid #d8deea;border-radius:9px;padding:0 12px;font-size:13px}.record-count{color:var(--admin-muted);white-space:nowrap;font-size:12px;font-weight:800}.admin-button.primary{background:#6d28d9}.danger-button{color:#b91c1c;min-height:33px;font:inherit;cursor:pointer;background:#fee2e2;border:0;border-radius:8px;justify-content:center;align-items:center;padding:0 11px;font-size:11px;font-weight:900;display:inline-flex}.row-actions{justify-content:flex-end;align-items:center;gap:7px;display:flex}.row-actions form{margin:0}.actions-col{text-align:right!important}.crud-table td:last-child{text-align:right}.crud-pagination{margin-top:16px}.crud-form-card{max-width:980px}.crud-form-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:17px;display:grid}.crud-field{gap:7px;display:grid}.crud-field>span,.crud-check strong{color:#263248;font-size:12px;font-weight:900}.crud-field input,.crud-field select,.crud-field textarea{color:#172033;width:100%;font:inherit;background:#fff;border:1px solid #d5dce7;border-radius:9px;outline:none;padding:10px 11px;font-size:13px}.crud-field input:focus,.crud-field select:focus,.crud-field textarea:focus{border-color:#8b5cf6;box-shadow:0 0 0 3px #8b5cf61a}.crud-field textarea{resize:vertical;line-height:1.55}.crud-field small{color:var(--admin-muted);font-size:11px}.crud-field .field-error{color:#b91c1c}.full-field{grid-column:1/-1}.crud-check{background:#fafbfe;border:1px solid #e0e5ee;border-radius:9px;align-items:center;gap:9px;padding:11px 12px;display:flex}.crud-check input[type=checkbox]{width:18px;height:18px}.crud-form-actions{border-top:1px solid var(--admin-line);align-items:center;gap:9px;margin-top:20px;padding-top:20px;display:flex}.admin-error{color:#991b1b;background:#fff1f2;border:1px solid #fecaca;border-radius:10px;max-width:980px;margin-bottom:16px;padding:14px 16px;font-size:13px}.admin-error ul{margin:8px 0 0 18px}.admin-flash.error{color:#991b1b;background:#fff1f2;border-color:#fecaca}@media(max-width:760px){.crud-toolbar,.crud-search{flex-direction:column;align-items:stretch}.crud-toolbar .record-count{align-self:flex-start}.crud-search input{width:100%}.crud-form-grid{grid-template-columns:1fr}.full-field{grid-column:auto}.crud-heading .admin-button,.crud-heading .toggle-button{width:100%}.crud-form-actions{flex-direction:column;align-items:stretch}.crud-form-actions>*{width:100%}.crud-table{min-width:760px!important}}.crud-heading-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}@media(max-width:640px){.crud-heading-actions{flex-direction:column;align-items:stretch;width:100%}}.legal-shell{max-width:850px}.legal-shell h1{margin:10px 0 8px;font-size:clamp(38px,6vw,64px)}.legal-meta{color:var(--muted);margin-bottom:30px;font-size:12px}.legal-copy{color:#cbd5e1;white-space:normal;font-size:17px;line-height:1.85}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} diff --git a/public/build/manifest.json b/public/build/manifest.json index 1f54ec9..62ab917 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,6 +1,6 @@ { "resources/css/app.css": { - "file": "assets/app-B873fQy4.css", + "file": "assets/app-DbTvU8vz.css", "src": "resources/css/app.css", "isEntry": true, "name": "app", diff --git a/resources/css/app.css b/resources/css/app.css index 5766544..4122485 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,3 +14,10 @@ @media(max-width:1100px){.dj-cards{grid-template-columns:repeat(3,minmax(0,1fr))}.steps{grid-template-columns:repeat(2,1fr)}.profile-grid{grid-template-columns:minmax(0,1fr) 330px}.admin-stats{grid-template-columns:repeat(2,1fr)}.role-grid{grid-template-columns:repeat(2,1fr)}} @media(max-width:900px){.shell{width:min(100% - 28px,1240px)}.site-header{height:68px}.nav{height:68px}.mobile-menu-button{display:block}.nav-panel{display:none;position:absolute;top:68px;left:14px;right:14px;margin:0;padding:14px;flex-direction:column;align-items:stretch;gap:12px;background:#0e1425;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.nav-panel.open{display:flex}.navlinks,.nav-actions{align-items:stretch;flex-direction:column;gap:2px}.navlinks a,.nav-actions>a,.nav-actions .text-button{padding:11px 10px;text-align:left}.nav-actions{padding-top:8px;border-top:1px solid var(--line)}.nav-actions .button{width:100%}.hero{min-height:auto}.hero-copy{padding:74px 0}.searchbox,.filterbar{grid-template-columns:1fr 1fr}.searchbox .button,.filterbar .button{grid-column:1/-1}.dj-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split-feature{grid-template-columns:1fr;gap:36px}.profile-panel{grid-template-columns:100px 1fr}.profile-price{grid-column:1/-1;padding:18px 0 0;border-left:0;border-top:1px solid #e2e8f0;display:grid;grid-template-columns:auto auto 1fr auto;gap:8px;align-items:center}.profile-price .button{margin:0}.profile-grid{grid-template-columns:1fr}.enquiry-card{position:static}.account-shell{grid-template-columns:1fr}.account-sidebar{position:static}.account-nav{display:flex;gap:4px;overflow-x:auto}.account-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}.admin-shell{display:block}.admin-sidebar{position:fixed;left:0;top:0;width:270px;transform:translateX(-105%);transition:transform .2s ease}.admin-sidebar.open{transform:none}.admin-overlay{position:fixed;inset:0;background:rgba(11,16,32,.55);z-index:70}.admin-overlay.open{display:block}.admin-menu,.admin-close{display:inline-grid;place-items:center}.admin-topbar{padding:0 18px}.admin-page{padding:22px 18px}.admin-flash{margin:14px 18px 0}.admin-grid{grid-template-columns:1fr}.settings-grid{grid-template-columns:1fr}} @media(max-width:640px){.shell{width:min(100% - 22px,1240px)}.brand{font-size:21px}.hero-shade{background:linear-gradient(180deg,rgba(7,9,20,.78),rgba(7,9,20,.97))}.hero-copy{padding:58px 0}.hero h1{font-size:44px}.hero-copy>p{font-size:16px}.searchbox,.filterbar{grid-template-columns:1fr;padding:8px}.searchbox .button,.filterbar .button{grid-column:auto}.hero-proof{gap:9px 16px;font-size:11px}.section{padding:58px 0}.section-head,.directory-heading,.page-heading,.pricing-box{align-items:flex-start;flex-direction:column}.section-head .button{width:100%}.dj-cards{grid-template-columns:1fr}.card-img{aspect-ratio:16/10}.steps{grid-template-columns:1fr}.feature-list{grid-template-columns:1fr}.pricing-box{padding:28px}.pricing-box .button{width:100%}.footer-grid{grid-template-columns:1fr;gap:28px}.cookie{left:10px;right:10px;bottom:10px;flex-direction:column;align-items:stretch}.cookie-actions{display:grid;grid-template-columns:1fr 1fr}.profile-hero{height:230px}.profile-shell{width:100%}.profile-panel{margin:-34px 11px 0;padding:18px;grid-template-columns:72px 1fr;gap:14px;border-radius:17px}.avatar{width:72px;height:72px}.profile-title h1{font-size:27px}.profile-summary p{font-size:13px}.profile-price{grid-template-columns:auto 1fr}.profile-price strong{font-size:30px}.profile-price small{display:none}.profile-price .button{grid-column:1/-1}.profile-grid{padding:28px 11px 0}.directory-page{padding:44px 0 60px}.directory-heading h1{font-size:42px}.account-page{padding:36px 0 58px}.account-person{display:none}.account-sidebar{border-radius:12px}.account-stats{grid-template-columns:1fr 1fr}.account-stat:last-child{grid-column:1/-1}.panel{padding:16px}.admin-topbar .button{display:none}.admin-page{padding:18px 12px}.admin-stats{grid-template-columns:1fr 1fr;gap:10px}.admin-stat{padding:15px}.admin-stat strong{font-size:24px}.settings-list>div{grid-template-columns:1fr}.role-grid{grid-template-columns:1fr}.inline-controls{align-items:stretch;flex-direction:column}.inline-controls select,.inline-controls .admin-button{width:100%}.auth-card{padding:23px}.auth-page{padding:38px 12px}} + +/* Admin CRUD */ +.crud-heading{gap:18px}.crud-heading .admin-button{flex:0 0 auto}.crud-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px;padding:14px 16px}.crud-search{display:flex;gap:8px;align-items:center;flex:1}.crud-search input{width:min(420px,100%);height:38px;padding:0 12px;border:1px solid #d8deea;border-radius:9px;background:#fff;color:#172033;font:inherit;font-size:13px}.record-count{font-size:12px;font-weight:800;color:var(--admin-muted);white-space:nowrap}.admin-button.primary{background:#6d28d9}.danger-button{display:inline-flex;align-items:center;justify-content:center;min-height:33px;padding:0 11px;border:0;border-radius:8px;background:#fee2e2;color:#b91c1c;font:inherit;font-size:11px;font-weight:900;cursor:pointer}.row-actions{display:flex;align-items:center;gap:7px;justify-content:flex-end}.row-actions form{margin:0}.actions-col{text-align:right!important}.crud-table td:last-child{text-align:right}.crud-pagination{margin-top:16px}.crud-form-card{max-width:980px}.crud-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.crud-field{display:grid;gap:7px}.crud-field>span,.crud-check strong{font-size:12px;font-weight:900;color:#263248}.crud-field input,.crud-field select,.crud-field textarea{width:100%;padding:10px 11px;border:1px solid #d5dce7;border-radius:9px;background:#fff;color:#172033;font:inherit;font-size:13px;outline:none}.crud-field input:focus,.crud-field select:focus,.crud-field textarea:focus{border-color:#8b5cf6;box-shadow:0 0 0 3px rgba(139,92,246,.10)}.crud-field textarea{resize:vertical;line-height:1.55}.crud-field small{color:var(--admin-muted);font-size:11px}.crud-field .field-error{color:#b91c1c}.full-field{grid-column:1/-1}.crud-check{display:flex;align-items:center;gap:9px;padding:11px 12px;border:1px solid #e0e5ee;border-radius:9px;background:#fafbfe}.crud-check input[type=checkbox]{width:18px;height:18px}.crud-form-actions{display:flex;align-items:center;gap:9px;padding-top:20px;margin-top:20px;border-top:1px solid var(--admin-line)}.admin-error{max-width:980px;margin-bottom:16px;padding:14px 16px;border:1px solid #fecaca;border-radius:10px;background:#fff1f2;color:#991b1b;font-size:13px}.admin-error ul{margin:8px 0 0 18px}.admin-flash.error{background:#fff1f2;color:#991b1b;border-color:#fecaca} +@media(max-width:760px){.crud-toolbar,.crud-search{align-items:stretch;flex-direction:column}.crud-toolbar .record-count{align-self:flex-start}.crud-search input{width:100%}.crud-form-grid{grid-template-columns:1fr}.full-field{grid-column:auto}.crud-heading .admin-button,.crud-heading .toggle-button{width:100%}.crud-form-actions{align-items:stretch;flex-direction:column}.crud-form-actions>*{width:100%}.crud-table{min-width:760px!important}} + +.crud-heading-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}@media(max-width:640px){.crud-heading-actions{width:100%;align-items:stretch;flex-direction:column}} +.legal-shell{max-width:850px}.legal-shell h1{font-size:clamp(38px,6vw,64px);margin:10px 0 8px}.legal-meta{color:var(--muted);font-size:12px;margin-bottom:30px}.legal-copy{color:#cbd5e1;font-size:17px;line-height:1.85;white-space:normal} diff --git a/resources/views/admin/crud/form.blade.php b/resources/views/admin/crud/form.blade.php new file mode 100644 index 0000000..3c980ac --- /dev/null +++ b/resources/views/admin/crud/form.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.admin') +@section('title', ($record ? 'Edit ' : 'Add ').$definition['singular']) +@section('page-title', $definition['title']) +@section('content') +
+
{{ $record ? 'Edit record' : 'New record' }}

{{ $record ? 'Edit '.$definition['singular'] : 'Add '.$definition['singular'] }}

Complete the fields below and save your changes.

+ ← Back to {{ strtolower($definition['title']) }} +
+ +@if($errors->any()) +
Please correct the following:
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
+@endif + +
+
+ @csrf + @if($record) @method('PUT') @endif +
+ @foreach($definition['fields'] as $name => $field) + @php + $raw = old($name); + if ($raw === null && $record) { + if ($name === 'role' && method_exists($record, 'getRoleNames')) $raw = $record->getRoleNames()->first(); + elseif ($name === 'filters' && is_array($record->filters ?? null)) $raw = json_encode($record->filters, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + else $raw = data_get($record, $name); + } + if ($raw instanceof \Carbon\CarbonInterface && ($field['type'] ?? '') === 'datetime-local') $raw = $raw->format('Y-m-d\TH:i'); + if ($raw instanceof \Carbon\CarbonInterface && ($field['type'] ?? '') === 'date') $raw = $raw->format('Y-m-d'); + @endphp + @if(($field['type'] ?? 'text') === 'checkbox') + + @else + + @endif + @endforeach +
+
Cancel
+
+
+@endsection diff --git a/resources/views/admin/crud/index.blade.php b/resources/views/admin/crud/index.blade.php new file mode 100644 index 0000000..2b19287 --- /dev/null +++ b/resources/views/admin/crud/index.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.admin') +@section('title', $definition['title']) +@section('page-title', $definition['title']) +@section('content') +
+
+
Management
+

{{ $definition['title'] }}

+

Create, edit and remove {{ strtolower($definition['title']) }} records.

+
+
+ @if($resource === 'roles')Manage permissions@endif + @if($resource === 'permissions')Manage roles@endif + @if($resource === 'settings')
@csrf
@endif + + Add {{ strtolower($definition['singular']) }} +
+
+ +
+ + {{ number_format($records->total()) }} records +
+ +
+
+ + @foreach($definition['columns'] as $label)@endforeach + + @forelse($records as $record) + + @foreach($definition['columns'] as $path => $label) + @php + $value = $path === 'roles_text' ? $record->getRoleNames()->join(', ') : data_get($record, $path); + if ($value instanceof \Carbon\CarbonInterface) $value = $value->format('j M Y H:i'); + elseif (is_bool($value)) $value = $value ? 'Yes' : 'No'; + elseif (in_array($path, ['amount','total','deposit','price_from'], true) && $value !== null) $value = '£'.number_format((float)$value, 2); + @endphp + + @endforeach + + + @empty + + @endforelse + +
{{ $label }}Actions
{{ filled($value) ? $value : '—' }} +
+ Edit +
+ @csrf @method('DELETE') + +
+
+
+

No {{ strtolower($definition['title']) }} yet

Create the first record to start managing this area.

Add {{ strtolower($definition['singular']) }}
+
+
+
{{ $records->links() }}
+@endsection diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 6296461..0f7afb8 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -3,10 +3,10 @@ @section('page-title','Dashboard') @section('content')
Marketplace control centre

Dashboard

Live overview of users, DJs, bookings and moderation.

-
@foreach($stats as $key=>$value){{ ucfirst($key) }}{{ number_format($value) }}Open section →@endforeach
+
@foreach($stats as $key=>$value){{ ucfirst($key) }}{{ number_format($value) }}Open section →@endforeach
-

Newest users

Manage
@forelse($recentUsers as $user)
{{ $user->name }}{{ $user->email }}
{{ $user->created_at?->diffForHumans() }}
@empty
No users yet.
@endforelse
-

Latest enquiries

Clients
@forelse($recentEnquiries as $item)
{{ $item->user?->name }}{{ $item->djProfile?->stage_name }} · {{ $item->event_type }}
{{ ucfirst($item->status) }}
@empty
No enquiries yet.
@endforelse
-

Recent bookings

View all
@forelse($recentBookings as $item)@empty@endforelse
ClientDJDateTotalStatus
{{ $item->client?->name }}{{ $item->djProfile?->stage_name }}{{ optional($item->event_date)->format('j M Y') }}£{{ number_format((float)$item->total,2) }}{{ ucfirst($item->status) }}
No bookings yet.
+

Newest users

Manage
@forelse($recentUsers as $user)
{{ $user->name }}{{ $user->email }}
{{ $user->created_at?->diffForHumans() }}
@empty
No users yet.
@endforelse
+

Latest enquiries

Clients
@forelse($recentEnquiries as $item)
{{ $item->user?->name }}{{ $item->djProfile?->stage_name }} · {{ $item->event_type }}
{{ ucfirst($item->status) }}
@empty
No enquiries yet.
@endforelse
+

Recent bookings

View all
@forelse($recentBookings as $item)@empty@endforelse
ClientDJDateTotalStatus
{{ $item->client?->name }}{{ $item->djProfile?->stage_name }}{{ optional($item->event_date)->format('j M Y') }}£{{ number_format((float)$item->total,2) }}{{ ucfirst($item->status) }}
No bookings yet.
@endsection diff --git a/resources/views/admin/section.blade.php b/resources/views/admin/section.blade.php deleted file mode 100644 index bdbedf7..0000000 --- a/resources/views/admin/section.blade.php +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - {{ ucwords(str_replace('-', ' ', $section)) }} — BeatBook Admin - @vite(['resources/css/app.css']) - - -
- -
-
Administration
-

{{ ucwords(str_replace('-', ' ', $section)) }}

-

This BeatBook administration section is connected and ready for its management workflow.

-
- @foreach($counts as $label => $value) -
{{ ucfirst($label) }}{{ number_format($value) }}
- @endforeach -
-
-
- - diff --git a/resources/views/admin/sections/bookings.blade.php b/resources/views/admin/sections/bookings.blade.php deleted file mode 100644 index 0c8f5af..0000000 --- a/resources/views/admin/sections/bookings.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Bookings') @section('page-title','Bookings') @section('content') -
Events

Bookings

All marketplace bookings and event values.

{{ number_format($counts['bookings']) }} bookings
-
@forelse($bookings as $item)@empty@endforelse
ClientDJEvent dateTotalDepositStatus
{{ $item->client?->name }}{{ $item->djProfile?->stage_name }}{{ optional($item->event_date)->format('j M Y') }}£{{ number_format((float)$item->total,2) }}£{{ number_format((float)$item->deposit,2) }}{{ ucfirst($item->status) }}
No bookings yet.
{{ $bookings->links() }} -@endsection diff --git a/resources/views/admin/sections/clients.blade.php b/resources/views/admin/sections/clients.blade.php deleted file mode 100644 index 83beeec..0000000 --- a/resources/views/admin/sections/clients.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Clients') @section('page-title','Clients') @section('content') -
Customers

Clients

Review client activity across enquiries and bookings.

{{ number_format($counts['clients']) }} clients
-
@forelse($clients as $client)@empty@endforelse
ClientPhoneEnquiriesBookingsJoined
{{ $client->name }}{{ $client->email }}{{ $client->phone ?: '—' }}{{ $client->enquiries_count }}{{ $client->bookings_count }}{{ $client->created_at?->format('j M Y') }}
No clients yet.
{{ $clients->links() }} -@endsection diff --git a/resources/views/admin/sections/content.blade.php b/resources/views/admin/sections/content.blade.php deleted file mode 100644 index 860490b..0000000 --- a/resources/views/admin/sections/content.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Content') @section('page-title','Content') @section('content') -
Website

Content

Public-site content and marketplace presentation.

- -@endsection diff --git a/resources/views/admin/sections/djs.blade.php b/resources/views/admin/sections/djs.blade.php deleted file mode 100644 index d0146bf..0000000 --- a/resources/views/admin/sections/djs.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','DJs & businesses') @section('page-title','DJs & businesses') @section('content') -
Marketplace supply

DJs & businesses

Control visibility and featured placement.

{{ number_format($counts['djs']) }} profiles
-
@forelse($djs as $dj)@empty@endforelse
DJOwnerCityPricePublishedFeatured
{{ $dj->stage_name }}{{ $dj->business?->name ?: 'Independent' }}{{ $dj->user?->email }}{{ $dj->city }}{{ $dj->price_from ? '£'.number_format((float)$dj->price_from) : '—' }}
@csrf @method('PATCH')
@csrf @method('PATCH')
No DJ profiles yet.
{{ $djs->links() }} -@endsection diff --git a/resources/views/admin/sections/messages.blade.php b/resources/views/admin/sections/messages.blade.php deleted file mode 100644 index b3f3224..0000000 --- a/resources/views/admin/sections/messages.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Messages') @section('page-title','Messages') @section('content') -
Communication

Messages

Marketplace messaging will appear here once conversations are created.

-
✉

No conversations yet

Enquiry messaging can be added here without exposing private contact details between clients and DJs.

View clients
-@endsection diff --git a/resources/views/admin/sections/notifications.blade.php b/resources/views/admin/sections/notifications.blade.php deleted file mode 100644 index bbe5ef5..0000000 --- a/resources/views/admin/sections/notifications.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Notifications') @section('page-title','Notifications') @section('content') -
Delivery

Notifications

System alerts and delivery health.

-

Notification channels

EmailLaravel mail channelConfigured
DatabaseIn-app notificationsReady

Recommended events

Booking confirmation, new enquiry, review request, payment receipt and support replies can be delivered from this area.

-@endsection diff --git a/resources/views/admin/sections/payments.blade.php b/resources/views/admin/sections/payments.blade.php deleted file mode 100644 index 4badbef..0000000 --- a/resources/views/admin/sections/payments.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Payments') @section('page-title','Payments') @section('content') -
Finance

Payments

Payment state and booking value overview.

-
@forelse($bookings as $item)@empty@endforelse
BookingClientDJTotalDepositPayment
{{ Str::limit($item->id,10,'…') }}{{ $item->client?->name }}{{ $item->djProfile?->stage_name }}£{{ number_format((float)$item->total,2) }}£{{ number_format((float)$item->deposit,2) }}{{ $item->paid_at ? 'Paid' : 'Outstanding' }}
No payment records yet.
{{ $bookings->links() }} -@endsection diff --git a/resources/views/admin/sections/privacy.blade.php b/resources/views/admin/sections/privacy.blade.php deleted file mode 100644 index c0a8d2b..0000000 --- a/resources/views/admin/sections/privacy.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Privacy') @section('page-title','Privacy') @section('content') -
Compliance

Privacy

Quick access to the public legal policies and privacy controls.

- -@endsection diff --git a/resources/views/admin/sections/reports.blade.php b/resources/views/admin/sections/reports.blade.php deleted file mode 100644 index b9b7e48..0000000 --- a/resources/views/admin/sections/reports.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Reports') @section('page-title','Reports') @section('content') -
Performance

Reports

Marketplace totals calculated from live BeatBook data.

-
Booking value£{{ number_format((float)$report['booking_value'],2) }}
Deposits£{{ number_format((float)$report['deposits'],2) }}
Published DJs{{ $report['published_djs'] }}
Pending reviews{{ $report['pending_reviews'] }}
-@endsection diff --git a/resources/views/admin/sections/reviews.blade.php b/resources/views/admin/sections/reviews.blade.php deleted file mode 100644 index 5dbc132..0000000 --- a/resources/views/admin/sections/reviews.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Reviews') @section('page-title','Reviews') @section('content') -
Trust & safety

Reviews

Moderate feedback before it appears publicly.

{{ number_format($counts['reviews']) }} reviews
-
@forelse($reviews as $review)@empty@endforelse
ReviewerDJRatingReviewStatusModerate
{{ $review->user?->name }}{{ $review->djProfile?->stage_name }}★ {{ $review->rating }}/5{{ $review->title ?: 'Review' }}{{ Str::limit($review->body,80) }}{{ ucfirst($review->status) }}
@csrf @method('PATCH')
No reviews yet.
{{ $reviews->links() }} -@endsection diff --git a/resources/views/admin/sections/roles.blade.php b/resources/views/admin/sections/roles.blade.php deleted file mode 100644 index b285cc1..0000000 --- a/resources/views/admin/sections/roles.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Roles & permissions') @section('page-title','Roles & permissions') @section('content') -
Access control

Roles & permissions

Roles currently available to BeatBook accounts.

-
@foreach($roles as $role)
{{ $role->name }}{{ $role->users_count }}

{{ Str::plural('user',$role->users_count) }}

@endforeach
-@endsection diff --git a/resources/views/admin/sections/settings.blade.php b/resources/views/admin/sections/settings.blade.php deleted file mode 100644 index f6c7104..0000000 --- a/resources/views/admin/sections/settings.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Settings') @section('page-title','Settings') @section('content') -
Application

Settings

Operational controls for the BeatBook installation.

-

Environment

Application{{ config('app.name') }}
Environment{{ app()->environment() }}
URL{{ config('app.url') }}

Maintenance

Clear application cache after configuration or content changes.

@csrf
-@endsection diff --git a/resources/views/admin/sections/support.blade.php b/resources/views/admin/sections/support.blade.php deleted file mode 100644 index b9dc74e..0000000 --- a/resources/views/admin/sections/support.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Support') @section('page-title','Support') @section('content') -
Customer care

Support

Central area for customer and DJ support cases.

-
?

No open support cases

The support queue is ready for a ticket model and inbound support form when you enable that workflow.

-@endsection diff --git a/resources/views/admin/sections/users.blade.php b/resources/views/admin/sections/users.blade.php deleted file mode 100644 index e5fc220..0000000 --- a/resources/views/admin/sections/users.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends('layouts.admin') @section('title','Users') @section('page-title','Users') @section('content') -
People

Users

Manage accounts and assign marketplace roles.

{{ number_format($counts['users']) }} users
-
@foreach($users as $user)@endforeach
UserRoleJoinedChange role
{{ $user->name }}{{ $user->email }}{{ $user->getRoleNames()->join(', ') ?: 'No role' }}{{ $user->created_at?->format('j M Y') }}
@csrf @method('PATCH')
{{ $users->links() }} -@endsection diff --git a/resources/views/content/show.blade.php b/resources/views/content/show.blade.php new file mode 100644 index 0000000..dec3a34 --- /dev/null +++ b/resources/views/content/show.blade.php @@ -0,0 +1,9 @@ +@extends('layouts.app') +@section('title', $page->title) +@section('content') +
+@endsection diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 87c06d4..9dbf08c 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -18,12 +18,12 @@
@@ -40,6 +40,7 @@ Public site @if(session('success'))
{{ session('success') }}
@endif + @if(session('error'))
{{ session('error') }}
@endif
@yield('content')
diff --git a/resources/views/legal/page.blade.php b/resources/views/legal/page.blade.php new file mode 100644 index 0000000..8771e48 --- /dev/null +++ b/resources/views/legal/page.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.app') +@section('title', $document?->title ?? $fallbackTitle) +@section('content') +
+@endsection diff --git a/routes/web.php b/routes/web.php index d4e64e1..b28a5a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,21 +3,24 @@ use App\Http\Controllers\Account\DashboardController as AccountDashboardController; use App\Http\Controllers\Account\PageController as AccountPageController; use App\Http\Controllers\Admin\ActionController as AdminActionController; +use App\Http\Controllers\Admin\CrudController; use App\Http\Controllers\Admin\DashboardController; -use App\Http\Controllers\Admin\SectionController; use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\NewPasswordController; use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\RegisteredUserController; +use App\Http\Controllers\ContentPageController; use App\Http\Controllers\DjController; use App\Http\Controllers\EnquiryController; use App\Http\Controllers\HomeController; +use App\Http\Controllers\LegalController; use App\Http\Controllers\ReviewController; use Illuminate\Support\Facades\Route; Route::get('/', [HomeController::class, 'index'])->name('home'); Route::get('/djs', [DjController::class, 'index'])->name('djs.index'); Route::get('/dj/{djProfile:slug}', [DjController::class, 'show'])->name('djs.show'); +Route::get('/pages/{page:slug}', [ContentPageController::class, 'show'])->name('content.show'); Route::middleware('guest')->group(function () { Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); @@ -44,14 +47,16 @@ Route::middleware('auth')->group(function () { Route::middleware(['auth', 'role:admin|super-admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/', DashboardController::class)->name('dashboard'); - Route::get('/{section}', SectionController::class)->name('section'); - Route::patch('/users/{user}/role', [AdminActionController::class, 'userRole'])->name('users.role'); - Route::patch('/djs/{djProfile}/published', [AdminActionController::class, 'toggleDjPublished'])->name('djs.published'); - Route::patch('/djs/{djProfile}/featured', [AdminActionController::class, 'toggleDjFeatured'])->name('djs.featured'); - Route::patch('/reviews/{review}/status', [AdminActionController::class, 'reviewStatus'])->name('reviews.status'); Route::post('/settings/clear-cache', [AdminActionController::class, 'clearCaches'])->name('settings.clear-cache'); + + Route::get('/{resource}', [CrudController::class, 'index'])->name('resources.index'); + Route::get('/{resource}/create', [CrudController::class, 'create'])->name('resources.create'); + Route::post('/{resource}', [CrudController::class, 'store'])->name('resources.store'); + Route::get('/{resource}/{id}/edit', [CrudController::class, 'edit'])->name('resources.edit'); + Route::put('/{resource}/{id}', [CrudController::class, 'update'])->name('resources.update'); + Route::delete('/{resource}/{id}', [CrudController::class, 'destroy'])->name('resources.destroy'); }); -Route::view('/privacy', 'legal.privacy')->name('privacy'); -Route::view('/cookies', 'legal.cookies')->name('cookies'); -Route::view('/terms', 'legal.terms')->name('terms'); +Route::get('/privacy', [LegalController::class, 'privacy'])->name('privacy'); +Route::get('/cookies', [LegalController::class, 'cookies'])->name('cookies'); +Route::get('/terms', [LegalController::class, 'terms'])->name('terms'); diff --git a/tests/Feature/AccountAndAdminRoutesTest.php b/tests/Feature/AccountAndAdminRoutesTest.php index f3113d3..94c2323 100644 --- a/tests/Feature/AccountAndAdminRoutesTest.php +++ b/tests/Feature/AccountAndAdminRoutesTest.php @@ -26,11 +26,12 @@ class AccountAndAdminRoutesTest extends TestCase $admin = User::factory()->create(); $admin->assignRole('admin'); - $sections = ['users', 'djs', 'clients', 'bookings', 'payments', 'reviews', 'messages', 'notifications', 'support', 'reports', 'content', 'roles', 'privacy', 'settings']; + $sections = ['users', 'clients', 'djs', 'businesses', 'bookings', 'payments', 'reviews', 'messages', 'notifications', 'support', 'reports', 'content', 'roles', 'privacy', 'settings']; $this->actingAs($admin)->get('/admin')->assertSuccessful(); foreach ($sections as $section) { $this->actingAs($admin)->get('/admin/'.$section)->assertSuccessful(); + $this->actingAs($admin)->get('/admin/'.$section.'/create')->assertSuccessful(); } } } diff --git a/tests/Feature/AdminCrudTest.php b/tests/Feature/AdminCrudTest.php new file mode 100644 index 0000000..5956f33 --- /dev/null +++ b/tests/Feature/AdminCrudTest.php @@ -0,0 +1,70 @@ +withoutMiddleware(PreventRequestForgery::class); + Role::create(['name' => 'admin', 'guard_name' => 'web']); + } + + public function test_admin_can_create_edit_and_delete_setting(): void + { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin)->post('/admin/settings', [ + 'label' => 'Marketplace name', + 'key' => 'marketplace.name', + 'value' => 'BeatBook', + 'type' => 'text', + 'group' => 'general', + 'description' => 'Displayed marketplace name.', + ])->assertRedirect('/admin/settings'); + + $setting = AppSetting::firstOrFail(); + $this->assertSame('BeatBook', $setting->value); + + $this->actingAs($admin)->put('/admin/settings/'.$setting->id, [ + 'label' => 'Marketplace name', + 'key' => 'marketplace.name', + 'value' => 'BeatBook UK', + 'type' => 'text', + 'group' => 'general', + 'description' => 'Displayed marketplace name.', + ])->assertRedirect('/admin/settings'); + + $this->assertDatabaseHas('app_settings', ['id' => $setting->id, 'value' => 'BeatBook UK']); + $this->actingAs($admin)->delete('/admin/settings/'.$setting->id)->assertRedirect('/admin/settings'); + $this->assertDatabaseMissing('app_settings', ['id' => $setting->id]); + } + + public function test_admin_can_create_client(): void + { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin)->post('/admin/clients', [ + 'name' => 'New Client', + 'email' => 'new-client@example.test', + 'phone' => '01234567890', + 'password' => 'password12345', + 'marketing_opt_in' => '0', + ])->assertRedirect('/admin/clients'); + + $client = User::where('email', 'new-client@example.test')->firstOrFail(); + $this->assertTrue($client->hasRole('client')); + } +}