diff --git a/Makefile b/Makefile index 98ec388..adcae22 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,10 @@ queue: php artisan queue:work --queue=mailwizz --tries=3 # Start Laravel dev server + Vite in parallel +# PHP defaults (often post_max_size=2M) reject page forms with background (≤5MB) + logo (≤2MB). dev: npx concurrently --names "laravel,vite" --prefix-colors "green,blue" \ - "php artisan serve" \ + "php -d post_max_size=64M -d upload_max_filesize=32M artisan serve" \ "npm run dev" # ────────────────────────────────────────── diff --git a/app/Http/Controllers/Admin/PageController.php b/app/Http/Controllers/Admin/PageController.php index cb16d82..eea531d 100644 --- a/app/Http/Controllers/Admin/PageController.php +++ b/app/Http/Controllers/Admin/PageController.php @@ -5,43 +5,128 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StorePreregistrationPageRequest; +use App\Http\Requests\Admin\UpdatePreregistrationPageRequest; use App\Models\PreregistrationPage; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Illuminate\View\View; class PageController extends Controller { - public function index(): \Illuminate\View\View + public function __construct() { - return view('admin.pages.index'); + $this->authorizeResource(PreregistrationPage::class, 'page'); } - public function create(): \Illuminate\View\View + public function index(Request $request): View + { + $query = PreregistrationPage::query() + ->withCount('subscribers') + ->orderByDesc('start_date'); + + if (! $request->user()?->isSuperadmin()) { + $query->where('user_id', $request->user()->id); + } else { + $query->with('user'); + } + + $pages = $query->get(); + + return view('admin.pages.index', compact('pages')); + } + + public function create(): View { return view('admin.pages.create'); } - public function store(Request $request): \Illuminate\Http\RedirectResponse + public function store(StorePreregistrationPageRequest $request): RedirectResponse { - return redirect()->route('admin.pages.index'); + $validated = $request->validated(); + $background = $request->file('background_image'); + $logo = $request->file('logo_image'); + unset($validated['background_image'], $validated['logo_image']); + + $validated['slug'] = (string) Str::uuid(); + $validated['user_id'] = $request->user()->id; + + $page = DB::transaction(function () use ($validated, $background, $logo): PreregistrationPage { + $page = PreregistrationPage::create($validated); + $paths = []; + if ($background !== null) { + $paths['background_image'] = $background->store("preregister/pages/{$page->id}", 'public'); + } + if ($logo !== null) { + $paths['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public'); + } + if ($paths !== []) { + $page->update($paths); + } + + return $page->fresh(); + }); + + return redirect() + ->route('admin.pages.index') + ->with('status', __('Page created. Public URL: :url', ['url' => url('/r/'.$page->slug)])); } - public function show(PreregistrationPage $page): \Illuminate\View\View + public function show(PreregistrationPage $page): RedirectResponse { - return view('admin.pages.show', compact('page')); + return redirect()->route('admin.pages.edit', $page); } - public function edit(PreregistrationPage $page): \Illuminate\View\View + public function edit(PreregistrationPage $page): View { return view('admin.pages.edit', compact('page')); } - public function update(Request $request, PreregistrationPage $page): \Illuminate\Http\RedirectResponse + public function update(UpdatePreregistrationPageRequest $request, PreregistrationPage $page): RedirectResponse { - return redirect()->route('admin.pages.index'); + $validated = $request->validated(); + $background = $request->file('background_image'); + $logo = $request->file('logo_image'); + unset($validated['background_image'], $validated['logo_image']); + + DB::transaction(function () use ($validated, $background, $logo, $page): void { + if ($background !== null) { + if ($page->background_image !== null) { + Storage::disk('public')->delete($page->background_image); + } + $validated['background_image'] = $background->store("preregister/pages/{$page->id}", 'public'); + } + if ($logo !== null) { + if ($page->logo_image !== null) { + Storage::disk('public')->delete($page->logo_image); + } + $validated['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public'); + } + $page->update($validated); + }); + + return redirect() + ->route('admin.pages.index') + ->with('status', __('Page updated.')); } - public function destroy(PreregistrationPage $page): \Illuminate\Http\RedirectResponse + public function destroy(PreregistrationPage $page): RedirectResponse { - return redirect()->route('admin.pages.index'); + DB::transaction(function () use ($page): void { + if ($page->background_image !== null) { + Storage::disk('public')->delete($page->background_image); + } + if ($page->logo_image !== null) { + Storage::disk('public')->delete($page->logo_image); + } + $page->delete(); + }); + + return redirect() + ->route('admin.pages.index') + ->with('status', __('Page deleted.')); } } diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index 7f61e72..6009655 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -5,34 +5,58 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\IndexSubscriberRequest; use App\Models\PreregistrationPage; +use Illuminate\View\View; +use Symfony\Component\HttpFoundation\StreamedResponse; class SubscriberController extends Controller { - public function index(PreregistrationPage $page): \Illuminate\View\View + public function index(IndexSubscriberRequest $request, PreregistrationPage $page): View { - return view('admin.subscribers.index', compact('page')); + $search = $request->validated('search'); + $subscribers = $page->subscribers() + ->search(is_string($search) ? $search : null) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return view('admin.subscribers.index', compact('page', 'subscribers')); } - public function export(PreregistrationPage $page): \Symfony\Component\HttpFoundation\StreamedResponse + public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse { - $this->authorize('view', $page); + $search = $request->validated('search'); + $subscribers = $page->subscribers() + ->search(is_string($search) ? $search : null) + ->orderBy('created_at') + ->get(); - $subscribers = $page->subscribers()->orderBy('created_at')->get(); + $phoneEnabled = $page->phone_enabled; - return response()->streamDownload(function () use ($subscribers, $page) { + return response()->streamDownload(function () use ($subscribers, $phoneEnabled): void { $handle = fopen('php://output', 'w'); - fputcsv($handle, ['First Name', 'Last Name', 'Email', 'Phone', 'Registered At']); - foreach ($subscribers as $sub) { - fputcsv($handle, [ - $sub->first_name, - $sub->last_name, - $sub->email, - $sub->phone, - $sub->created_at->toDateTimeString(), - ]); + $headers = ['First Name', 'Last Name', 'Email']; + if ($phoneEnabled) { + $headers[] = 'Phone'; } + $headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']); + fputcsv($handle, $headers); + + foreach ($subscribers as $sub) { + $row = [$sub->first_name, $sub->last_name, $sub->email]; + if ($phoneEnabled) { + $row[] = $sub->phone ?? ''; + } + $row[] = $sub->created_at?->toDateTimeString() ?? ''; + $row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No'; + $row[] = $sub->synced_at?->toDateTimeString() ?? ''; + fputcsv($handle, $row); + } + fclose($handle); - }, "subscribers-{$page->slug}.csv"); + }, 'subscribers-'.$page->slug.'.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); } } diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index a14c370..4ee06f3 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -5,38 +5,71 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StoreUserRequest; +use App\Http\Requests\Admin\UpdateUserRequest; use App\Models\User; -use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Hash; +use Illuminate\View\View; class UserController extends Controller { - public function index(): \Illuminate\View\View + public function __construct() { - return view('admin.users.index'); + $this->authorizeResource(User::class, 'user', [ + 'except' => ['show'], + ]); } - public function create(): \Illuminate\View\View + public function index(): View + { + $users = User::query()->orderBy('name')->paginate(25); + + return view('admin.users.index', compact('users')); + } + + public function create(): View { return view('admin.users.create'); } - public function store(Request $request): \Illuminate\Http\RedirectResponse + public function store(StoreUserRequest $request): RedirectResponse { - return redirect()->route('admin.users.index'); + $data = $request->validated(); + $data['password'] = Hash::make($data['password']); + User::query()->create($data); + + return redirect() + ->route('admin.users.index') + ->with('status', __('User created.')); } - public function edit(User $user): \Illuminate\View\View + public function edit(User $user): View { return view('admin.users.edit', compact('user')); } - public function update(Request $request, User $user): \Illuminate\Http\RedirectResponse + public function update(UpdateUserRequest $request, User $user): RedirectResponse { - return redirect()->route('admin.users.index'); + $data = $request->validated(); + if ($request->filled('password')) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + $user->update($data); + + return redirect() + ->route('admin.users.index') + ->with('status', __('User updated.')); } - public function destroy(User $user): \Illuminate\Http\RedirectResponse + public function destroy(User $user): RedirectResponse { - return redirect()->route('admin.users.index'); + $user->delete(); + + return redirect() + ->route('admin.users.index') + ->with('status', __('User deleted.')); } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..6cdfe82 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -1,8 +1,15 @@ route('page'); + if (! $page instanceof PreregistrationPage) { + return false; + } + + return $this->user()?->can('view', $page) ?? false; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/StorePreregistrationPageRequest.php b/app/Http/Requests/Admin/StorePreregistrationPageRequest.php new file mode 100644 index 0000000..34c0ad7 --- /dev/null +++ b/app/Http/Requests/Admin/StorePreregistrationPageRequest.php @@ -0,0 +1,32 @@ +user()?->can('create', PreregistrationPage::class) ?? false; + } + + protected function prepareForValidation(): void + { + $this->preparePreregistrationPageFields(); + } + + /** + * @return array> + */ + public function rules(): array + { + return $this->preregistrationPageRules(); + } +} diff --git a/app/Http/Requests/Admin/StoreUserRequest.php b/app/Http/Requests/Admin/StoreUserRequest.php new file mode 100644 index 0000000..f573dd6 --- /dev/null +++ b/app/Http/Requests/Admin/StoreUserRequest.php @@ -0,0 +1,32 @@ +user()?->can('create', User::class) ?? false; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)], + 'password' => ['required', 'confirmed', Password::defaults()], + 'role' => ['required', Rule::in(['superadmin', 'user'])], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php b/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php new file mode 100644 index 0000000..0b90862 --- /dev/null +++ b/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php @@ -0,0 +1,36 @@ +route('page'); + if ($page === null) { + return false; + } + + return $this->user()?->can('update', $page) ?? false; + } + + protected function prepareForValidation(): void + { + $this->preparePreregistrationPageFields(); + } + + /** + * @return array> + */ + public function rules(): array + { + return $this->preregistrationPageRules(); + } +} diff --git a/app/Http/Requests/Admin/UpdateUserRequest.php b/app/Http/Requests/Admin/UpdateUserRequest.php new file mode 100644 index 0000000..0833e05 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateUserRequest.php @@ -0,0 +1,54 @@ +route('user'); + if (! $user instanceof User) { + return false; + } + + return $this->user()?->can('update', $user) ?? false; + } + + protected function prepareForValidation(): void + { + if ($this->input('password') === '' || $this->input('password') === null) { + $this->merge(['password' => null]); + } + } + + /** + * @return array> + */ + public function rules(): array + { + /** @var User $target */ + $target = $this->route('user'); + + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($target->id), + ], + 'password' => ['nullable', 'confirmed', Password::defaults()], + 'role' => ['required', Rule::in(['superadmin', 'user'])], + ]; + } +} diff --git a/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php b/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php new file mode 100644 index 0000000..8d9cb2d --- /dev/null +++ b/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php @@ -0,0 +1,46 @@ +> + */ + protected function preregistrationPageRules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'heading' => ['required', 'string', 'max:255'], + 'intro_text' => ['nullable', 'string'], + 'thank_you_message' => ['nullable', 'string'], + 'expired_message' => ['nullable', 'string'], + 'ticketshop_url' => ['nullable', 'string', 'url:http,https', 'max:255'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after:start_date'], + 'phone_enabled' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + 'background_image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'], + 'logo_image' => ['nullable', 'file', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], + ]; + } + + protected function preparePreregistrationPageFields(): void + { + $ticketshop = $this->input('ticketshop_url'); + $ticketshopNormalized = null; + if (is_string($ticketshop) && trim($ticketshop) !== '') { + $ticketshopNormalized = trim($ticketshop); + } + + $this->merge([ + 'phone_enabled' => $this->boolean('phone_enabled'), + 'is_active' => $this->boolean('is_active'), + 'ticketshop_url' => $ticketshopNormalized, + ]); + } +} diff --git a/app/Models/PreregistrationPage.php b/app/Models/PreregistrationPage.php index 33664be..7c617a8 100644 --- a/app/Models/PreregistrationPage.php +++ b/app/Models/PreregistrationPage.php @@ -74,6 +74,7 @@ class PreregistrationPage extends Model public function isActive(): bool { $now = Carbon::now(); + return $now->gte($this->start_date) && $now->lte($this->end_date); } @@ -86,4 +87,20 @@ class PreregistrationPage extends Model { return $query->where('is_active', true); } + + /** + * Lifecycle label for admin tables: before registration opens, open window, or after end. + */ + public function statusKey(): string + { + if ($this->isBeforeStart()) { + return 'before_start'; + } + + if ($this->isExpired()) { + return 'expired'; + } + + return 'active'; + } } diff --git a/app/Models/Subscriber.php b/app/Models/Subscriber.php index caf3c9e..e83831f 100644 --- a/app/Models/Subscriber.php +++ b/app/Models/Subscriber.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -34,4 +35,19 @@ class Subscriber extends Model { return $this->belongsTo(PreregistrationPage::class); } + + public function scopeSearch(Builder $query, ?string $term): Builder + { + if ($term === null || $term === '') { + return $query; + } + + $like = '%'.$term.'%'; + + return $query->where(function (Builder $q) use ($like): void { + $q->where('first_name', 'like', $like) + ->orWhere('last_name', 'like', $like) + ->orWhere('email', 'like', $like); + }); + } } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..6846d3a --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,34 @@ +isSuperadmin(); + } + + public function create(User $user): bool + { + return $user->isSuperadmin(); + } + + public function update(User $user, User $target): bool + { + return $user->isSuperadmin(); + } + + public function delete(User $user, User $target): bool + { + if (! $user->isSuperadmin()) { + return false; + } + + return $user->id !== $target->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..24f164c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,7 +1,10 @@ withRouting( @@ -12,9 +15,33 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ - 'role' => \App\Http\Middleware\CheckRole::class, + 'role' => CheckRole::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->renderable(function (PostTooLargeException $e, Request $request) { + $contentLength = (int) $request->server('CONTENT_LENGTH', 0); + $contentLengthMb = $contentLength > 0 ? $contentLength / 1048576 : 0; + $postMaxSize = ini_get('post_max_size') ?: 'unknown'; + $uploadMaxSize = ini_get('upload_max_filesize') ?: 'unknown'; + $backUrl = url()->previous() ?: url('/'); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('The request body is too large. Increase PHP post_max_size and upload_max_filesize (e.g. `composer run dev` or php.ini).'), + 'content_length_bytes' => $contentLength, + 'post_max_size' => $postMaxSize, + 'upload_max_filesize' => $uploadMaxSize, + ], 413); + } + + // ValidatePostSize runs before the web middleware group (no session yet), so a flash on redirect is often lost. + return response() + ->view('errors.post-too-large', [ + 'contentLengthMb' => $contentLengthMb, + 'postMaxSize' => $postMaxSize, + 'uploadMaxSize' => $uploadMaxSize, + 'backUrl' => $backUrl, + ], 413); + }); })->create(); diff --git a/composer.json b/composer.json index cfc0e92..8654a54 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php -d post_max_size=64M -d upload_max_filesize=32M artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "test": [ "@php artisan config:clear --ansi", diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..2af6906 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,7 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + $this->call(SuperadminSeeder::class); User::factory()->create([ 'name' => 'Test User', diff --git a/resources/css/app.css b/resources/css/app.css index b5c61c9..87c5f2d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +[x-cloak] { + display: none !important; +} diff --git a/resources/views/admin/pages/_form.blade.php b/resources/views/admin/pages/_form.blade.php new file mode 100644 index 0000000..5a10b25 --- /dev/null +++ b/resources/views/admin/pages/_form.blade.php @@ -0,0 +1,121 @@ +@php + /** @var \App\Models\PreregistrationPage|null $page */ + $page = $page ?? null; +@endphp + +
+
+ + + @error('title') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('heading') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('intro_text') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('thank_you_message') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('expired_message') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('ticketshop_url') +

{{ $message }}

+ @enderror +
+ +
+
+ + + @error('start_date') +

{{ $message }}

+ @enderror +
+
+ + + @error('end_date') +

{{ $message }}

+ @enderror +
+
+ +
+ + +
+ +
+ +

{{ __('JPG, PNG or WebP. Max 5 MB.') }}

+ + @error('background_image') +

{{ $message }}

+ @enderror + @if ($page?->background_image) +

{{ __('Current file:') }} {{ __('View') }}

+ @endif +
+ +
+ +

{{ __('JPG, PNG, WebP or SVG. Max 2 MB.') }}

+ + @error('logo_image') +

{{ $message }}

+ @enderror + @if ($page?->logo_image) +

{{ __('Current file:') }} {{ __('View') }}

+ @endif +
+
diff --git a/resources/views/admin/pages/create.blade.php b/resources/views/admin/pages/create.blade.php index f4d6cbd..7d653fe 100644 --- a/resources/views/admin/pages/create.blade.php +++ b/resources/views/admin/pages/create.blade.php @@ -5,8 +5,32 @@ @section('mobile_title', __('New page')) @section('content') -
-

{{ __('Create page') }}

-

{{ __('Form will be added in Step 8.') }}

+
+
+ ← {{ __('Back to pages') }} +

{{ __('Create pre-registration page') }}

+

{{ __('After saving, use the pages list to copy the public URL.') }}

+
+ +
+ @csrf + @if ($errors->any()) + + @endif + @include('admin.pages._form', ['page' => null]) +
+ + {{ __('Cancel') }} +
+
@endsection diff --git a/resources/views/admin/pages/edit.blade.php b/resources/views/admin/pages/edit.blade.php index f15864e..3329f57 100644 --- a/resources/views/admin/pages/edit.blade.php +++ b/resources/views/admin/pages/edit.blade.php @@ -2,11 +2,38 @@ @section('title', __('Edit') . ' — ' . $page->title) -@section('mobile_title', __('Edit')) +@section('mobile_title', __('Edit page')) @section('content') -
-

{{ __('Edit page') }}

-

{{ __('Form will be added in Step 8.') }}

+
+
+ ← {{ __('Back to pages') }} +

{{ __('Edit page') }}

+

+ {{ __('Public URL') }}: {{ url('/r/'.$page->slug) }} +

+
+ +
+ @csrf + @method('PUT') + @if ($errors->any()) + + @endif + @include('admin.pages._form', ['page' => $page]) +
+ + {{ __('Cancel') }} +
+
@endsection diff --git a/resources/views/admin/pages/index.blade.php b/resources/views/admin/pages/index.blade.php index 8fad587..81a0eba 100644 --- a/resources/views/admin/pages/index.blade.php +++ b/resources/views/admin/pages/index.blade.php @@ -5,8 +5,106 @@ @section('mobile_title', __('Pages')) @section('content') -
-

{{ __('Pre-registration pages') }}

-

{{ __('Page list and CRUD will be added in the next step.') }}

+
+
+
+

{{ __('Pre-registration pages') }}

+

{{ __('Manage landing pages and subscriber lists.') }}

+
+ @can('create', \App\Models\PreregistrationPage::class) + + {{ __('New page') }} + + @endcan +
+ +
+
+ + + + @if (auth()->user()->isSuperadmin()) + + @endif + + + + + + + + + + @forelse ($pages as $page) + @php + $publicUrl = route('public.page', ['slug' => $page->slug]); + $key = $page->statusKey(); + $statusClasses = match ($key) { + 'before_start' => 'bg-slate-100 text-slate-800', + 'active' => 'bg-emerald-100 text-emerald-800', + 'expired' => 'bg-rose-100 text-rose-800', + default => 'bg-slate-100 text-slate-800', + }; + $statusLabel = match ($key) { + 'before_start' => __('Before start'), + 'active' => __('Active'), + 'expired' => __('Expired'), + default => $key, + }; + @endphp + + @if (auth()->user()->isSuperadmin()) + + @endif + + + + + + + + @empty + + + + @endforelse + +
{{ __('Owner') }}{{ __('Title') }}{{ __('Status') }}{{ __('Start') }}{{ __('End') }}{{ __('Subscribers') }}{{ __('Actions') }}
{{ $page->user?->email ?? '—' }}{{ $page->title }} + {{ $statusLabel }} + {{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}{{ number_format($page->subscribers_count) }} +
+ @can('update', $page) + {{ __('Edit') }} + @endcan + @can('view', $page) + {{ __('Subscribers') }} + @endcan + + @can('delete', $page) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+ {{ __('No pages yet.') }} + @can('create', \App\Models\PreregistrationPage::class) + {{ __('Create one') }} + @endcan +
+
+
@endsection diff --git a/resources/views/admin/pages/show.blade.php b/resources/views/admin/pages/show.blade.php deleted file mode 100644 index a5ed663..0000000 --- a/resources/views/admin/pages/show.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@extends('layouts.admin') - -@section('title', $page->title) - -@section('mobile_title', $page->title) - -@section('content') -
-

{{ $page->title }}

-

{{ __('Detail view placeholder.') }}

-
-@endsection diff --git a/resources/views/admin/subscribers/index.blade.php b/resources/views/admin/subscribers/index.blade.php new file mode 100644 index 0000000..431a8f5 --- /dev/null +++ b/resources/views/admin/subscribers/index.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.admin') + +@section('title', __('Subscribers') . ' — ' . $page->title) + +@section('mobile_title', __('Subscribers')) + +@section('content') +
+
+ ← {{ __('Back to pages') }} +

{{ __('Subscribers') }}

+

{{ $page->title }}

+
+ +
+
+ +
+ + +
+ @error('search') +

{{ $message }}

+ @enderror +
+ + {{ __('Export CSV') }} + +
+ +
+
+ + + + + + + @if ($page->phone_enabled) + + @endif + + + + + + @forelse ($subscribers as $subscriber) + + + + + @if ($page->phone_enabled) + + @endif + + + + @empty + + + + @endforelse + +
{{ __('First name') }}{{ __('Last name') }}{{ __('Email') }}{{ __('Phone') }}{{ __('Registered at') }}{{ __('Mailwizz') }}
{{ $subscriber->first_name }}{{ $subscriber->last_name }}{{ $subscriber->email }}{{ $subscriber->phone ?? '—' }}{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }} + @if ($subscriber->synced_to_mailwizz) + + + + @else + + + + @endif +
+ {{ __('No subscribers match your criteria.') }} +
+
+ @if ($subscribers->hasPages()) +
+ {{ $subscribers->links() }} +
+ @endif +
+
+@endsection diff --git a/resources/views/admin/users/_form.blade.php b/resources/views/admin/users/_form.blade.php new file mode 100644 index 0000000..5ce815e --- /dev/null +++ b/resources/views/admin/users/_form.blade.php @@ -0,0 +1,55 @@ +@php + /** @var \App\Models\User|null $user */ + $user = $user ?? null; + $isEdit = $user !== null; +@endphp + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + @if ($isEdit) +

{{ __('Leave blank to keep the current password.') }}

+ @endif + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + @error('role') +

{{ $message }}

+ @enderror +
+
diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php index c3ad2f9..03a5b7d 100644 --- a/resources/views/admin/users/create.blade.php +++ b/resources/views/admin/users/create.blade.php @@ -5,8 +5,21 @@ @section('mobile_title', __('New user')) @section('content') -
-

{{ __('Create user') }}

-

{{ __('Form will be added in Step 10.') }}

+
+
+ ← {{ __('Back to users') }} +

{{ __('Create user') }}

+
+ +
+ @csrf + @include('admin.users._form', ['user' => null]) +
+ + {{ __('Cancel') }} +
+
@endsection diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index d851d0e..d74d293 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -5,9 +5,23 @@ @section('mobile_title', __('Edit user')) @section('content') -
-

{{ __('Edit user') }}

-

{{ $user->email }}

-

{{ __('Form will be added in Step 10.') }}

+
+
+ ← {{ __('Back to users') }} +

{{ __('Edit user') }}

+

{{ $user->email }}

+
+ +
+ @csrf + @method('PUT') + @include('admin.users._form', ['user' => $user]) +
+ + {{ __('Cancel') }} +
+
@endsection diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index 6808d62..7c3d58f 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -5,8 +5,64 @@ @section('mobile_title', __('Users')) @section('content') -
-

{{ __('Users') }}

-

{{ __('User management will be completed in a later step.') }}

+
+
+
+

{{ __('Users') }}

+

{{ __('Backend accounts and roles.') }}

+
+ @can('create', \App\Models\User::class) + + {{ __('New user') }} + + @endcan +
+ +
+
+ + + + + + + + + + + @foreach ($users as $user) + + + + + + + @endforeach + +
{{ __('Name') }}{{ __('Email') }}{{ __('Role') }}{{ __('Actions') }}
{{ $user->name }}{{ $user->email }} + + {{ $user->role === 'superadmin' ? __('Superadmin') : __('User') }} + + + @can('update', $user) + {{ __('Edit') }} + @endcan + @can('delete', $user) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+ @if ($users->hasPages()) +
+ {{ $users->links() }} +
+ @endif +
@endsection diff --git a/resources/views/errors/post-too-large.blade.php b/resources/views/errors/post-too-large.blade.php new file mode 100644 index 0000000..b78f02d --- /dev/null +++ b/resources/views/errors/post-too-large.blade.php @@ -0,0 +1,29 @@ + + + + + + {{ __('Upload too large') }} — {{ config('app.name') }} + + + +

{{ __('Upload too large for your PHP settings') }}

+
+

{{ __('This request was about :size MB. Your server limit post_max_size is :post (and upload_max_filesize is :upload). Both must be larger than your form plus files (background up to 5 MB + logo up to 2 MB).', ['size' => number_format($contentLengthMb, 2), 'post' => $postMaxSize, 'upload' => $uploadMaxSize]) }}

+
+

{{ __('Fix (local development)') }}

+
    +
  • {{ __('Run') }} composer run dev {{ __('or') }} make dev — {{ __('they start PHP with higher limits.') }}
  • +
  • {{ __('Or run:') }} php -d post_max_size=64M -d upload_max_filesize=32M artisan serve
  • +
  • {{ __('Or edit php.ini (see php --ini) and restart.') }}
  • +
+

{{ __('← Back to previous page') }}

+ + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 1fc7e04..d9a1158 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -120,6 +120,12 @@
@endif + @if (session('error')) + + @endif + @yield('content')
diff --git a/routes/web.php b/routes/web.php index e97c5a3..4e64f2a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,7 +8,6 @@ use App\Http\Controllers\Admin\MailwizzController; use App\Http\Controllers\Admin\PageController; use App\Http\Controllers\Admin\SubscriberController; use App\Http\Controllers\Admin\UserController; -use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\PublicPageController; use Illuminate\Support\Facades\Route; @@ -32,9 +31,9 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group( // Pre-registration pages (CRUD) Route::resource('pages', PageController::class); - // Subscribers (nested under pages) - Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index'); + // Subscribers (nested under pages) — export before index so the path is unambiguous Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export'); + Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index'); // Mailwizz configuration (nested under pages) Route::get('pages/{page}/mailwizz', [MailwizzController::class, 'edit'])->name('pages.mailwizz.edit'); diff --git a/tests/Feature/StorePreregistrationPageTest.php b/tests/Feature/StorePreregistrationPageTest.php new file mode 100644 index 0000000..9445995 --- /dev/null +++ b/tests/Feature/StorePreregistrationPageTest.php @@ -0,0 +1,53 @@ +create(['role' => 'user']); + + $response = $this->actingAs($user)->post(route('admin.pages.store'), [ + 'title' => 'Summer Fest', + 'heading' => 'Register', + 'intro_text' => null, + 'thank_you_message' => null, + 'expired_message' => null, + 'ticketshop_url' => null, + 'start_date' => '2026-06-01T10:00', + 'end_date' => '2026-06-30T18:00', + 'phone_enabled' => false, + 'is_active' => true, + ]); + + $response->assertRedirect(route('admin.pages.index')); + $this->assertDatabaseCount('preregistration_pages', 1); + $this->assertSame('Summer Fest', PreregistrationPage::query()->first()?->title); + } + + public function test_validation_failure_redirects_back_with_input(): void + { + $user = User::factory()->create(['role' => 'user']); + + $response = $this->actingAs($user)->from(route('admin.pages.create'))->post(route('admin.pages.store'), [ + 'title' => '', + 'heading' => 'H', + 'start_date' => '2026-06-30T10:00', + 'end_date' => '2026-06-01T10:00', + ]); + + $response->assertRedirect(route('admin.pages.create')); + $response->assertSessionHasErrors('title'); + $response->assertSessionHasInput('heading', 'H'); + } +}