feat: Phase 5 - polish, validation, rate limiting, Dutch translations

This commit is contained in:
2026-04-03 22:13:59 +02:00
parent 83e2158383
commit 330950cc6e
18 changed files with 251 additions and 102 deletions

View File

@@ -7,6 +7,7 @@ APP_URL=http://localhost
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
APP_TIMEZONE=Europe/Amsterdam
# Use nl for Dutch public UI (lang/nl.json + lang/nl/*). Admin uses the same locale when set.
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US

143
README.md
View File

@@ -1,58 +1,131 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# PreRegister
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
Laravel 11 app for festival **ticket pre-registration**: branded public landing pages, local subscriber storage, and optional sync to [Mailwizz](https://www.mailwizz.nl/) (API key per page, encrypted at rest).
## About Laravel
## Tech stack
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
| Layer | Choice |
|--------|--------|
| Backend | PHP 8.2+, Laravel 11, MySQL 8 |
| Frontend (public + admin) | Blade, Tailwind CSS 3, Alpine.js 3 |
| Auth | Laravel Breeze (Blade stack) |
| Queue | Database driver; Mailwizz sync on `mailwizz` queue |
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
**Not used:** React, Vue, Livewire, or Inertia.
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Requirements
## Learning Laravel
- PHP ≥ 8.2 (extensions: mbstring, xml, curl, pdo_mysql, gd)
- Composer ≥ 2.7
- Node.js ≥ 20 (for Vite / Tailwind)
- MySQL 8 (local or Docker)
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
## Local setup
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
1. **Clone and install PHP dependencies**
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
```bash
composer install
```
## Agentic Development
2. **Environment**
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
cp .env.example .env
php artisan key:generate
```
```bash
composer require laravel/boost --dev
Configure database (example for Docker MySQL on localhost):
php artisan boost:install
```
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=preregister
DB_USERNAME=preregister
DB_PASSWORD=preregister
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
QUEUE_CONNECTION=database
```
## Contributing
Mailwizz API keys are **not** in `.env`; they are stored per pre-registration page in the database (encrypted).
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
3. **Docker (optional)**
## Code of Conduct
From the project root:
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
```bash
make up
# or: docker compose up -d
```
## Security Vulnerabilities
See [documentation/Setup.md](documentation/Setup.md) for MySQL, Mailpit, and phpMyAdmin ports.
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
4. **Database**
```bash
php artisan migrate --seed
```
5. **Storage link (uploads)**
```bash
php artisan storage:link
```
6. **Frontend build**
```bash
npm install
npm run build
# or during development: npm run dev
```
7. **Run the app**
```bash
php artisan serve
```
Admin UI lives under `/admin` (after login). Public pre-registration URLs are `/r/{uuid-slug}`.
8. **Queue worker (Mailwizz sync)**
```bash
php artisan queue:work --queue=mailwizz
```
Use Supervisor or your hosts process manager in production.
## Default login (after seed)
`php artisan db:seed` creates a **superadmin** (via `SuperadminSeeder`):
- Email: `admin@preregister.app`
- Password: `changeme123!`
Change this password immediately in any shared or production environment. Additional users are created by a superadmin in **Admin → Users**. Sign in at `/login`.
## Deployment notes
- Run `php artisan migrate` (and `--seed` only on first deploy if you rely on seed data).
- Run `php artisan storage:link` so `public/storage` serves uploaded backgrounds and logos.
- Run a queue worker on the `mailwizz` queue (or `queue:work` without `--queue` if you only use the default queue and push jobs there consistently).
- Set `APP_KEY`, use HTTPS behind a reverse proxy, and configure `APP_URL` correctly.
- Optional: cron for `* * * * * php /path/to/artisan schedule:run` if you add scheduled tasks.
More detail: [documentation/DEPLOYMENT-STRATEGY.md](documentation/DEPLOYMENT-STRATEGY.md).
## Product specification
Full functional spec and development sequence: [documentation/Pregister-Development-Prompt.md](documentation/Pregister-Development-Prompt.md).
## Security
- Public subscribe endpoint is rate limited (`throttle:10,1`).
- CSRF on web forms; policies for admin resources.
- Never expose Mailwizz API keys in responses, logs, or the browser.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
Application code follows your projects license. Laravel is open source under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
@@ -24,6 +26,6 @@ class PasswordController extends Controller
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
return back()->with('status', __('Password updated successfully.'));
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
@@ -34,7 +36,7 @@ class ProfileController extends Controller
$request->user()->save();
return Redirect::route('admin.profile.edit')->with('status', 'profile-updated');
return Redirect::route('admin.profile.edit')->with('status', __('Profile updated successfully.'));
}
/**

View File

@@ -32,6 +32,19 @@ class SubscribePublicPageRequest extends FormRequest
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'first_name' => __('First name'),
'last_name' => __('Last name'),
'email' => __('Email'),
'phone' => __('Phone'),
];
}
protected function prepareForValidation(): void
{
$email = $this->input('email');

7
lang/en/public.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'register_button' => 'Register',
];

18
lang/nl.json Normal file
View File

@@ -0,0 +1,18 @@
{
"First name": "Voornaam",
"Last name": "Achternaam",
"Email": "E-mailadres",
"Phone": "Mobiel",
"Register": "Registreren",
"days": "dagen",
"day": "dag",
"hrs": "uur",
"mins": "minuten",
"secs": "seconden",
"Sending…": "Bezig met verzenden…",
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
"Visit ticket shop": "Ga naar de ticketshop",
"Thank you for registering!": "Bedankt voor je registratie!",
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement."
}

7
lang/nl/public.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'register_button' => 'Registreer nu!',
];

View File

@@ -19,6 +19,8 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_LOCALE" value="en"/>
<env name="APP_FALLBACK_LOCALE" value="en"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>

View File

@@ -10,6 +10,8 @@ document.addEventListener('alpine:init', () => {
subscribeUrl: config.subscribeUrl,
csrfToken: config.csrfToken,
genericError: config.genericError,
labelDay: config.labelDay,
labelDays: config.labelDays,
days: 0,
hours: 0,
minutes: 0,

View File

@@ -45,6 +45,17 @@
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
</div>
@if ($errors->any())
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
<p class="font-medium">{{ __('Please fix the following:') }}</p>
<ul class="mt-2 list-disc space-y-1 pl-5">
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
@if ($config !== null)
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
<p class="font-medium">{{ __('Integration active') }}</p>

View File

@@ -13,6 +13,47 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased bg-slate-100 text-slate-900" x-data="{ sidebarOpen: false }">
@php
$adminFlashSuccess = session('status');
$adminFlashError = session('error');
@endphp
@if ($adminFlashSuccess !== null || $adminFlashError !== null)
<div
class="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-full max-w-sm flex-col gap-2 px-4 sm:px-0"
aria-live="polite"
>
@foreach (array_filter([
$adminFlashSuccess !== null ? ['type' => 'success', 'message' => $adminFlashSuccess] : null,
$adminFlashError !== null ? ['type' => 'error', 'message' => $adminFlashError] : null,
]) as $toast)
<div
x-data="{ visible: true }"
x-show="visible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-2 opacity-0"
x-transition:enter-end="translate-y-0 opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-2 opacity-0"
x-init="setTimeout(() => visible = false, 6500)"
class="pointer-events-auto flex items-start gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg {{ $toast['type'] === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-red-200 bg-red-50 text-red-900' }}"
role="{{ $toast['type'] === 'success' ? 'status' : 'alert' }}"
>
<p class="min-w-0 flex-1 leading-snug">{{ $toast['message'] }}</p>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@click="visible = false"
aria-label="{{ __('Dismiss notification') }}"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
@endforeach
</div>
@endif
{{-- Mobile overlay --}}
<div
x-show="sidebarOpen"
@@ -114,18 +155,6 @@
</header>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
@if (session('status'))
<div class="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800" role="status">
{{ session('status') }}
</div>
@endif
@if (session('error'))
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
{{ session('error') }}
</div>
@endif
@yield('content')
</main>
</div>

View File

@@ -1,29 +1,27 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
@extends('layouts.admin')
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@section('title', __('Profile'))
@section('mobile_title', __('Profile'))
@section('content')
<div class="mx-auto max-w-3xl space-y-6">
<div>
<a href="{{ route('admin.dashboard') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> {{ __('Back to dashboard') }}</a>
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Profile') }}</h1>
<p class="mt-1 text-sm text-slate-600">{{ __('Update your account settings and password.') }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
@include('profile.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
@include('profile.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
@include('profile.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>
@endsection

View File

@@ -33,16 +33,6 @@
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@@ -49,16 +49,6 @@
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@@ -40,6 +40,8 @@
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
'csrfToken' => csrf_token(),
'genericError' => __('Something went wrong. Please try again.'),
'labelDay' => __('day'),
'labelDays' => __('days'),
]))"
>
@if ($logoUrl !== null)
@@ -73,7 +75,7 @@
>
<div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('days') }}</div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60" x-text="days === 1 ? labelDay : labelDays"></div>
</div>
<div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
@@ -164,7 +166,7 @@
class="mt-2 w-full rounded-lg bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow transition hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="submitting"
>
<span x-show="!submitting">{{ __('Register') }}</span>
<span x-show="!submitting">{{ __('public.register_button') }}</span>
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
</button>
</form>

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -9,14 +11,14 @@ class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
public function test_registration_is_disabled(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
$response->assertNotFound();
}
public function test_new_users_can_register(): void
public function test_registration_post_is_rejected(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
@@ -25,7 +27,7 @@ class RegistrationTest extends TestCase
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('admin.dashboard', absolute: false));
$response->assertNotFound();
$this->assertGuest();
}
}

View File

@@ -10,10 +10,10 @@ class ExampleTest extends TestCase
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
public function test_root_redirects_to_admin_dashboard(): void
{
$response = $this->get('/');
$response->assertStatus(200);
$response->assertRedirect(route('admin.dashboard', absolute: false));
}
}