## Purpose

The accept-gated RSVP form and decline path: how a guest's answer is captured, validated, merged, confirmed, and protected from abuse — for both tokenized and open/QR visitors.

## ADDED Requirements

### Requirement: Accept-gated form reveal (FR-2.1, P0)
The RSVP form SHALL be hidden until the guest taps "Accept Invitation"; it then slides/fades in and the page scrolls to it.

#### Scenario: Accept tapped
- **WHEN** a guest taps "Accept Invitation"
- **THEN** the RSVP form appears and receives focus/scroll

### Requirement: Form fields and validation (FR-2.2–FR-2.4, P0)
The form SHALL collect: Name (required), Phone (required), Email (optional but encouraged), Number of guests attending (1..`max_guests`), Message to the couple (optional, ≤500 chars), Dietary notes (optional). Phone input uses a country selector defaulting to BD +880 and auto-normalises local input (`01712345678` → `+8801712345678`), rejecting invalid lengths inline. Email is validated by format and, if provided, enables the email reminder channel.

#### Scenario: Local phone number entered
- **WHEN** the guest types `01712345678` with the default BD country code
- **THEN** the stored value is `+8801712345678`

#### Scenario: Invalid phone
- **WHEN** the guest enters a number of invalid length
- **THEN** an inline validation error blocks submission

#### Scenario: Party size ceiling
- **WHEN** the invitation's `max_guests` is 4
- **THEN** the guests-attending selector does not allow values above 4 or below 1

### Requirement: Pre-fill and write-back (FR-2.5, P0)
Known fields SHALL be pre-filled from the guest record; the guest may correct them, and corrections update the guest record.

#### Scenario: Guest corrects phone
- **WHEN** a tokenized guest edits the pre-filled phone number and submits
- **THEN** the guest record's phone is updated to the corrected value

### Requirement: Decline path (FR-2.6, P0)
A separate "Sorry, I can't make it" action SHALL record `declined` with no form required, then show a short "we'll miss you" screen.

#### Scenario: Guest declines
- **WHEN** the guest taps the decline button
- **THEN** the invitation status becomes `declined` and the farewell screen is shown

### Requirement: Success experience (FR-2.7, P0)
On successful submit the page SHALL show a confetti/thank-you animation, a summary of what was confirmed, an Add-to-Calendar action, and a note that they can cancel later using the same link.

#### Scenario: Successful accept
- **WHEN** the guest submits a valid acceptance for 2 guests
- **THEN** a confirmation screen summarises the event, date, and party size 2, with Add-to-Calendar offered

### Requirement: Confirmation message (FR-2.8 scoped to email, P0)
On acceptance, a confirmation email SHALL be queued immediately when the guest has an email address, containing the cancel link. Because email is the only v1 channel, the form SHALL clearly encourage providing an email ("to receive your confirmation and reminders"); a guest without email still RSVPs successfully but receives no messages.

#### Scenario: Accept queues confirmation
- **WHEN** an RSVP acceptance is stored for a guest with an email address
- **THEN** a confirmation email job exists containing the cancel link

#### Scenario: Accept without email
- **WHEN** a guest accepts without providing an email
- **THEN** the RSVP is recorded normally and the success screen notes they can revisit the same link to manage their RSVP

### Requirement: Idempotent submission (FR-2.9, P0)
Submitting the form twice MUST NOT create two RSVPs — one current answer per invitation; re-submission updates the existing answer.

#### Scenario: Double tap on submit
- **WHEN** the same acceptance is submitted twice (double-tap or retry)
- **THEN** exactly one RSVP row exists for the invitation and headcount counts it once

### Requirement: Open-link registration (FR-2.10, P0)
On the open/QR link, the form SHALL additionally ask which event(s) the visitor is responding to (checkboxes), then create the guest and invitation records with source `self_registered` and `invited_via = print_qr` or `link` as applicable.

#### Scenario: Walk-in registers for two events
- **WHEN** an open-link visitor submits name/phone and ticks Marriage + Walima
- **THEN** one guest record and two invitations (one per event) are created with their RSVP answers

### Requirement: Duplicate merging on open link (FR-2.11, P0)
Open-link submissions whose phone or email already exists for the wedding SHALL be merged into the existing guest, not duplicated. Uniqueness is enforced on `(wedding_id, phone_e164)` and `(wedding_id, lower(email))` where not null (§4.2).

#### Scenario: Known guest uses QR
- **WHEN** an open-link submission arrives with a phone matching an existing guest
- **THEN** the RSVP attaches to that existing guest and no duplicate guest row is created

### Requirement: Abuse protection (FR-2.12, P0)
The system SHALL rate-limit RSVP submissions to a maximum of 5 per IP per 10 minutes and show a CAPTCHA (Turnstile) on the open link only — the tokenized flow stays frictionless (§7.6).

#### Scenario: Rate limit exceeded
- **WHEN** a 6th submission arrives from one IP within 10 minutes
- **THEN** the submission is rejected with a rate-limit response

#### Scenario: Tokenized flow has no CAPTCHA
- **WHEN** a guest submits via a valid `/i/{token}` page
- **THEN** no CAPTCHA challenge is required

### Requirement: Status lifecycle rules (§4.3, P0)
Status transitions SHALL follow: `pending → opened → accepted|declined`, `accepted → cancelled` any time before event start, `cancelled → accepted` allowed (mind changed again), `declined → accepted` allowed, `pending/opened → expired` after the RSVP deadline with no response. Every transition writes an append-only `rsvp_history` row with timestamp, actor, and source. Headcount counts only invitations currently `accepted`, summing `party_size`.

#### Scenario: Cancel then re-accept
- **WHEN** a guest cancels and later re-accepts via the same link
- **THEN** the status returns to `accepted`, and history preserves the full pending→accepted→cancelled→accepted trail

#### Scenario: Headcount math
- **WHEN** two invitations are accepted with party sizes 2 and 3 and one is cancelled
- **THEN** the event headcount equals the remaining accepted party size only

### Requirement: Progressive enhancement fallback (FR-2.13, P2)
The form SHALL work without JavaScript as a plain POST fallback.

#### Scenario: JS disabled
- **WHEN** a guest with JavaScript disabled submits the form
- **THEN** the RSVP is recorded via standard form POST and a server-rendered confirmation is shown
