Compare commits
2 Commits
83e2158383
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f3fefca5c | |||
| 330950cc6e |
@@ -59,7 +59,7 @@ PreRegister is a Laravel 11 application for festival ticket pre-registration. Vi
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
- CSRF on all forms
|
- CSRF on all forms
|
||||||
- Rate limiting on public endpoints (`throttle:10,1`)
|
- Rate limiting on public endpoints (`config/preregister.php` → `public_requests_per_minute`, applied in `routes/web.php`)
|
||||||
- Never expose API keys in frontend, logs, or responses
|
- Never expose API keys in frontend, logs, or responses
|
||||||
- Validate and restrict file uploads (image types, max size)
|
- Validate and restrict file uploads (image types, max size)
|
||||||
- UUID slugs prevent URL enumeration
|
- UUID slugs prevent URL enumeration
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ APP_KEY=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60).
|
||||||
|
# PUBLIC_REQUESTS_PER_MINUTE=120
|
||||||
|
|
||||||
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
||||||
APP_TIMEZONE=Europe/Amsterdam
|
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_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|||||||
147
README.md
147
README.md
@@ -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">
|
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).
|
||||||
<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>
|
|
||||||
|
|
||||||
## 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).
|
**Not used:** React, Vue, Livewire, or Inertia.
|
||||||
- [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).
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Agentic Development
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
composer require laravel/boost --dev
|
composer install
|
||||||
|
|
||||||
php artisan boost:install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
2. **Environment**
|
||||||
|
|
||||||
## Contributing
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
Configure database (example for Docker MySQL on localhost):
|
||||||
|
|
||||||
## Code of Conduct
|
```env
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=preregister
|
||||||
|
DB_USERNAME=preregister
|
||||||
|
DB_PASSWORD=preregister
|
||||||
|
|
||||||
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).
|
QUEUE_CONNECTION=database
|
||||||
|
```
|
||||||
|
|
||||||
## Security Vulnerabilities
|
Mailwizz API keys are **not** in `.env`; they are stored per pre-registration page in the database (encrypted).
|
||||||
|
|
||||||
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.
|
3. **Docker (optional)**
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
# or: docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [documentation/Setup.md](documentation/Setup.md) for MySQL, Mailpit, and phpMyAdmin ports.
|
||||||
|
|
||||||
|
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 host’s 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
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
Application code follows your project’s license. Laravel is open source under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@@ -24,6 +26,6 @@ class PasswordController extends Controller
|
|||||||
'password' => Hash::make($validated['password']),
|
'password' => Hash::make($validated['password']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('status', 'password-updated');
|
return back()->with('status', __('Password updated successfully.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\ProfileUpdateRequest;
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
@@ -34,7 +36,7 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
$request->user()->save();
|
$request->user()->save();
|
||||||
|
|
||||||
return Redirect::route('admin.profile.edit')->with('status', 'profile-updated');
|
return Redirect::route('admin.profile.edit')->with('status', __('Profile updated successfully.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
|
|||||||
'field_email' => ['required', 'string', 'max:255'],
|
'field_email' => ['required', 'string', 'max:255'],
|
||||||
'field_first_name' => ['required', 'string', 'max:255'],
|
'field_first_name' => ['required', 'string', 'max:255'],
|
||||||
'field_last_name' => ['required', 'string', 'max:255'],
|
'field_last_name' => ['required', 'string', 'max:255'],
|
||||||
'field_phone' => $page->phone_enabled
|
'field_phone' => ['nullable', 'string', 'max:255'],
|
||||||
? ['required', 'string', 'max:255']
|
|
||||||
: ['nullable', 'string', 'max:255'],
|
|
||||||
'tag_field' => ['required', 'string', 'max:255'],
|
'tag_field' => ['required', 'string', 'max:255'],
|
||||||
'tag_value' => ['required', 'string', 'max:255'],
|
'tag_value' => ['required', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests;
|
|||||||
|
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Email;
|
||||||
|
|
||||||
class SubscribePublicPageRequest extends FormRequest
|
class SubscribePublicPageRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,13 +23,41 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
/** @var PreregistrationPage $page */
|
/** @var PreregistrationPage $page */
|
||||||
$page = $this->route('publicPage');
|
$page = $this->route('publicPage');
|
||||||
|
|
||||||
|
$emailRule = (new Email)
|
||||||
|
->rfcCompliant()
|
||||||
|
->preventSpoofing();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'email', 'max:255'],
|
'email' => ['required', 'string', 'max:255', $emailRule],
|
||||||
'phone' => $page->phone_enabled
|
'phone' => $page->phone_enabled
|
||||||
? ['required', 'string', 'max:20']
|
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
||||||
: ['nullable', 'string', 'max:20'],
|
: ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => __('Please enter a valid email address.'),
|
||||||
|
'phone.regex' => __('Please enter a valid phone number (8–15 digits).'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name' => __('First name'),
|
||||||
|
'last_name' => __('Last name'),
|
||||||
|
'email' => __('Email'),
|
||||||
|
'phone' => __('Phone'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +69,18 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
'email' => strtolower(trim($email)),
|
'email' => strtolower(trim($email)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var PreregistrationPage $page */
|
||||||
|
$page = $this->route('publicPage');
|
||||||
|
$phone = $this->input('phone');
|
||||||
|
if (! $page->phone_enabled) {
|
||||||
|
$this->merge(['phone' => null]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_string($phone)) {
|
||||||
|
$digits = preg_replace('/\D+/', '', $phone);
|
||||||
|
$this->merge(['phone' => $digits === '' ? null : $digits]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->configIsComplete($config, $page->phone_enabled)) {
|
if (! $this->configIsComplete($config)) {
|
||||||
Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [
|
Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [
|
||||||
'subscriber_id' => $subscriber->id,
|
'subscriber_id' => $subscriber->id,
|
||||||
'page_id' => $page->id,
|
'page_id' => $page->id,
|
||||||
@@ -106,16 +106,12 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function configIsComplete(MailwizzConfig $config, bool $phoneEnabled): bool
|
private function configIsComplete(MailwizzConfig $config): bool
|
||||||
{
|
{
|
||||||
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
|
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($phoneEnabled && ($config->field_phone === null || $config->field_phone === '')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') {
|
if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
23
config/preregister.php
Normal file
23
config/preregister.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$env = env('APP_ENV', 'production');
|
||||||
|
$defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Public routes rate limit
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Max requests per minute per IP for the public landing page and subscribe
|
||||||
|
| endpoint. Local and testing use a high default so refresh-heavy dev work
|
||||||
|
| does not hit 429. Override with PUBLIC_REQUESTS_PER_MINUTE in .env.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'public_requests_per_minute' => (int) env('PUBLIC_REQUESTS_PER_MINUTE', (string) $defaultPerMinute),
|
||||||
|
|
||||||
|
];
|
||||||
7
lang/en/public.php
Normal file
7
lang/en/public.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'register_button' => 'Register',
|
||||||
|
];
|
||||||
22
lang/nl.json
Normal file
22
lang/nl.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"First name": "Voornaam",
|
||||||
|
"Last name": "Achternaam",
|
||||||
|
"Email": "E-mailadres",
|
||||||
|
"Phone": "Mobiel",
|
||||||
|
"optional": "optioneel",
|
||||||
|
"Phone (optional)": "Mobiel (optioneel)",
|
||||||
|
"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.",
|
||||||
|
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
||||||
|
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)."
|
||||||
|
}
|
||||||
7
lang/nl/public.php
Normal file
7
lang/nl/public.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'register_button' => 'Registreer nu!',
|
||||||
|
];
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<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="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
|
|||||||
@@ -5,3 +5,12 @@
|
|||||||
[x-cloak] {
|
[x-cloak] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public landing: respect reduced motion for entrance animation */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-preregister-in {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,36 @@ import './bootstrap';
|
|||||||
|
|
||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side email check (aligned loosely with RFC-style rules; server is authoritative).
|
||||||
|
*/
|
||||||
|
function isValidEmailFormat(value) {
|
||||||
|
const email = String(value).trim().toLowerCase();
|
||||||
|
if (email.length < 5 || email.length > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const at = email.lastIndexOf('@');
|
||||||
|
const local = email.slice(0, at);
|
||||||
|
const domain = email.slice(at + 1);
|
||||||
|
if (!local || !domain || local.includes('..') || domain.includes('..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (domain.startsWith('.') || domain.endsWith('.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(local)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tld = domain.split('.').pop();
|
||||||
|
return Boolean(tld && tld.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
Alpine.data('publicPreregisterPage', (config) => ({
|
Alpine.data('publicPreregisterPage', (config) => ({
|
||||||
phase: config.phase,
|
phase: config.phase,
|
||||||
@@ -10,6 +40,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
subscribeUrl: config.subscribeUrl,
|
subscribeUrl: config.subscribeUrl,
|
||||||
csrfToken: config.csrfToken,
|
csrfToken: config.csrfToken,
|
||||||
genericError: config.genericError,
|
genericError: config.genericError,
|
||||||
|
labelDay: config.labelDay,
|
||||||
|
labelDays: config.labelDays,
|
||||||
|
invalidEmailMsg: config.invalidEmailMsg,
|
||||||
|
invalidPhoneMsg: config.invalidPhoneMsg,
|
||||||
days: 0,
|
days: 0,
|
||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
@@ -54,15 +88,36 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
validateEmailAndPhone() {
|
||||||
|
this.fieldErrors = {};
|
||||||
|
let ok = true;
|
||||||
|
if (!isValidEmailFormat(this.email)) {
|
||||||
|
this.fieldErrors.email = [this.invalidEmailMsg];
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if (this.phoneEnabled) {
|
||||||
|
const digits = String(this.phone).replace(/\D/g, '');
|
||||||
|
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
|
||||||
|
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
|
this.formError = '';
|
||||||
|
this.fieldErrors = {};
|
||||||
|
if (!this.validateEmailAndPhone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const form = this.$refs.form;
|
const form = this.$refs.form;
|
||||||
if (form !== undefined && form !== null && !form.checkValidity()) {
|
if (form !== undefined && form !== null && !form.checkValidity()) {
|
||||||
form.reportValidity();
|
form.reportValidity();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.formError = '';
|
|
||||||
this.fieldErrors = {};
|
|
||||||
this.submitting = true;
|
this.submitting = true;
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
@@ -260,10 +315,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.errorMessage = cfg.strings.mapFieldsError;
|
this.errorMessage = cfg.strings.mapFieldsError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.phoneEnabled && !this.fieldPhone) {
|
|
||||||
this.errorMessage = cfg.strings.mapPhoneError;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.tagField) {
|
if (!this.tagField) {
|
||||||
this.errorMessage = cfg.strings.tagFieldError;
|
this.errorMessage = cfg.strings.tagFieldError;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
||||||
'selectListError' => __('Select a mailing list.'),
|
'selectListError' => __('Select a mailing list.'),
|
||||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
||||||
'mapPhoneError' => __('Map the phone field for this page.'),
|
|
||||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||||
],
|
],
|
||||||
@@ -45,6 +44,17 @@
|
|||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||||
</div>
|
</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)
|
@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">
|
<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>
|
<p class="font-medium">{{ __('Integration active') }}</p>
|
||||||
@@ -172,7 +182,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="phoneEnabled">
|
<div x-show="phoneEnabled">
|
||||||
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }}</label>
|
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||||
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
<option value="">{{ __('Select field…') }}</option>
|
<option value="">{{ __('Select field…') }}</option>
|
||||||
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
|
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
|
||||||
|
|||||||
@@ -13,6 +13,47 @@
|
|||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased bg-slate-100 text-slate-900" x-data="{ sidebarOpen: false }">
|
<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 --}}
|
{{-- Mobile overlay --}}
|
||||||
<div
|
<div
|
||||||
x-show="sidebarOpen"
|
x-show="sidebarOpen"
|
||||||
@@ -114,18 +155,6 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
<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')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
<x-app-layout>
|
@extends('layouts.admin')
|
||||||
<x-slot name="header">
|
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
|
||||||
{{ __('Profile') }}
|
|
||||||
</h2>
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="py-12">
|
@section('title', __('Profile'))
|
||||||
<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">
|
@section('mobile_title', __('Profile'))
|
||||||
<div class="max-w-xl">
|
|
||||||
|
@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')
|
@include('profile.partials.update-profile-information-form')
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
<div class="max-w-xl">
|
|
||||||
@include('profile.partials.update-password-form')
|
@include('profile.partials.update-password-form')
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
<div class="max-w-xl">
|
|
||||||
@include('profile.partials.delete-user-form')
|
@include('profile.partials.delete-user-form')
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endsection
|
||||||
</div>
|
|
||||||
</x-app-layout>
|
|
||||||
|
|||||||
@@ -33,16 +33,6 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -49,16 +49,6 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,11 +27,11 @@
|
|||||||
></div>
|
></div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="absolute inset-0 bg-black/50" aria-hidden="true"></div>
|
<div class="absolute inset-0 bg-black/30" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-10 sm:px-6 sm:py-12">
|
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-6 sm:px-6 sm:py-10">
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-lg rounded-2xl border border-white/15 bg-white/10 px-6 py-8 shadow-2xl backdrop-blur-md sm:px-10 sm:py-10"
|
class="animate-preregister-in w-full max-w-3xl rounded-3xl border border-white/20 bg-black/60 px-5 py-8 shadow-[0_25px_60px_-15px_rgba(0,0,0,0.65)] backdrop-blur-[4px] sm:px-10 sm:py-10"
|
||||||
x-cloak
|
x-cloak
|
||||||
x-data="publicPreregisterPage(@js([
|
x-data="publicPreregisterPage(@js([
|
||||||
'phase' => $phase,
|
'phase' => $phase,
|
||||||
@@ -40,63 +40,74 @@
|
|||||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||||
'csrfToken' => csrf_token(),
|
'csrfToken' => csrf_token(),
|
||||||
'genericError' => __('Something went wrong. Please try again.'),
|
'genericError' => __('Something went wrong. Please try again.'),
|
||||||
|
'labelDay' => __('day'),
|
||||||
|
'labelDays' => __('days'),
|
||||||
|
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
||||||
|
'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'),
|
||||||
]))"
|
]))"
|
||||||
>
|
>
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
@if ($logoUrl !== null)
|
@if ($logoUrl !== null)
|
||||||
<div class="mb-6 flex justify-center">
|
<div class="mb-4 flex w-full justify-center sm:mb-5">
|
||||||
<img
|
<img
|
||||||
src="{{ e($logoUrl) }}"
|
src="{{ e($logoUrl) }}"
|
||||||
alt=""
|
alt=""
|
||||||
class="max-h-20 w-auto object-contain object-center"
|
class="max-h-32 w-auto object-contain object-center drop-shadow-[0_8px_32px_rgba(0,0,0,0.45)] sm:max-h-44 md:max-h-48"
|
||||||
width="320"
|
width="384"
|
||||||
height="80"
|
height="192"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<h1 class="text-center text-3xl font-semibold leading-tight tracking-tight text-white sm:text-4xl">
|
<h1 class="w-full max-w-none text-balance text-2xl font-bold leading-snug tracking-tight text-festival sm:text-3xl">
|
||||||
{{ $page->heading }}
|
{{ $page->heading }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{{-- Before start: intro + countdown --}}
|
|
||||||
<div x-show="phase === 'before'" x-cloak class="mt-6 space-y-6">
|
|
||||||
@if (filled($page->intro_text))
|
@if (filled($page->intro_text))
|
||||||
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90">
|
<div
|
||||||
{{ $page->intro_text }}
|
x-show="phase === 'before' || phase === 'active'"
|
||||||
|
x-cloak
|
||||||
|
class="mt-0 w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed"
|
||||||
|
>
|
||||||
|
{{ trim($page->intro_text) }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Before start: countdown --}}
|
||||||
|
<div x-show="phase === 'before'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-4 gap-2 rounded-xl border border-white/20 bg-black/25 px-3 py-4 text-center sm:gap-3 sm:px-4"
|
class="grid grid-cols-4 gap-3 rounded-2xl border border-white/15 bg-black/35 px-3 py-4 text-center shadow-inner sm:gap-4 sm:px-4 sm:py-5"
|
||||||
role="timer"
|
role="timer"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></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-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="days === 1 ? labelDay : labelDays"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
|
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
|
||||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('hrs') }}</div>
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('hrs') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
|
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
|
||||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('mins') }}</div>
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('mins') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
|
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
|
||||||
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('secs') }}</div>
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('secs') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Active: registration form --}}
|
{{-- Active: registration form --}}
|
||||||
<div x-show="phase === 'active'" x-cloak class="mt-8">
|
<div x-show="phase === 'active'" x-cloak class="mt-8 sm:mt-10">
|
||||||
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
||||||
<div x-show="formError !== ''" x-cloak class="rounded-lg border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-100" x-text="formError"></div>
|
<div x-show="formError !== ''" x-cloak class="rounded-xl border border-amber-400/50 bg-amber-500/15 px-4 py-3 text-sm leading-snug text-amber-50" x-text="formError"></div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="first_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('First name') }}</label>
|
<label for="first_name" class="sr-only">{{ __('First name') }}</label>
|
||||||
<input
|
<input
|
||||||
id="first_name"
|
id="first_name"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -105,14 +116,14 @@
|
|||||||
required
|
required
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
x-model="first_name"
|
x-model="first_name"
|
||||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||||
placeholder="{{ __('First name') }}"
|
placeholder="{{ __('First name') }}"
|
||||||
>
|
>
|
||||||
<p x-show="fieldErrors.first_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
|
<p x-show="fieldErrors.first_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="last_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('Last name') }}</label>
|
<label for="last_name" class="sr-only">{{ __('Last name') }}</label>
|
||||||
<input
|
<input
|
||||||
id="last_name"
|
id="last_name"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -121,14 +132,15 @@
|
|||||||
required
|
required
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
x-model="last_name"
|
x-model="last_name"
|
||||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||||
placeholder="{{ __('Last name') }}"
|
placeholder="{{ __('Last name') }}"
|
||||||
>
|
>
|
||||||
<p x-show="fieldErrors.last_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
|
<p x-show="fieldErrors.last_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="mb-1 block text-sm font-medium text-white/90">{{ __('Email') }}</label>
|
<label for="email" class="sr-only">{{ __('Email') }}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -137,59 +149,58 @@
|
|||||||
required
|
required
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
x-model="email"
|
x-model="email"
|
||||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||||
placeholder="{{ __('Email') }}"
|
placeholder="{{ __('Email') }}"
|
||||||
>
|
>
|
||||||
<p x-show="fieldErrors.email" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
<p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="phoneEnabled">
|
<div x-show="phoneEnabled">
|
||||||
<label for="phone" class="mb-1 block text-sm font-medium text-white/90">{{ __('Phone') }}</label>
|
<label for="phone" class="sr-only">{{ __('Phone (optional)') }}</label>
|
||||||
<input
|
<input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
autocomplete="tel"
|
autocomplete="tel"
|
||||||
:required="phoneEnabled"
|
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
x-model="phone"
|
x-model="phone"
|
||||||
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||||
placeholder="{{ __('Phone') }}"
|
placeholder="{{ __('Phone (optional)') }}"
|
||||||
>
|
>
|
||||||
<p x-show="fieldErrors.phone" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
<p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
class="mt-2 min-h-[52px] w-full rounded-xl bg-festival px-6 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
>
|
>
|
||||||
<span x-show="!submitting">{{ __('Register') }}</span>
|
<span x-show="!submitting">{{ __('public.register_button') }}</span>
|
||||||
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Thank you (after successful AJAX) --}}
|
{{-- Thank you (after successful AJAX) --}}
|
||||||
<div x-show="phase === 'thanks'" x-cloak class="mt-8">
|
<div x-show="phase === 'thanks'" x-cloak class="mt-8 sm:mt-10">
|
||||||
<p class="whitespace-pre-line text-center text-lg leading-relaxed text-white/95" x-text="thankYouMessage"></p>
|
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Expired --}}
|
{{-- Expired --}}
|
||||||
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6">
|
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
||||||
@if (filled($page->expired_message))
|
@if (filled($page->expired_message))
|
||||||
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90">
|
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
|
||||||
{{ $page->expired_message }}
|
{{ $page->expired_message }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<p class="text-center text-base text-white/90">{{ __('This pre-registration period has ended.') }}</p>
|
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($page->ticketshop_url))
|
@if (filled($page->ticketshop_url))
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a
|
<a
|
||||||
href="{{ e($page->ticketshop_url) }}"
|
href="{{ e($page->ticketshop_url) }}"
|
||||||
class="inline-flex items-center justify-center rounded-lg bg-white px-5 py-2.5 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"
|
class="inline-flex min-h-[52px] items-center justify-center rounded-xl bg-festival px-8 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99]"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Route::get('/', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Public (no auth) ────────────────────────────────────
|
// ─── Public (no auth) ────────────────────────────────────
|
||||||
Route::middleware('throttle:10,1')->group(function () {
|
Route::middleware(sprintf('throttle:%d,1', max(1, config('preregister.public_requests_per_minute'))))->group(function () {
|
||||||
Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page');
|
Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page');
|
||||||
Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
|
Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,24 @@ export default {
|
|||||||
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
colors: {
|
||||||
|
festival: {
|
||||||
|
DEFAULT: '#f06c05',
|
||||||
|
dark: '#f06c05',
|
||||||
|
},
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
||||||
},
|
},
|
||||||
|
keyframes: {
|
||||||
|
'preregister-in': {
|
||||||
|
from: { opacity: '0', transform: 'translateY(1rem)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'preregister-in': 'preregister-in 0.65s cubic-bezier(0.22, 1, 0.36, 1) forwards',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -9,14 +11,14 @@ class RegistrationTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_registration_screen_can_be_rendered(): void
|
public function test_registration_is_disabled(): void
|
||||||
{
|
{
|
||||||
$response = $this->get('/register');
|
$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', [
|
$response = $this->post('/register', [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
@@ -25,7 +27,7 @@ class RegistrationTest extends TestCase
|
|||||||
'password_confirmation' => 'password',
|
'password_confirmation' => 'password',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$response->assertNotFound();
|
||||||
$response->assertRedirect(route('admin.dashboard', absolute: false));
|
$this->assertGuest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ class ExampleTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* A basic test example.
|
* 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 = $this->get('/');
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertRedirect(route('admin.dashboard', absolute: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,88 @@ class PublicPageTest extends TestCase
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_subscribe_rejects_invalid_email(): void
|
||||||
|
{
|
||||||
|
$page = $this->makePage([
|
||||||
|
'start_date' => now()->subHour(),
|
||||||
|
'end_date' => now()->addMonth(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||||
|
'first_name' => 'A',
|
||||||
|
'last_name' => 'B',
|
||||||
|
'email' => 'not-a-valid-email',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_subscribe_accepts_empty_phone_when_phone_field_enabled(): void
|
||||||
|
{
|
||||||
|
$page = $this->makePage([
|
||||||
|
'start_date' => now()->subHour(),
|
||||||
|
'end_date' => now()->addMonth(),
|
||||||
|
'phone_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||||
|
'first_name' => 'A',
|
||||||
|
'last_name' => 'B',
|
||||||
|
'email' => 'nophone@example.com',
|
||||||
|
'phone' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertDatabaseHas('subscribers', [
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'email' => 'nophone@example.com',
|
||||||
|
'phone' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_subscribe_rejects_phone_with_too_few_digits(): void
|
||||||
|
{
|
||||||
|
$page = $this->makePage([
|
||||||
|
'start_date' => now()->subHour(),
|
||||||
|
'end_date' => now()->addMonth(),
|
||||||
|
'phone_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||||
|
'first_name' => 'A',
|
||||||
|
'last_name' => 'B',
|
||||||
|
'email' => 'a@example.com',
|
||||||
|
'phone' => '+31 12 3',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['phone']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_subscribe_normalizes_phone_to_digits(): void
|
||||||
|
{
|
||||||
|
$page = $this->makePage([
|
||||||
|
'start_date' => now()->subHour(),
|
||||||
|
'end_date' => now()->addMonth(),
|
||||||
|
'phone_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||||
|
'first_name' => 'A',
|
||||||
|
'last_name' => 'B',
|
||||||
|
'email' => 'phoneuser@example.com',
|
||||||
|
'phone' => '+31 6 1234 5678',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertDatabaseHas('subscribers', [
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'email' => 'phoneuser@example.com',
|
||||||
|
'phone' => '31612345678',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $overrides
|
* @param array<string, mixed> $overrides
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user