## Context

Stack: Laravel 12 + Inertia v2 React (TSX) + Tailwind v4 + Fortify + Wayfinder, mobile-first single-district app ("Rokto Bondhu"). Relevant current state (see proposal.md for motivation):

- **Donations**: `Donation` model (uuid, `donation_date`, `location`, …) is stored via `ProfileController::storeDonation()` (`POST /profile/donations`) with `before_or_equal:today` date validation and a flash redirect. The add-donation UI is a `BottomSheet` + Inertia `<Form resetOnSuccess>` in `resources/js/pages/profile/donations.tsx`; the sheet does not close on success. Users carry a cached `last_donation_date` column used by donor filtering.
- **Location**: migrations only have `unions` and `villages` (no upazila level — deliberately collapsed in the earlier `inertia-donor-app` change). `UnionSeeder` currently seeds 8 placeholder unions; `VillageSeeder` seeds a few villages each; `LocationSelects` cascades union→village client-side with all villages passed as props. Register flow (Fortify `CreateNewUser`) validates `union_id`/`village_id`.
- **Navigation**: bottom nav in `resources/js/layouts/donor-app-layout.tsx` uses plain `<a href>` (full reloads); the "Find" tab targets `/donors?tab=search`, but the server never reads `tab` — the page derives it from active filters, so the active state rarely matches. Back button (`donor-page-header.tsx`) gates on `window.history.length > 1`, which counts the whole tab history, so `router.back()` can exit the app on deep links/direct loads.
- **Home page** (`resources/js/pages/donors/index.tsx`): blood-group chip row (the "category showcase"), a Filter button opening a `BottomSheet`, debounced search, and server-side `paginate(15)` with Prev/Next links. `DonorController@index` filters + `withQueryString`.
- **Registration**: Fortify `CreateNewUser` (name, phone, username, password, blood_group, union/village, availability). No referral logic anywhere. Users have no email column.

## Goals / Non-Goals

**Goals:**

- Keep every change inside the existing patterns: Wayfinder routes, Inertia `<Form>`/`router`, `BottomSheet`, shared donor components.
- Single source of truth for the 75-day rule reused by validation, UI copy, and eligibility display.
- Make upazila→union an additive schema change (new table + nullable FK), reusing the existing cascade-picker approach.
- Keep server-side pagination for the donor list; implement infinite scroll on top of it with partial reloads.

**Non-Goals:**

- No village seeding — villages stay user-created (per proposal).
- No changes to auth (phone/username login stays), no email support.
- No admin panel for managing locations.
- No referral rewards/leaderboards — only code + count.
- Not migrating the legacy `resources/views/ui.html` mockup.

## Decisions

### D1. Donation gap rule as a named constant + custom validation
- Add `MIN_DONATION_GAP_DAYS = 75` in a central place (`Donation` model constant) and a closure/rule that fails when the submitted date is `< 75 days` after the user's latest donation dated before the submitted date. Validation extends the existing `donation_date` rules in `storeDonation()`.
- Gap checked against the latest donation **earlier than the submitted date** (not just "most recent overall") so backdated entries also validate correctly.
- Keep the existing cached `last_donation_date` update; `next_eligible_date = last_donation_date + 75 days` computed server-side and passed to the page.
- *Alternative considered*: enforcing the gap only in the UI — rejected; server-side is the contract and the spec requires it.

### D2. Modal close + success via `onSuccess` on the existing Inertia `<Form>`
- In `donations.tsx`, add `onSuccess` to close the `BottomSheet` and surface the flash/success message (toast or inline banner) — the form already resets. On validation failure the modal stays open and `errors` render per-field (already wired).
- Client-side: keep `max=today` on the date input; add a light pre-check (required, not future). Gap errors come from the server and display inline in the still-open sheet.
- *Alternative considered*: convert to a manual `router.post` + `useRemember` — rejected, more churn than the existing `<Form>` needs.

### D3. Referral: two nullable columns on `users` + count via relation
- Migration adds `referral_code` (string, unique) and `referred_by_user_id` (nullable self-FK, nullOnDelete). `User` gets `referrals()` HasMany and `referrer()` BelongsTo; `referrals_count` computed via `withCount('referrals')` on the profile page (no denormalized counter).
- Code format: short uppercase alphanumeric derived from the uuid/username (e.g. 8 chars), generated in `CreateNewUser` with a uniqueness retry; unique index backs it.
- Registration: optional `referral_code` input validated with `exists:users,referral_code` (nullable) → resolve to `referred_by_user_id` in `CreateNewUser`. Profile shows the code (shareable) and referral count.
- *Alternative considered*: separate `referrals` table — rejected for now; two columns answer "who referred whom + how many" with no extra joins.

### D4. Upazila added additively; seeders replace placeholder data
- New `upazilas` table (id, name unique, timestamps) + `upazila_id` nullable FK on `unions` (nullOnDelete). New `Upazila` model; `Union` gets `upazila()` BelongsTo; `User`/`Village` unchanged (they resolve hierarchy through the union).
- `UpazilaSeeder` (9 upazilas) and rewritten `UnionSeeder` (91 unions with `upazila_id` mapping, from the user-provided Bengali lists), both idempotent (`firstOrCreate`), wired into `DatabaseSeeder`. `VillageSeeder`/`DonorSeeder` updated only as needed (union names changed; donors reference the new unions).
- Because union names change wholesale, this is effectively re-seed data: run on fresh/dev DBs (`migrate:fresh --seed`). Not a production data migration — app is pre-launch.
- `LocationSelects` becomes upazila → union → village; `DonorController@register|index` props pass upazilas; donor card location string shows village, union, upazila (union eager-load gains `upazila:id,name`).

### D5. Village searchable combobox with create-if-missing
- New endpoint `POST /villages` (auth) validating `name` + `union_id`, using `firstOrCreate(['union_id', 'name'])` — the unique(union_id, name) index already exists. Rate-limit via standard throttle.
- New `VillageCombobox` component: input filters the village list client-side; when the typed text has no exact match, show a "Create '<name>'" option that POSTs then emits the new village id. Replaces the village `<select>` inside `LocationSelects` (register + profile edit + filter sheet where applicable).
- *Alternative considered*: seeded village catalog — rejected per user (too many villages in Noakhali; grows organically).

### D6. Nav + back button
- Bottom nav: replace `<a>` with Inertia `<Link>` + `preserveState` defaults; active matching fixed so the Find tab (`/donors?tab=search`) highlights when search/filters are active — pass `tab` through to the page (server reads `tab` query param and the page honors it) or match on `filters.active`.
- Back button: replace the `window.history.length` check with an in-app-navigation flag — a small module (e.g. `lib/back.ts`) that records whether any Inertia navigation has happened this session (set on first `router.on('navigate')`/layout mount). If the app has navigated, `router.back()`; else `router.get(fallback)` where fallback is per-page (prop with default `/donors`).
- *Alternative considered*: `document.referrer` sniffing — rejected (blank on many loads, same-origin parsing fragile).

### D7. Home page: compact blood-group control + infinite scroll
- Remove the chip row; add a compact blood-group button beside the Filter button (e.g. a `Droplet` icon + current group) opening a small popover/sheet with the 8 groups (the filter sheet's blood-group grid remains).
- Infinite scroll: keep `DonorController@index` `paginate(15)`. In `donors/index.tsx`, hold accumulated donors in `useState` (seeded from props, reset when filters/search change via `useEffect` on the query), and use an `IntersectionObserver` sentinel that calls `router.get(donorsIndexRoute, { page: next }, { only: ['donors'], preserveState: true, preserveScroll: true })`, merging the returned page's items and updating `currentPage/lastPage`. Show a skeleton/spinner while loading and an end-of-list marker when `page >= lastPage`.
- *Alternative considered*: cursor pagination — rejected; offset pages already exist and the dataset is small (single district).

## Risks / Trade-offs

- [Union re-seeding changes existing union names → seeded donors' union_id references break] → run `migrate:fresh --seed` on dev; pre-launch app, no production data to preserve.
- [Infinite scroll + client-accumulated state can desync from server props on back/forward navigation] → reset accumulated list from fresh props on every full Inertia visit (`useEffect` keyed on the visits), keep `preserveState` only for the page-fetch reloads.
- [75-day constant vs the existing `recent` donation_status filter that uses 90 days] → align the filter threshold to the same constant in this change so eligibility messaging is consistent (single constant referenced by both).
- [Village free-text creation invites typos/duplicates across unions] → duplicates prevented per (union, name); cross-union near-duplicates accepted for now (Non-Goal to fuzzy-merge).
- [Bengali names in code files] → ensure seeders and DB use utf8mb4 (MySQL default in Laravel schema); no transliteration.
- [`window.history.length` removal] → first-visit flag heuristic: a user who lands, navigates in-app, then presses back gets real history; a hard reload mid-session resets the flag and falls back to the safe route. Acceptable trade-off vs exiting the app.

## Migration Plan

1. Deploy code + run `php artisan migrate` (new `upazilas` table, `unions.upazila_id`, `users.referral_code`/`referred_by_user_id`).
2. `php artisan db:seed` (idempotent upazila/union seeders) — dev/staging uses `migrate:fresh --seed`.
3. Rollback: migrations down; seed data harmless to leave in place.

## Open Questions

- Referral code exact format/length (8-char uppercase alnum proposed) — safe to finalize during implementation.
- Whether the compact blood-group control opens its own popover or reuses the filter sheet scrolled to the blood-group section — implementation detail, no spec impact.
