# ND-Link — Project Reference

## Werkwijze
- **Verifieer eerst voordat je de gebruiker tegenspreekt.** Check de relevante bestanden of config als de gebruiker iets rapporteert dat niet klopt met je verwachting — niet andersom.
- **GDPR-exportregel:** elk nieuw veld dat aan een model of functie wordt toegevoegd, moet ook worden opgenomen in de GDPR-exportfile (`app/Http/Controllers/ProfileDataExportController.php`), methode `generateFor(User $user)`. Dit geldt voor profielvelden, personal insights, voorkeuren, en alle andere persoonsgebonden data. De CSV moet **altijd alle beschikbare gebruikersdata** bevatten — ook data die in de toekomst wordt toegevoegd. De admin-downloadknop op de gebruikersdetailpagina (`admin.users.export`) gebruikt dezelfde methode en moet dus automatisch up-to-date blijven.

## Project overview
- **App name:** ND-Link — GDPR-compliant neurodivergent-focused dating/community platform
- **Stack:** Laravel 11 + MySQL + Alpine.js + Tailwind CSS (JIT)
- **Payments:** Mollie (recurring subscriptions via first-sequence mandate)
- **Role:** Act as a senior Laravel full-stack developer and UX designer

## Dev priorities (MVP order)
1. Registration & login
2. Profiles
3. Matching (likes)
4. Premium payments
5. Basic chat

---

## Coding conventions

### Naming
- `camelCase` for JS variables and functions
- `snake_case` for DB columns and migrations
- `PascalCase` for PHP classes and models

### Always include
- Server-side input validation
- CSRF protection on all forms
- XSS escaping (Blade `{{ }}` auto-escapes)
- Eloquent bindings (no raw SQL)
- Authorization checks via Policies/Gates

### Error handling (try-catch)
Wrap every operation that can fail for reasons outside the app's control:
- **External APIs** — Mollie, Cloudflare, any `Http::` call
- **Mail** — `Mail::send/queue`, `$user->notify()`, `sendEmailVerificationNotification()` — SMTP can be unreachable
- **File system** — `Storage::put/delete`, `file_put_contents`, Intervention Image `->save()` — disk can be full or unwritable
- **Session writes** — `$request->session()->regenerate()` — can fail on DB/lock errors

Rules:
- Log every caught exception with `Log::error()` or `Log::warning()` and relevant context (user id, resource id, message)
- For user-facing actions: redirect/return with `__('Something went wrong.')` — never expose the raw exception
- For background/webhook handlers: log and continue — do **not** let a mail or subscription-create failure block the primary state update
- For `try-finally` blocks (e.g. temp-file cleanup): always add a `catch` clause too, otherwise the exception still propagates as a 500

### Database
- Always use foreign keys with `constrained()`
- Always use `timestamps()` on tables
- Avoid data duplication
- Add indexes on columns used in `where()`/`orderBy()`

### Performance
- Paginate all lists
- Eager load relations to avoid N+1 (`with([...])`)
- Cache where appropriate

---

## UI / Frontend rules

### Translations — critical rule
**Always hard-code strings in English with `__()`, even when the user writes Dutch in their prompt.**
- View: `{{ __('Refer a friend') }}`
- Then add the Dutch translation to `lang/nl.json`: `"Refer a friend": "Vrienden uitnodigen"`
- For plurals use `trans_choice()` and the pipe format in `nl.json`
- Never hard-code Dutch strings directly in Blade files
- Always add the German, French and Spanish translations to their respective files

### Tailwind JIT on Windows
- Only classes present in the last `npm run build` output work
- JIT sometimes misses classes in PHP ternaries or dynamic strings
- Before using a new class, grep `public/build/assets/*.css` to verify it's compiled
- For one-off values not in the build, use inline `style=""` or `<style>` blocks

### Nav bar styling
- In light mode `html:not(.dark) nav` has a colored accent background (set per theme in `app.css`)
- Standard gray Tailwind classes are overridden in `resources/css/app.css` (~line 130–175):
  - `text-gray-500` → 75% white, `hover:text-gray-700` → 100% white
- Any button added to the nav **must include `text-gray-500`** explicitly for the hover transition to work
- Use `pt-1` (no `pb`) on nav buttons that need a `border-b-2` flush with the nav bottom — matches `<x-nav-link>` behavior

### Alpine.js
- Used throughout for reactive UI (dropdown toggles, form state, copy buttons, etc.)
- `x-data`, `x-model`, `x-show`, `@click`, `@keydown` patterns are standard

---

## Security patterns

### Rate limiting
Defined in `AppServiceProvider::configureRateLimiting()`. Existing limiters:
- `login` — 20/min per IP
- `register` — 5/hour per IP
- `password-reset` — 5/hour per IP
- `coupon-validate` — 10/min per IP (brute-force protection)
- Applied via `->middleware('throttle:limiter-name')` on routes

### Race conditions
For operations that must be atomic (e.g. coupon usage counting):
```php
DB::transaction(function () {
    $record = Model::lockForUpdate()->find($id);
    // check + update inside lock
});
```

### Event listeners (Laravel 11)
Laravel 11 **auto-discovers** listeners in `app/Listeners/` via typed `handle()` parameters.
Do **not** also register them manually in `AppServiceProvider` — that causes double-firing.

---

## Subscription / Premium system

### Plans (`PremiumController::PLANS`)
| Key | Price | Interval | Tier |
|-----|-------|----------|------|
| `monthly` | €9.99 | 1 month | premium |
| `yearly` | €79.00 | 12 months | premium |
| `pro_monthly` | €15.99 | 1 month | pro |
| `pro_yearly` | €125.00 | 12 months | pro |

### Subscription status values
`active` | `cancelled` | `suspended` | `completed` | `trial` | `expired`

### `is_premium` accessor
`User::getIsPremiumAttribute()` returns `true` if raw `is_premium = true` **OR** `is_pro = true`.
- Use the accessor in PHP/Blade: `$user->is_premium`
- Use raw columns in DB queries: `->where('is_premium', true)->orWhere('is_pro', true)`

### Payment flow (Mollie)
1. `checkout()` / `checkoutPro()` — creates first-sequence Mollie payment, redirects user
2. Mollie calls `webhook()` on payment status change
3. On first successful payment: creates recurring subscription, activates premium, rewards referrer, records coupon use
4. Recurring payments: extend `subscription_ends_at`
5. Webhook is CSRF-exempt (configured in `bootstrap/app.php`)

### Admin subscription actions
`grant` | `extend` | `revoke` | `manual` | `trial`
- `trial` sets `subscription_status = 'trial'`, no `subscription_plan`

---

## Referral system

- Every user gets a unique 8-char `referral_code` (auto-generated in `User::booted()`)
- Registration URL: `/register?ref=CODE`
- `referred_by` (FK) stored on the new user at registration
- Only users with `subscription_status = 'active'` AND (`is_premium` OR `is_pro`) can refer (checked in `RegisteredUserController`)
- Reward: after the referred user's **first** Mollie payment, the referrer gets 1 free month added to `subscription_ends_at`
- `referral_rewarded_at` on the referred user prevents double-rewarding
- Referral card shown on dashboard to all users where `$user->is_premium`

---

## Coupon / discount system

- Table: `coupons` (code, type `percent`/`fixed`, value, max_uses, applicable_plans, expires_at, is_active)
- Table: `coupon_uses` (unique per coupon+user — DB constraint as safety net)
- `Coupon::isValidFor(string $plan, User $user)` — full validation
- `Coupon::discountedAmount(string $amount)` — returns Mollie-formatted decimal (min €0.01)
- Applies to **first payment only**; recurring subscription uses original price
- Coupon code passed as `coupon_code` in checkout form, validated server-side via `resolveCoupon()`
- AJAX validation endpoint: `GET /premium/coupon/validate` (rate-limited, auth required)
- Used in `checkout()`, `checkoutPro()`, `switchPlan()`
- Coupon usage recorded in webhook inside `DB::transaction()` with `lockForUpdate()`
- Admin management at `/admin/coupons`

---

## Community feed (dashboard)

- Feed type selected via checkboxes (`feed_type[]` GET parameter)
- Options: `all` | `following` | `regular` | `pro` | `suggestions`
- Multiple options combine as OR query
- Default: `following` if user follows anyone, else `all`
- `suggestions` = posts from users sharing at least one diagnosis with the current user
- `regular` = posts from users where `is_pro = false`
- Pagination preserves selection via `$posts->appends(['feed_type' => $selectedTypes])`

---

## Admin panel

- Route prefix: `/admin`, named prefix: `admin.`
- Middleware: `auth`, `verified`, `role:admin,moderator,support`
- Controllers in `app/Http/Controllers/Admin/`
- Views in `resources/views/admin/`
- Layout: `resources/views/admin/layout.blade.php` (sidebar nav with `$navLink` helper)
- Existing sections: Dashboard, Users, Contact, Reports, Server, Kortingscodes

### Admin-only features (isAdmin() check required)
- Subscription management (`updateSubscription`)
- Role changes (`updateRole`)
- Coupon management

---

## Ik-menu — data storage

All data entered via the **Ik** (Me) menu is stored **locally by default** (localStorage). The user can optionally sync to the server or export/import as a file.

### localStorage keys
| Key | Contents |
|-----|----------|
| `ndMeZones` | Signal plan zones (signals, helps, helps_not per zone) |
| `ndMeThoughts` | Helpful thoughts (array of strings) |
| `ndMeReframes` | Reframes (array of `{original, reframe}`) |
| `ndMeGratitude` | Gratitude diary (array of `{date, items[]}`) |
| `ndMeEmotions` | Emotion history |
| `ndMeSyncEnabled` | `'true'` when user has consented to server sync |

### Sync preference (`ndMeSyncEnabled`)
- Controlled via **Ik → Mijn gegevens** (`/me/data`) — consent checkbox persisted to localStorage
- When `ndMeSyncEnabled === 'true'`: changes auto-sync to the server (debounced, 1.5 s)
- When not set or `'false'`: data stays local only — no server calls
- Guests never see the sync option; the key is never set for them

### Server-side storage
- **Signal plan zones** → `signal_plan_zones` table (via `SignalPlanController`)
- **Thoughts & reframes** → `signal_plans.thoughts` / `signal_plans.reframes` columns
- **Bulk sync** (thoughts + reframes + gratitude) → `signal_plans.me_data` JSON column, endpoint `POST /me/data/sync` (`me.data.sync`)

### Routes
| Route | Method | Auth | Purpose |
|-------|--------|------|---------|
| `/signal-plan` | GET/PATCH | — | Signal plan (guests + users) |
| `/thoughts` | GET/PATCH | — | Thoughts & reframes (guests + users) |
| `/me/data` | GET | — | My data overview |
| `/me/data/sync` | POST | required | Bulk sync to server |

### Guest behaviour
- Routes `/signal-plan` and `/thoughts` are public (no auth middleware)
- Guest data stored in session as fallback; localStorage is the primary persistence
- On page load: PHP initialises Alpine with session/DB data, then `init()` overrides from localStorage
- If localStorage has zone data and mode is not `'view'`, Alpine switches to `'view'` automatically

---

## Key file locations

| What | Where |
|------|-------|
| Translations (NL) | `lang/nl.json` |
| Translations (FR) | `lang/fr.json` |
| Translations (DE) | `lang/de.json` |
| Translations (ES) | `lang/es.json` |

| Payment controller | `app/Http/Controllers/PremiumController.php` |
| User model | `app/Models/User.php` |
| Dashboard controller | `app/Http/Controllers/DashboardController.php` |
| Admin user controller | `app/Http/Controllers/Admin/UserController.php` |
| Upgrade page | `resources/views/premium/upgrade.blade.php` |
| Dashboard view | `resources/views/dashboard.blade.php` |
| Nav CSS overrides | `resources/css/app.css` (~line 130–175 light, ~line 330+ dark) |
| Rate limiters | `app/Providers/AppServiceProvider.php` |
| CSRF exceptions | `bootstrap/app.php` |
