diff --git a/frontend/pages/dashboard/events/[id].vue b/frontend/pages/dashboard/events/[id].vue index 0db499a..0bc2cf9 100644 --- a/frontend/pages/dashboard/events/[id].vue +++ b/frontend/pages/dashboard/events/[id].vue @@ -412,6 +412,52 @@ async function confirmDelete() { } } +// RSVP edit history (Tier 2 Block A). Lazy-loaded — we only fetch revisions +// when the host clicks "History" on a guest, so a 200-row event list stays +// cheap. The current RSVP is in historyData.rsvp; revisions are newest-first. +interface RSVPRevision { + id: string + rsvp_id: string + prev_response: 'attending' | 'declined' | 'maybe' + prev_plus_ones: number + prev_dietary?: string | null + changed_at: string +} +interface RSVPHistoryResponse { + rsvp: { + response: 'attending' | 'declined' | 'maybe' + plus_ones: number + dietary_notes?: string | null + submitted_at: string + edit_count: number + } | null + revisions: RSVPRevision[] +} +const historyFor = ref(null) +const historyData = ref(null) +const historyLoading = ref(false) +const historyError = ref(null) +async function openHistory(g: Guest) { + historyFor.value = g + historyData.value = null + historyError.value = null + historyLoading.value = true + try { + historyData.value = await useApi( + `/events/${eventId}/guests/${g.id}/rsvp/history`, + ) + } catch (e: any) { + historyError.value = useErrMessage(e, 'Could not load history') + } finally { + historyLoading.value = false + } +} +function closeHistory() { + historyFor.value = null + historyData.value = null + historyError.value = null +} + // Regenerate-link flow. The modal stays open after click so the host can // see the new link copy succeed; closing returns to the list. const regenerating = ref(null) @@ -456,10 +502,11 @@ async function regenerateAndCopy() { // Esc closes any open modal — standard expectation for dialog UI. function onKeydown(e: KeyboardEvent) { if (e.key !== 'Escape') return - if (editing.value || deleting.value || regenerating.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) { + if (editing.value || deleting.value || regenerating.value || historyFor.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) { editing.value = null deleting.value = null regenerating.value = null + closeHistory() addingGuestOpen.value = false importingOpen.value = false whatsappOpen.value = false @@ -1001,6 +1048,23 @@ function checkLabel(band?: string): string { New link + @@ -1424,6 +1488,77 @@ function checkLabel(band?: string): string { + + +
+ +
+
+ +interface ExistingRSVP { + id: string + response: 'attending' | 'declined' | 'maybe' + plus_ones: number + dietary_notes?: string | null + submitted_at: string + edit_count: number +} + interface AccessResponse { guest: { id: string; name: string; email?: string | null; plus_ones: number } event: { id: string; name: string; venue: string; event_date: string } token: { id: string; status: string; expires_at: string } access_log_id: string + rsvp?: ExistingRSVP | null } interface RSVPSubmitResponse { - rsvp?: { id: string; response: string; plus_ones: number; risk_score: number } + rsvp?: ExistingRSVP & { risk_score?: number } fraud: { score: number; risk: string; reasons: string[]; used: boolean } blocked: boolean + edited?: boolean } +const MAX_EDITS = 5 + const route = useRoute() const token = route.params.token as string @@ -27,10 +40,32 @@ const submitting = ref(false) const result = ref(null) const submitError = ref(null) +// existing tracks the RSVP that was on file when the page loaded. +// editing toggles the form open over the "already responded" summary. +const existing = ref(null) +const editing = ref(false) + +const editsRemaining = computed(() => { + const used = existing.value?.edit_count ?? 0 + return Math.max(0, MAX_EDITS - used) +}) +const editLimitReached = computed(() => editsRemaining.value <= 0) + +function prefillFromRSVP(rsvp: ExistingRSVP) { + response.value = rsvp.response + plusOnes.value = rsvp.plus_ones + dietary.value = rsvp.dietary_notes ?? '' +} + onMounted(async () => { try { access.value = await useApi(`/access/${token}`) - if (access.value) plusOnes.value = access.value.guest.plus_ones || 0 + if (!access.value) return + plusOnes.value = access.value.guest.plus_ones || 0 + if (access.value.rsvp) { + existing.value = access.value.rsvp + prefillFromRSVP(access.value.rsvp) + } } catch (e: any) { loadError.value = e?.data?.error || e?.message || 'Invitation not found' } finally { @@ -41,10 +76,11 @@ onMounted(async () => { async function submit() { submitting.value = true submitError.value = null + const isEdit = !!existing.value try { const fp = useFingerprint() result.value = await useApi(`/rsvp/${token}`, { - method: 'POST', + method: isEdit ? 'PATCH' : 'POST', body: { response: response.value, plus_ones: plusOnes.value, @@ -52,8 +88,22 @@ async function submit() { fingerprint: fp, }, }) + // Refresh the cached "existing" view so a back-to-summary toggle shows + // the new state and the edit counter without a page reload. + if (result.value?.rsvp) { + existing.value = { + id: result.value.rsvp.id, + response: result.value.rsvp.response, + plus_ones: result.value.rsvp.plus_ones, + dietary_notes: result.value.rsvp.dietary_notes ?? null, + submitted_at: result.value.rsvp.submitted_at, + edit_count: result.value.rsvp.edit_count, + } + } + editing.value = false } catch (e: any) { - // 403 from BLOCK band returns a JSON body; surface its decision too. + // BLOCK band returns 403 with the fraud decision; surface it the same + // way the first-submit path does. if (e?.data?.fraud) { result.value = e.data } else { @@ -64,10 +114,28 @@ async function submit() { } } +function startEditing() { + if (existing.value) prefillFromRSVP(existing.value) + result.value = null + editing.value = true +} + +function cancelEditing() { + if (existing.value) prefillFromRSVP(existing.value) + editing.value = false +} + function fmtDate(iso?: string) { if (!iso) return '' try { return new Date(iso).toLocaleString() } catch { return iso } } + +// showForm = no prior submission, or the guest has clicked "Change my response" +const showForm = computed(() => editing.value || !existing.value) +const submitLabel = computed(() => { + if (submitting.value) return existing.value ? 'Updating…' : 'Submitting…' + return existing.value ? 'Update RSVP' : 'Submit RSVP' +})