1079 lines
39 KiB
Markdown
1079 lines
39 KiB
Markdown
# PreRegister — Development Prompt & Architecture Specification
|
|
|
|
> **Purpose of this document:** This is the master prompt and specification for building "PreRegister" — a Laravel application for festival ticket pre-registration. Feed this document to **Claude Code** or **Cursor** to scaffold, develop, and iterate on the application. Every section is written as an actionable instruction set.
|
|
|
|
---
|
|
|
|
## 0. Context & Role
|
|
|
|
You are a **senior full-stack software architect and Laravel expert**. You write clean, production-ready PHP 8.2+ / Laravel 11 code. You follow PSR-12, use strict typing, and prefer explicit over clever. You build with security, scalability, and maintainability in mind — even for "simple" apps.
|
|
|
|
When developing this application:
|
|
|
|
- **Think before you code.** Read the full spec first. Plan the migration order, model relationships, and route structure before writing a single file.
|
|
- **Work incrementally.** Commit logically grouped changes. Don't dump the entire app in one pass.
|
|
- **Test as you go.** After each feature, verify it works. Run `php artisan migrate:fresh --seed` regularly.
|
|
- **Ask clarifying questions** if a requirement is ambiguous — don't guess.
|
|
|
|
---
|
|
|
|
## 1. Project Overview
|
|
|
|
**PreRegister** is a multi-tenant pre-registration system for festival ticket sales. Event organizers create branded landing pages where visitors can sign up for early/discounted ticket access. Subscriber data is stored locally and optionally synced to Mailwizz (email marketing platform).
|
|
|
|
### Core User Flows
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ BACKEND (authenticated) │
|
|
│ │
|
|
│ Superadmin → manages Users │
|
|
│ User → creates/edits Pre-Registration Pages │
|
|
│ → configures Mailwizz integration │
|
|
│ → views/exports subscribers │
|
|
└─────────────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ FRONTEND (public, unique URL per page) │
|
|
│ │
|
|
│ Guest → sees countdown / form / expired message │
|
|
│ → fills in form (name, email, phone) │
|
|
│ → gets thank-you confirmation │
|
|
│ → data stored in DB + optionally pushed to │
|
|
│ Mailwizz with tag/source tracking │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Tech Stack & Environment
|
|
|
|
| Layer | Technology |
|
|
|---|---|
|
|
| Language | PHP 8.2+ (strict types everywhere) |
|
|
| Framework | Laravel 11.x |
|
|
| Database | MySQL 8.x |
|
|
| Frontend (backend UI) | Blade + Tailwind CSS 3.x + Alpine.js 3.x |
|
|
| Frontend (public pages) | Blade + Tailwind CSS + Alpine.js (countdown, form validation) |
|
|
| Auth | Laravel Breeze (Blade stack) |
|
|
| File storage | Local disk (`storage/app/public`, symlinked) |
|
|
| HTTP client | Laravel HTTP facade (for Mailwizz API) |
|
|
| Queue | `database` driver (for async Mailwizz sync) |
|
|
| Cache | `file` driver (default) |
|
|
|
|
### Development Environment
|
|
|
|
```bash
|
|
# Prerequisites
|
|
php >= 8.2 with extensions: mbstring, xml, curl, mysql, gd/imagick
|
|
composer >= 2.7
|
|
node >= 20 (for Tailwind/Vite)
|
|
mysql >= 8.0
|
|
|
|
# Bootstrap
|
|
composer create-project laravel/laravel preregister
|
|
cd preregister
|
|
composer require laravel/breeze --dev
|
|
php artisan breeze:install blade
|
|
npm install
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Database Schema
|
|
|
|
Design migrations in this exact order. Use `$table->id()` (unsigned big int) for all primary keys. Use UUIDs for public-facing identifiers (slugs/URLs).
|
|
|
|
### Migration 1: Users table modification
|
|
|
|
Laravel ships with a `users` table. Add a `role` enum column.
|
|
|
|
```php
|
|
// database/migrations/xxxx_add_role_to_users_table.php
|
|
Schema::table('users', function (Blueprint $table) {
|
|
$table->enum('role', ['superadmin', 'user'])->default('user')->after('email');
|
|
});
|
|
```
|
|
|
|
> **Note:** "Guest" is not a database role — it's an unauthenticated visitor on the public frontend.
|
|
|
|
### Migration 2: Pre-Registration Pages
|
|
|
|
```php
|
|
Schema::create('preregistration_pages', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->uuid('slug')->unique(); // used in public URL: /r/{slug}
|
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
$table->string('title'); // internal title (backend)
|
|
$table->string('heading'); // public heading on the page
|
|
$table->text('intro_text')->nullable(); // shown before start_date (with countdown)
|
|
$table->text('thank_you_message')->nullable(); // shown after successful registration
|
|
$table->text('expired_message')->nullable(); // shown after end_date
|
|
$table->string('ticketshop_url')->nullable(); // link shown after expiration
|
|
$table->dateTime('start_date');
|
|
$table->dateTime('end_date');
|
|
$table->boolean('phone_enabled')->default(false); // show phone field yes/no
|
|
$table->string('background_image')->nullable(); // path in storage
|
|
$table->string('logo_image')->nullable(); // path in storage
|
|
$table->boolean('is_active')->default(true);
|
|
$table->timestamps();
|
|
});
|
|
```
|
|
|
|
### Migration 3: Subscribers
|
|
|
|
```php
|
|
Schema::create('subscribers', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
|
$table->string('first_name');
|
|
$table->string('last_name');
|
|
$table->string('email');
|
|
$table->string('phone')->nullable();
|
|
$table->boolean('synced_to_mailwizz')->default(false);
|
|
$table->timestamp('synced_at')->nullable();
|
|
$table->timestamps();
|
|
|
|
// Prevent duplicate emails per page
|
|
$table->unique(['preregistration_page_id', 'email']);
|
|
});
|
|
```
|
|
|
|
### Migration 4: Mailwizz Configuration
|
|
|
|
```php
|
|
Schema::create('mailwizz_configs', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
|
$table->text('api_key'); // encrypted at rest
|
|
$table->string('list_uid'); // Mailwizz list UID
|
|
$table->string('list_name')->nullable(); // display name for reference
|
|
$table->string('field_email')->default('EMAIL'); // Mailwizz tag for email
|
|
$table->string('field_first_name')->default('FNAME');
|
|
$table->string('field_last_name')->default('LNAME');
|
|
$table->string('field_phone')->nullable(); // e.g. 'PHONE'
|
|
$table->string('tag_field')->nullable(); // e.g. 'TAGS' — the checkboxlist field tag
|
|
$table->string('tag_value')->nullable(); // e.g. 'ezf2026-preregister'
|
|
$table->timestamps();
|
|
});
|
|
```
|
|
|
|
> **Encryption:** The `api_key` column MUST use Laravel's `encrypted` cast on the model. Never store API keys in plain text.
|
|
|
|
---
|
|
|
|
## 4. Models & Relationships
|
|
|
|
### `User` model
|
|
|
|
```
|
|
- hasMany PreregistrationPage
|
|
- Has `role` attribute (superadmin | user)
|
|
- Add `isSuperadmin()` and `isUser()` helper methods
|
|
```
|
|
|
|
### `PreregistrationPage` model
|
|
|
|
```
|
|
- belongsTo User
|
|
- hasMany Subscriber
|
|
- hasOne MailwizzConfig
|
|
- Uses UUID slug for public URL resolution (route model binding on 'slug')
|
|
- Add accessors: isBeforeStart(), isActive(), isExpired()
|
|
- Add scope: scopeActive()
|
|
- Cast start_date and end_date to datetime
|
|
```
|
|
|
|
### `Subscriber` model
|
|
|
|
```
|
|
- belongsTo PreregistrationPage
|
|
```
|
|
|
|
### `MailwizzConfig` model
|
|
|
|
```
|
|
- belongsTo PreregistrationPage
|
|
- Cast 'api_key' => 'encrypted'
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Authentication & Authorization
|
|
|
|
### Roles
|
|
|
|
| Role | Access |
|
|
|---|---|
|
|
| `superadmin` | Full backend access. Can manage users (CRUD). Can see/edit all pages. |
|
|
| `user` | Backend access. Can only manage own pages and see own subscribers. |
|
|
| `guest` | Public frontend only. No authentication required. |
|
|
|
|
### Implementation
|
|
|
|
1. Install Laravel Breeze (Blade stack) for login/register scaffolding.
|
|
2. **Disable public registration** — only superadmin can create users. Remove the registration routes or gate them behind superadmin middleware.
|
|
3. Create a `CheckRole` middleware:
|
|
|
|
```php
|
|
// app/Http/Middleware/CheckRole.php
|
|
public function handle($request, Closure $next, string ...$roles): Response
|
|
{
|
|
if (! in_array($request->user()->role, $roles)) {
|
|
abort(403);
|
|
}
|
|
return $next($request);
|
|
}
|
|
```
|
|
|
|
4. Register in `bootstrap/app.php` as alias `role`.
|
|
5. Apply to routes:
|
|
|
|
```php
|
|
Route::middleware(['auth', 'role:superadmin'])->group(function () {
|
|
// User management routes
|
|
});
|
|
|
|
Route::middleware(['auth', 'role:superadmin,user'])->group(function () {
|
|
// Pre-registration page management routes
|
|
});
|
|
```
|
|
|
|
6. Add a **Policy** for `PreregistrationPage`:
|
|
- `superadmin` can do everything.
|
|
- `user` can only view/edit/delete own pages.
|
|
|
|
---
|
|
|
|
## 6. Route Structure
|
|
|
|
```php
|
|
// ─── Public (no auth) ────────────────────────────────
|
|
Route::get('/r/{slug}', [PublicPageController::class, 'show'])->name('public.page');
|
|
Route::post('/r/{slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
|
|
|
|
// ─── Backend (auth required) ─────────────────────────
|
|
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
|
|
|
|
Route::get('/dashboard', DashboardController::class)->name('dashboard');
|
|
|
|
// 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');
|
|
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
|
|
|
// Mailwizz configuration (nested under pages)
|
|
Route::get('pages/{page}/mailwizz', [MailwizzController::class, 'edit'])->name('pages.mailwizz.edit');
|
|
Route::put('pages/{page}/mailwizz', [MailwizzController::class, 'update'])->name('pages.mailwizz.update');
|
|
Route::delete('pages/{page}/mailwizz', [MailwizzController::class, 'destroy'])->name('pages.mailwizz.destroy');
|
|
|
|
// Mailwizz AJAX endpoints (for dynamic field loading)
|
|
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
|
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
|
|
|
// User management (superadmin only)
|
|
Route::middleware('role:superadmin')->group(function () {
|
|
Route::resource('users', UserController::class)->except(['show']);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Backend Features — Detailed Specifications
|
|
|
|
### 7.1 Dashboard
|
|
|
|
Simple overview showing:
|
|
- Total pages (own pages for `user`, all for `superadmin`)
|
|
- Total subscribers across pages
|
|
- Active pages (currently within start/end date window)
|
|
- Quick links to create new page
|
|
|
|
### 7.2 Pre-Registration Page CRUD
|
|
|
|
**Create/Edit form fields:**
|
|
|
|
| Field | Type | Validation |
|
|
|---|---|---|
|
|
| Title | text | required, max:255 |
|
|
| Heading | text | required, max:255 |
|
|
| Intro Text | textarea (rich or plain) | nullable |
|
|
| Thank You Message | textarea | nullable |
|
|
| Expired Message | textarea | nullable |
|
|
| Ticket Shop URL | url | nullable, valid URL |
|
|
| Start Date | datetime-local | required, valid date |
|
|
| End Date | datetime-local | required, after:start_date |
|
|
| Phone Enabled | checkbox | boolean |
|
|
| Background Image | file upload | nullable, image, max:5MB, jpg/png/webp |
|
|
| Logo Image | file upload | nullable, image, max:2MB, jpg/png/webp/svg |
|
|
| Active | checkbox | boolean |
|
|
|
|
**On create:**
|
|
- Auto-generate a UUID slug (use `Str::uuid()`)
|
|
- Store uploaded images via `Storage::disk('public')->put(...)`
|
|
- Display the full public URL to the user: `{APP_URL}/r/{slug}`
|
|
|
|
**Index view:**
|
|
- Table with: Title, Status (before start / active / expired), Start Date, End Date, Subscriber Count, Actions (edit, view subscribers, public URL copy button, delete)
|
|
- Superadmin sees all pages; User sees only own pages
|
|
|
|
### 7.3 Subscribers View
|
|
|
|
- Table: First Name, Last Name, Email, Phone (if enabled), Registered At, Synced to Mailwizz (yes/no icon)
|
|
- Pagination (25 per page)
|
|
- CSV export button
|
|
- Search/filter by name or email
|
|
|
|
### 7.4 Mailwizz Configuration (per page)
|
|
|
|
This is a **multi-step configuration wizard** inside the page edit area (can be a separate tab or section). Use Alpine.js for dynamic behavior.
|
|
|
|
**Step-by-step flow:**
|
|
|
|
1. **Enter API Key**
|
|
- Input field for the Mailwizz API key
|
|
- Display instruction text: *"First, create a mailing list in Mailwizz with the required custom fields. Make sure to add a custom field of the type 'Checkbox List' with an available option value that will be used to track the source of this pre-registration."*
|
|
- "Connect" button → validates the API key by fetching lists
|
|
|
|
2. **Select List**
|
|
- On valid API key: make a server-side call to `GET https://www.mailwizz.nl/api/lists`
|
|
- Header: `X-Api-Key: {api_key}`
|
|
- Display available lists in a dropdown (show `general.name`, store `general.list_uid`)
|
|
- "Load Fields" button → fetches fields for the selected list
|
|
|
|
3. **Map Fields**
|
|
- Fetch fields from `GET https://www.mailwizz.nl/api/lists/{list_uid}/fields`
|
|
- Header: `X-Api-Key: {api_key}`
|
|
- Display mapping dropdowns:
|
|
|
|
| Local Field | Mailwizz Field Filter | Map To |
|
|
|---|---|---|
|
|
| Email | Show fields where `type.identifier` is `text` AND (`tag` contains `EMAIL` or is standard email field) | Dropdown of matching fields (display `label`, store `tag`) |
|
|
| First Name | Show fields where `type.identifier` is `text` | Dropdown |
|
|
| Last Name | Show fields where `type.identifier` is `text` | Dropdown |
|
|
| Phone | Show fields where `type.identifier` is `phonenumber` | Dropdown (only shown if phone_enabled) |
|
|
| Tag / Source | Show fields where `type.identifier` is `checkboxlist` | Dropdown of checkboxlist fields |
|
|
|
|
4. **Select Tag Value**
|
|
- After selecting the checkboxlist field, load its `options` object
|
|
- Display available options as radio buttons or dropdown (show the value label, store the key)
|
|
- Example: key = `ezf2026-preregister`, label = `Echt Zomer Feesten 2026 - Pré-register`
|
|
|
|
5. **Save Configuration**
|
|
- Store all mappings in the `mailwizz_configs` table
|
|
- Show success message with summary of the configuration
|
|
|
|
**AJAX Controller (`MailwizzApiController`):**
|
|
|
|
```php
|
|
// POST /admin/mailwizz/lists
|
|
// Receives: api_key
|
|
// Returns: list of Mailwizz lists
|
|
|
|
public function lists(Request $request): JsonResponse
|
|
{
|
|
$request->validate(['api_key' => 'required|string']);
|
|
|
|
$response = Http::withHeaders([
|
|
'X-Api-Key' => $request->api_key,
|
|
])->get('https://www.mailwizz.nl/api/lists');
|
|
|
|
if ($response->failed()) {
|
|
return response()->json(['error' => 'Invalid API key or connection failed'], 422);
|
|
}
|
|
|
|
return response()->json($response->json());
|
|
}
|
|
|
|
// POST /admin/mailwizz/fields
|
|
// Receives: api_key, list_uid
|
|
// Returns: list fields for the given list
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Frontend (Public Pages) — Detailed Specifications
|
|
|
|
### 8.1 Page States
|
|
|
|
The public page at `/r/{slug}` has three states based on current date/time:
|
|
|
|
#### State 1: Before Start Date (Countdown)
|
|
|
|
```
|
|
┌──────────────────────────────────────────┐
|
|
│ [Full-screen background image] │
|
|
│ │
|
|
│ [Logo] │
|
|
│ │
|
|
│ {heading} │
|
|
│ │
|
|
│ {intro_text} │
|
|
│ │
|
|
│ ┌──────────────────────────┐ │
|
|
│ │ DD : HH : MM : SS │ │
|
|
│ │ days hrs mins secs │ │
|
|
│ └──────────────────────────┘ │
|
|
│ │
|
|
└──────────────────────────────────────────┘
|
|
```
|
|
|
|
- Use Alpine.js for a live countdown timer
|
|
- When countdown reaches zero, auto-refresh to show the form (or use Alpine to reactively switch state)
|
|
|
|
#### State 2: Active (Registration Form)
|
|
|
|
```
|
|
┌──────────────────────────────────────────┐
|
|
│ [Full-screen background image] │
|
|
│ │
|
|
│ [Logo] │
|
|
│ │
|
|
│ {heading} │
|
|
│ │
|
|
│ ┌──────────────────────────┐ │
|
|
│ │ First Name [________] │ │
|
|
│ │ Last Name [________] │ │
|
|
│ │ Email [________] │ │
|
|
│ │ Phone* [________] │ *if on │
|
|
│ │ │ │
|
|
│ │ [ Register ] │ │
|
|
│ └──────────────────────────┘ │
|
|
│ │
|
|
└──────────────────────────────────────────┘
|
|
```
|
|
|
|
- Client-side validation with Alpine.js
|
|
- Server-side validation in controller
|
|
- On success: show `{thank_you_message}` (replace the form, no page reload — use Alpine)
|
|
- On duplicate email error: show a friendly message ("You are already registered for this event")
|
|
|
|
#### State 3: Expired
|
|
|
|
```
|
|
┌──────────────────────────────────────────┐
|
|
│ [Full-screen background image] │
|
|
│ │
|
|
│ [Logo] │
|
|
│ │
|
|
│ {heading} │
|
|
│ │
|
|
│ {expired_message} │
|
|
│ │
|
|
│ [Visit Ticket Shop →] │ (if ticketshop_url set)
|
|
│ │
|
|
└──────────────────────────────────────────┘
|
|
```
|
|
|
|
### 8.2 Styling Requirements
|
|
|
|
- **Background image:** Full-screen cover, centered, no-repeat. Use CSS `background-size: cover; background-position: center;` on the `<body>` or a wrapper div.
|
|
- **Overlay:** Add a semi-transparent dark overlay (e.g., `bg-black/50`) so text is readable on any image.
|
|
- **Content card:** Center a card/container (max-width ~500px) with slight backdrop blur and padding.
|
|
- **Logo:** Displayed above the heading, max-height ~80px, centered.
|
|
- **Typography:** White text, clean sans-serif. Heading large (text-3xl), body text readable.
|
|
- **Mobile responsive:** The form and all states must work perfectly on mobile.
|
|
|
|
### 8.3 Form Submission Logic
|
|
|
|
```php
|
|
// PublicPageController@subscribe
|
|
|
|
public function subscribe(Request $request, string $slug)
|
|
{
|
|
$page = PreregistrationPage::where('slug', $slug)
|
|
->where('is_active', true)
|
|
->firstOrFail();
|
|
|
|
// Verify page is in active window
|
|
abort_if(now()->lt($page->start_date) || now()->gt($page->end_date), 403);
|
|
|
|
$validated = $request->validate([
|
|
'first_name' => 'required|string|max:255',
|
|
'last_name' => 'required|string|max:255',
|
|
'email' => 'required|email|max:255',
|
|
'phone' => $page->phone_enabled ? 'required|string|max:20' : 'nullable',
|
|
]);
|
|
|
|
// Check duplicate
|
|
$exists = $page->subscribers()->where('email', $validated['email'])->exists();
|
|
if ($exists) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'This email address is already registered.',
|
|
], 422);
|
|
}
|
|
|
|
// Store subscriber
|
|
$subscriber = $page->subscribers()->create($validated);
|
|
|
|
// Dispatch Mailwizz sync job (if configured)
|
|
if ($page->mailwizzConfig) {
|
|
SyncSubscriberToMailwizz::dispatch($subscriber);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => $page->thank_you_message ?? 'Thank you for registering!',
|
|
]);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Mailwizz Integration — Subscriber Sync Logic
|
|
|
|
> **CRITICAL:** This is the most complex part of the app. Implement as a queued job.
|
|
|
|
### Job: `SyncSubscriberToMailwizz`
|
|
|
|
```
|
|
App\Jobs\SyncSubscriberToMailwizz
|
|
- Receives: Subscriber $subscriber
|
|
- Queue: 'mailwizz'
|
|
- Retries: 3, backoff: [10, 30, 60]
|
|
```
|
|
|
|
### Sync Algorithm
|
|
|
|
```
|
|
1. Load the subscriber + related page + mailwizz config
|
|
2. Decrypt the API key from mailwizz_configs
|
|
|
|
3. SEARCH for existing subscriber in Mailwizz:
|
|
GET https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/search-by-email
|
|
Query: EMAIL={subscriber.email}
|
|
Header: X-Api-Key: {api_key}
|
|
|
|
4. IF subscriber does NOT exist (status: "error"):
|
|
→ CREATE subscriber
|
|
POST https://www.mailwizz.nl/api/lists/{list_uid}/subscribers
|
|
Body (form-data):
|
|
{email_field_tag} = subscriber.email
|
|
{fname_field_tag} = subscriber.first_name
|
|
{lname_field_tag} = subscriber.last_name
|
|
{phone_field_tag} = subscriber.phone (if mapped)
|
|
{tag_field_tag} = [configured_tag_value] (send as array)
|
|
|
|
5. IF subscriber DOES exist (status: "success"):
|
|
→ FETCH full subscriber data
|
|
GET https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid}
|
|
|
|
→ Parse existing tag value from the tag field (comma-separated string)
|
|
→ Append configured_tag_value if not already present
|
|
→ Example: existing = "tag1,tag2" → append → "tag1,tag2,ezf2026-preregister"
|
|
|
|
→ UPDATE subscriber
|
|
PUT https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid}
|
|
Body (form-data):
|
|
{email_field_tag} = subscriber.email
|
|
{fname_field_tag} = subscriber.first_name
|
|
{lname_field_tag} = subscriber.last_name
|
|
{phone_field_tag} = subscriber.phone (if mapped)
|
|
{tag_field_tag} = [merged_tag_values_as_array]
|
|
|
|
6. On success: update subscriber record
|
|
$subscriber->update(['synced_to_mailwizz' => true, 'synced_at' => now()]);
|
|
|
|
7. On failure: log the error, let the job retry
|
|
```
|
|
|
|
### Important Mailwizz API Notes
|
|
|
|
- **Base URL:** `https://www.mailwizz.nl/api`
|
|
- **Auth:** Header `X-Api-Key: {key}`
|
|
- **Data format:** Send as `form-data` (use `->asForm()` with Laravel HTTP client)
|
|
- **Checkboxlist values** are stored as comma-separated strings in Mailwizz. When updating, you must:
|
|
1. Load existing value
|
|
2. Split by comma
|
|
3. Add the new value if not present
|
|
4. Send back as an **array** in the form data (Mailwizz expects array input for checkboxlist, it stores as CSV internally)
|
|
- **Typo in docs:** The update endpoint in the Mailwizz docs has a typo (`/llists/` instead of `/lists/`). Use the correct URL: `https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid}`
|
|
|
|
### Service Class
|
|
|
|
Create a dedicated `App\Services\MailwizzService` class to encapsulate all API communication:
|
|
|
|
```php
|
|
class MailwizzService
|
|
{
|
|
public function __construct(
|
|
private string $apiKey,
|
|
private string $baseUrl = 'https://www.mailwizz.nl/api'
|
|
) {}
|
|
|
|
public function getLists(): array
|
|
public function getListFields(string $listUid): array
|
|
public function searchSubscriber(string $listUid, string $email): ?array
|
|
public function getSubscriber(string $listUid, string $subscriberUid): ?array
|
|
public function createSubscriber(string $listUid, array $data): array
|
|
public function updateSubscriber(string $listUid, string $subscriberUid, array $data): array
|
|
}
|
|
```
|
|
|
|
Each method should:
|
|
- Use `Http::withHeaders(['X-Api-Key' => $this->apiKey])->asForm()`
|
|
- Throw descriptive exceptions on failure
|
|
- Log all API interactions at `debug` level
|
|
|
|
---
|
|
|
|
## 10. Security Checklist
|
|
|
|
- [ ] **CSRF protection** on all forms (Blade `@csrf` directive)
|
|
- [ ] **API key encryption** using Laravel's `encrypted` cast — never store in plain text
|
|
- [ ] **File upload validation** — restrict to image types, enforce max size
|
|
- [ ] **Authorization policies** — users can only access own resources
|
|
- [ ] **Rate limiting** on public subscription endpoint (prevent spam): `throttle:10,1` (10 per minute)
|
|
- [ ] **Input sanitization** — XSS protection on all user-provided content displayed in Blade
|
|
- [ ] **Slug unpredictability** — UUID slugs prevent enumeration
|
|
- [ ] **SQL injection** — use Eloquent/query builder only, never raw queries with user input
|
|
- [ ] **Mass assignment protection** — define `$fillable` on every model
|
|
- [ ] **HTTPS only** — set `APP_URL` with `https://`, enable `secure` cookies in production
|
|
|
|
---
|
|
|
|
## 11. File & Folder Structure
|
|
|
|
```
|
|
app/
|
|
├── Http/
|
|
│ ├── Controllers/
|
|
│ │ ├── Admin/
|
|
│ │ │ ├── DashboardController.php
|
|
│ │ │ ├── PageController.php
|
|
│ │ │ ├── SubscriberController.php
|
|
│ │ │ ├── MailwizzController.php
|
|
│ │ │ ├── MailwizzApiController.php
|
|
│ │ │ └── UserController.php
|
|
│ │ └── PublicPageController.php
|
|
│ ├── Middleware/
|
|
│ │ └── CheckRole.php
|
|
│ └── Requests/
|
|
│ ├── StorePageRequest.php
|
|
│ ├── UpdatePageRequest.php
|
|
│ └── SubscribeRequest.php
|
|
├── Jobs/
|
|
│ └── SyncSubscriberToMailwizz.php
|
|
├── Models/
|
|
│ ├── User.php
|
|
│ ├── PreregistrationPage.php
|
|
│ ├── Subscriber.php
|
|
│ └── MailwizzConfig.php
|
|
├── Policies/
|
|
│ └── PreregistrationPagePolicy.php
|
|
├── Services/
|
|
│ └── MailwizzService.php
|
|
└── Providers/
|
|
|
|
resources/views/
|
|
├── admin/
|
|
│ ├── layouts/
|
|
│ │ └── app.blade.php (backend layout with sidebar nav)
|
|
│ ├── dashboard.blade.php
|
|
│ ├── pages/
|
|
│ │ ├── index.blade.php
|
|
│ │ ├── create.blade.php
|
|
│ │ ├── edit.blade.php
|
|
│ │ └── _form.blade.php (shared form partial)
|
|
│ ├── subscribers/
|
|
│ │ └── index.blade.php
|
|
│ ├── mailwizz/
|
|
│ │ └── edit.blade.php (configuration wizard)
|
|
│ └── users/
|
|
│ ├── index.blade.php
|
|
│ ├── create.blade.php
|
|
│ └── edit.blade.php
|
|
└── public/
|
|
└── page.blade.php (the public pre-registration page)
|
|
|
|
database/
|
|
├── migrations/
|
|
│ ├── xxxx_add_role_to_users_table.php
|
|
│ ├── xxxx_create_preregistration_pages_table.php
|
|
│ ├── xxxx_create_subscribers_table.php
|
|
│ └── xxxx_create_mailwizz_configs_table.php
|
|
└── seeders/
|
|
└── SuperadminSeeder.php (creates initial superadmin account)
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Seeder: Initial Superadmin
|
|
|
|
```php
|
|
// database/seeders/SuperadminSeeder.php
|
|
public function run(): void
|
|
{
|
|
User::updateOrCreate(
|
|
['email' => 'admin@preregister.app'],
|
|
[
|
|
'name' => 'Super Admin',
|
|
'password' => Hash::make('changeme123!'),
|
|
'role' => 'superadmin',
|
|
'email_verified_at' => now(),
|
|
]
|
|
);
|
|
}
|
|
```
|
|
|
|
> **Post-deploy:** Change the password immediately.
|
|
|
|
---
|
|
|
|
## 13. Key Implementation Details
|
|
|
|
### 13.1 Image Upload Handling
|
|
|
|
```php
|
|
// In PageController store/update methods:
|
|
if ($request->hasFile('background_image')) {
|
|
// Delete old image if replacing
|
|
if ($page->background_image) {
|
|
Storage::disk('public')->delete($page->background_image);
|
|
}
|
|
$path = $request->file('background_image')->store('pages/backgrounds', 'public');
|
|
$page->background_image = $path;
|
|
}
|
|
```
|
|
|
|
Run `php artisan storage:link` to create the public symlink.
|
|
|
|
### 13.2 Alpine.js Countdown Timer
|
|
|
|
```html
|
|
<!-- In public/page.blade.php -->
|
|
<div x-data="countdown('{{ $page->start_date->toIso8601String() }}')"
|
|
x-init="start()">
|
|
<div class="flex gap-4 justify-center text-white text-center">
|
|
<div>
|
|
<span class="text-4xl font-bold" x-text="days">00</span>
|
|
<span class="text-sm block">days</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-4xl font-bold" x-text="hours">00</span>
|
|
<span class="text-sm block">hours</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-4xl font-bold" x-text="minutes">00</span>
|
|
<span class="text-sm block">minutes</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-4xl font-bold" x-text="seconds">00</span>
|
|
<span class="text-sm block">seconds</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function countdown(targetDate) {
|
|
return {
|
|
days: '00', hours: '00', minutes: '00', seconds: '00',
|
|
interval: null,
|
|
start() {
|
|
this.update();
|
|
this.interval = setInterval(() => this.update(), 1000);
|
|
},
|
|
update() {
|
|
const diff = new Date(targetDate) - new Date();
|
|
if (diff <= 0) {
|
|
clearInterval(this.interval);
|
|
window.location.reload(); // Reload to show form
|
|
return;
|
|
}
|
|
this.days = String(Math.floor(diff / 86400000)).padStart(2, '0');
|
|
this.hours = String(Math.floor((diff % 86400000) / 3600000)).padStart(2, '0');
|
|
this.minutes = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0');
|
|
this.seconds = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0');
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
```
|
|
|
|
### 13.3 Form Submission with Alpine.js
|
|
|
|
```html
|
|
<div x-data="registrationForm()" x-show="!submitted">
|
|
<form @submit.prevent="submit">
|
|
<!-- fields with x-model bindings -->
|
|
<button type="submit" :disabled="loading">
|
|
<span x-show="!loading">Register</span>
|
|
<span x-show="loading">Submitting...</span>
|
|
</button>
|
|
<p x-show="error" x-text="error" class="text-red-300 mt-2"></p>
|
|
</form>
|
|
</div>
|
|
|
|
<div x-show="submitted" class="text-center text-white">
|
|
<p x-text="successMessage"></p>
|
|
</div>
|
|
```
|
|
|
|
### 13.4 CSV Export
|
|
|
|
```php
|
|
// SubscriberController@export
|
|
public function export(PreregistrationPage $page)
|
|
{
|
|
$this->authorize('view', $page);
|
|
|
|
$subscribers = $page->subscribers()->orderBy('created_at')->get();
|
|
|
|
return response()->streamDownload(function () use ($subscribers, $page) {
|
|
$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(),
|
|
]);
|
|
}
|
|
fclose($handle);
|
|
}, "subscribers-{$page->slug}.csv");
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 14. Coding Standards & Conventions
|
|
|
|
### For AI assistants (Claude Code & Cursor)
|
|
|
|
Follow these rules strictly:
|
|
|
|
1. **PHP strict types** — every PHP file starts with `declare(strict_types=1);`
|
|
2. **Type hints everywhere** — method parameters, return types, properties
|
|
3. **No magic strings** — use constants or enums for repeated values (roles, statuses)
|
|
4. **Form Request classes** — all validation in dedicated Form Request classes, not inline
|
|
5. **Single responsibility** — controllers are thin, business logic in services/jobs
|
|
6. **Blade components** — use `<x-component>` syntax for reusable UI elements
|
|
7. **No `dd()` in committed code** — use proper logging
|
|
8. **Database transactions** — wrap multi-step operations in `DB::transaction()`
|
|
9. **Error handling** — never swallow exceptions. Log and re-throw or return user-friendly error.
|
|
10. **Comments** — only for *why*, never for *what*. The code should be self-documenting.
|
|
|
|
### Git Workflow
|
|
|
|
When using Claude Code or Cursor in agentic mode:
|
|
|
|
```
|
|
Commit after each logical unit:
|
|
1. Migrations + Models → "feat: add database schema and models"
|
|
2. Auth + Middleware + Policies → "feat: add auth, roles and authorization"
|
|
3. Backend CRUD for pages → "feat: add pre-registration page management"
|
|
4. Public frontend → "feat: add public registration pages"
|
|
5. Subscriber management → "feat: add subscriber list and export"
|
|
6. Mailwizz configuration UI → "feat: add Mailwizz configuration wizard"
|
|
7. Mailwizz sync job → "feat: add Mailwizz subscriber sync"
|
|
8. User management (superadmin) → "feat: add user management for superadmin"
|
|
9. Polish, validation, error handling → "fix: add validation and error handling"
|
|
```
|
|
|
|
---
|
|
|
|
## 15. Development Sequence (Step-by-Step for AI)
|
|
|
|
> **Instructions for Claude Code / Cursor:** Execute these steps in order. Complete each step fully before moving to the next. Test each step.
|
|
|
|
### Phase 1: Foundation
|
|
|
|
```
|
|
Step 1: Create the Laravel project and install dependencies
|
|
- laravel/breeze (blade)
|
|
- tailwindcss, alpinejs (via breeze/vite)
|
|
- Configure .env for MySQL
|
|
|
|
Step 2: Create all migrations (in the order specified in Section 3)
|
|
- Run php artisan migrate
|
|
|
|
Step 3: Create all Eloquent models with relationships, casts, fillable (Section 4)
|
|
|
|
Step 4: Set up authentication
|
|
- Modify Breeze to disable public registration
|
|
- Add CheckRole middleware
|
|
- Create PreregistrationPagePolicy
|
|
- Create SuperadminSeeder and run it
|
|
|
|
Step 5: Set up route structure (Section 6)
|
|
```
|
|
|
|
### Phase 2: Backend
|
|
|
|
```
|
|
Step 6: Build the admin layout (sidebar, navigation, responsive)
|
|
|
|
Step 7: Build the Dashboard page
|
|
|
|
Step 8: Build Pre-Registration Page CRUD
|
|
- Index with status indicators
|
|
- Create/Edit form with image uploads
|
|
- Delete with confirmation
|
|
- Storage link for public images
|
|
|
|
Step 9: Build Subscriber index
|
|
- Paginated table
|
|
- Search/filter
|
|
- CSV export
|
|
|
|
Step 10: Build User Management (superadmin only)
|
|
- CRUD for users with role assignment
|
|
```
|
|
|
|
### Phase 3: Public Frontend
|
|
|
|
```
|
|
Step 11: Build the public page view
|
|
- Three states: countdown, form, expired
|
|
- Full-screen background + overlay + centered card
|
|
- Alpine.js countdown timer
|
|
- Alpine.js form submission (AJAX)
|
|
- Mobile responsive
|
|
|
|
Step 12: Build the subscription endpoint
|
|
- Validation
|
|
- Duplicate check
|
|
- Store subscriber
|
|
- Return JSON for Alpine.js
|
|
```
|
|
|
|
### Phase 4: Mailwizz Integration
|
|
|
|
```
|
|
Step 13: Create MailwizzService class
|
|
|
|
Step 14: Build Mailwizz configuration wizard
|
|
- API key input + validation
|
|
- List selection (AJAX)
|
|
- Field mapping (AJAX)
|
|
- Tag/source selection
|
|
- Save configuration
|
|
|
|
Step 15: Create SyncSubscriberToMailwizz job
|
|
- Search existing subscriber
|
|
- Create or update with tag merging
|
|
- Error handling and retries
|
|
- Mark as synced
|
|
|
|
Step 16: Wire up the job dispatch in PublicPageController@subscribe
|
|
```
|
|
|
|
### Phase 5: Polish
|
|
|
|
```
|
|
Step 17: Add rate limiting to public endpoints
|
|
Step 18: Add flash messages / toast notifications in backend
|
|
Step 19: Add form validation error display (Blade error bags)
|
|
Step 20: Test the full flow end-to-end
|
|
Step 21: Write a README.md with setup instructions
|
|
```
|
|
|
|
---
|
|
|
|
## 16. Environment Variables
|
|
|
|
```env
|
|
APP_NAME=PreRegister
|
|
APP_URL=https://preregister.yourdomain.com
|
|
|
|
DB_CONNECTION=mysql
|
|
DB_HOST=127.0.0.1
|
|
DB_PORT=3306
|
|
DB_DATABASE=preregister
|
|
DB_USERNAME=preregister
|
|
DB_PASSWORD=
|
|
|
|
QUEUE_CONNECTION=database
|
|
|
|
# No Mailwizz env vars — API keys are per-page and stored encrypted in DB
|
|
```
|
|
|
|
---
|
|
|
|
## 17. Deployment Notes
|
|
|
|
- Run `php artisan storage:link` to expose uploads
|
|
- Run `php artisan queue:work --queue=mailwizz` (or configure Supervisor) for async Mailwizz sync
|
|
- Run `php artisan migrate --seed` on first deploy
|
|
- Set up a cron for `php artisan schedule:run` (for any future scheduled tasks)
|
|
- Ensure `APP_KEY` is set and backed up
|
|
- Configure HTTPS via reverse proxy (Nginx/Caddy)
|
|
|
|
---
|
|
|
|
## Appendix A: Mailwizz API Quick Reference
|
|
|
|
| Action | Method | URL | Notes |
|
|
|---|---|---|---|
|
|
| Get all lists | GET | `/api/lists` | Paginated |
|
|
| Get list fields | GET | `/api/lists/{list_uid}/fields` | All custom fields |
|
|
| Search subscriber | GET | `/api/lists/{list_uid}/subscribers/search-by-email?EMAIL={email}` | Returns `subscriber_uid` |
|
|
| Get subscriber | GET | `/api/lists/{list_uid}/subscribers/{subscriber_uid}` | Full subscriber data |
|
|
| Create subscriber | POST | `/api/lists/{list_uid}/subscribers` | Form-data body |
|
|
| Update subscriber | PUT | `/api/lists/{list_uid}/subscribers/{subscriber_uid}` | Form-data body |
|
|
|
|
**All requests require header:** `X-Api-Key: {your_api_key}`
|
|
|
|
**Checkboxlist field handling:**
|
|
- Stored internally as comma-separated string: `"tag1,tag2,tag3"`
|
|
- When sending via API, send as array: `TAGS[]=tag1&TAGS[]=tag2`
|
|
- When reading, split the string by comma to get individual values
|
|
- When updating, merge existing values with new value, deduplicate, send as array
|
|
|
|
---
|
|
|
|
## Appendix B: Cursor-Specific Instructions
|
|
|
|
When using this prompt with **Cursor**:
|
|
|
|
1. Open the project folder in Cursor
|
|
2. Add this entire document as context (paste in chat or reference as file)
|
|
3. Use `@Codebase` to let Cursor see existing files
|
|
4. Work through the Development Sequence (Section 15) step by step
|
|
5. Use Cursor's "Apply" feature to write directly to files
|
|
6. After each phase, run `php artisan serve` and test in browser
|
|
7. Use the terminal panel to run Artisan commands
|
|
|
|
### Cursor Rules (add to `.cursorrules`)
|
|
|
|
```
|
|
You are working on PreRegister, a Laravel 11 application.
|
|
- Always use PHP 8.2+ strict types
|
|
- Follow PSR-12 coding standards
|
|
- Use Laravel conventions (route model binding, form requests, policies)
|
|
- Use Blade + Tailwind + Alpine.js (no React, no Vue, no Livewire)
|
|
- All controller methods must have return type hints
|
|
- Use the Service pattern for external API calls (MailwizzService)
|
|
- Never expose API keys in logs, responses, or frontend code
|
|
- Test after each feature: php artisan serve + browser check
|
|
```
|
|
|
|
---
|
|
|
|
## Appendix C: Claude Code-Specific Instructions
|
|
|
|
When using this prompt with **Claude Code**:
|
|
|
|
1. Start in the project root directory
|
|
2. Read this entire document first before writing any code
|
|
3. Execute the Development Sequence (Section 15) in order
|
|
4. Use `bash` to run Artisan commands and verify state
|
|
5. Create files with proper directory structure
|
|
6. After creating migrations, always run `php artisan migrate`
|
|
7. After modifying routes, run `php artisan route:list` to verify
|
|
8. Commit after each logical step with conventional commit messages
|
|
|
|
### Claude Code Tips
|
|
|
|
- When creating Blade views, always create the layout first
|
|
- When creating controllers, create the Form Request classes at the same time
|
|
- When adding a new route, immediately create the controller method (even as a stub)
|
|
- For the Mailwizz wizard: build the backend API endpoints first, then the Alpine.js frontend
|
|
- Use `php artisan tinker` to test model relationships
|
|
- Use `php artisan make:*` commands to generate boilerplate where possible |