diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index fca9af4..bfa4f07 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -168,6 +168,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [ ## CI-001 — Repair test gates and secure the deployment pipeline +**COMMUNITY-STATUS-001 #724 — source-only staging child:** correct member free-event availability/cancellation-history presentation and stale UI after uncertain status reads. Existing #707/#711/#719/#722 dependencies are closed. Additive response-only enum/after-read clock; no stored/mutation/policy/authorization/schema/migration change or new service/dependency/IAM/Rules/polling. Existing transactional checks and exact saved retries remain authoritative. Backend-first exact staging source/archive, resource/profile/security, Hosting and disposable real UI/cleanup are required before availability; real refunds/transfers/dues/production and full backend completion stay separate. + **CI-STAGING-VERIFY-001 #722:** one script-only child corrects the final community success output's undefined digest, adds closed redacted failure phases and fixed read-only native verification. Local reproduction proves the reporting bug, not the caught exception from every historical failure. Clean exact green current-main source, native ancestor provenance/exact archive, scoped owner/runtime/config/resource/invoker and unchanged profile inventory remain mandatory. No new deployment, provider/IAM/data/payment mutation, dependency, workflow or production authority. See the [officer review](./docs/officers/STAGING_COMMUNITY.md#check-an-uncertain-staging-release--722). This does not close #105/#133/#136 or full backend readiness; current command/unchanged-marker evidence must be recorded in #722 before claiming hosted verification. **CI-001C2 owner decision, 2026-10-11:** [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697) applies Dave's direct approval to continue Firebase backend setup without a backup owner. Dave (`daliu`) is the sole release/recovery owner. Backup appointment and two-person ownership are no longer backend prerequisites; absence of backup remains documented risk. #113/#133/#136 retain their other inventory, authority, exact-source, staging, policy and rollback requirements. Jeff's #667 read-only staging role does not expand. This decision is not Firebase deployment or live payment approval. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 40a1f4b..1154360 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,5 +1,7 @@ # MPRC Platform Operations Runbook +**#724 current-status correction — SOURCE ONLY:** same scoped staging service/runtime, response-only availability and no migration. Deploy only exact reviewed communityActions after green main, verify its native archive/settings/IAM and unchanged profiles, then publish exact staging Hosting. Supply only owned disposable member/event fixtures. Check all six availability states, stored confirmed history after event cancellation, own cancellation/count preservation, failed refresh clearing and exact saved retry; sample term/window boundaries after reads. Follow the [no-terminal review](./docs/officers/STAGING_COMMUNITY.md#current-member-status--724-source-prepared-not-available-yet). Remove only owned fixtures with identity/update-time fences and sign out; no extra service/polling, new permissions, real policy/payment or production action. Fresh native acceptance is pending #724. + ## Read-only community release verification — #722 The former success output used an undefined `digest` identifier; a provider-free regression reproduces `ReferenceError` and verifies serialization of `bundle.digest`. Historical generic failures remain their original reported status; this does not prove which exception every old run caught. New failures expose only a closed phase enum, never exception text, provider responses, tokens or signed URLs. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 2096c10..de555b5 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,5 +1,7 @@ # MPRC Platform System Design +**#724 member-status correction — SOURCE ONLY:** the existing staging status read adds a response-only closed `signupStatus` enum (open/not_open_yet/closed/ended/full/cancelled), derived from validated event/window/capacity with server time sampled after reads. No stored schema, mutation authorization, count, registration/waiver/audit, membership policy, IAM/Rules/service/dependency or production change. UI distinguishes cancelled events from stored signups, fails closed on missing/unknown availability and hides old membership/event status/actions on uncertain reads while preserving exact saved retry and account/provider fences. No polling. Backend-first staging archive/resource/readback, Hosting and disposable UI/failure/cleanup proof remain required under #724; source/tests are not hosted acceptance. + **#719 fictional account linking — STAGING VERIFIED 2026-10-12:** `admin_dues_link` deliberately connects one immutable fictional receipt to one owner-marked verified/enabled disposable pending account. Exact v3 association permission is separate from recording/approval/event permissions; native recent TOTP/App Check, exact caller/input/revision and <=24-hour disposable marker are required. One transaction creates a separate link and derived authority preserving the original current term, preventing duplicate receipt/account activation, reassignment and renewal. Saved retries recheck permission first; cancellation keeps the original term and server-clock expiry removes test membership. Receipt review adds only a linked boolean, no UID/contact data. No Auth claim/role, real policy/data/payment, dependency/service/schedule/IAM expansion/migration or production change. Default-deny browser Rules cover both new collections. PR720 merged b6a8fd57d9add466da73df1d33b69e131e3fecbd after six exact PR/main jobs and every step passed. Native communityActions version 4 and staging Hosting match that source; the downloaded 91,793-byte archive matches SHA-256 47f11b8bec7f5ae58281c9029a015e9cc751a04e5ad3eafe3234441fa3a963db. Forty-four hosted cases (32 denial cases), atomic races, immutable retries, actual officer/member UI, offline retry, phone-size DOM layout, cancellation and natural expiry passed. Six disposable users and 47 exact owned documents were removed; native absence and late profiles were verified. Both capabilities were disabled first; browser sign-out, Apps-on-device OFF, normal networking/viewport, closed tab/receiver and memory disposal complete the test handoff. Profiles v2/v1/2843388, App Check/MFA and the six-permission min0/max2 runtime remain unchanged. The release command reported its existing generic post-deploy failure; independent native checks qualified the result without repeating deployment or bypassing a guard. Reporting cause remains unqualified. Production/runmprc.com and real payments were untouched. **Status:** Target architecture and current-state assessment diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md index 8dedf49..6414e5b 100644 --- a/docs/officers/STAGING_COMMUNITY.md +++ b/docs/officers/STAGING_COMMUNITY.md @@ -62,6 +62,23 @@ ## Existing membership and free-event actions +### Current member status — #724 source prepared, NOT AVAILABLE YET + +**Purpose:** distinguish the event's current signup availability from the member's stored signup, and stop displaying old status after an uncertain read. **Approver/escalation:** Dave, sole staging owner. **Prerequisites:** the specialist supplies the verified #724 staging release, disposable account and made-up events. New behavior is not hosted proof until #724 records exact deployment, actual UI and cleanup. + +1. Open the supplied staging `/community` link and confirm **Staging test only**. +2. Sign in with the supplied disposable account. +3. Read the event's signup message before choosing an action. +4. Stop a new signup if it is cancelled, closed, full, not open yet, ended or unknown. +5. Keep **Event cancelled** separate from **Your signup** history. An event cancellation does not itself rewrite your stored signup or issue a refund. +6. If the specialist asks, cancel your own confirmed fictional signup using the existing button. +7. Select **Refresh status** after an event change or term expiry. +8. If the refresh fails, expect old membership/event status and new-action controls to disappear. +9. Use **Retry request** only for an uncertain saved action, or **Refresh status** for a new read. +10. Confirm only a successful fresh read restores current controls, then sign out and request owned-fixture cleanup. + +**Expected result/proof:** the server projects existing event window/capacity/status constraints using its clock after reads; the browser cannot turn a display value into authorization. Counts, registration/waiver evidence and paid-term cancellation policy are unchanged. A confirmed stored signup on a cancelled event is history, not proof that the event will take place. Existing transactional join checks still decide the result if availability changes after the read. Missing/unknown projection fails closed. No polling or extra service is added. **Stop:** production, real data/payment, unclear availability/count/history, stale status surviving failure or request for secrets/record edits. **Undo:** sign out; Dave/specialist uses the reviewed staging rollback if required. Account/fixture supply, deployment/readback/cleanup and rollback remain specialist-only; no officer uses a terminal or edits records. Real refunds, bib transfers and production remain unavailable. + - Request membership: records a pending request. It does not prove dues or grant membership. - Withdraw membership request: withdraws a pending application. - Cancel membership: records a cancellation request for an already-approved synthetic membership. Access lasts through its original paid term. No refund occurs. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index ff26ae1..ea68d40 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -2,6 +2,20 @@ These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version. +## Member status freshness — #724 source only + +```mermaid +flowchart LR + Member["Disposable staging member"] --> Read["Current status read; no polling"] + Read --> Server["Validated event/capacity; clock sampled after reads"] + Server --> Display["Signup availability and separate stored signup"] + Read -- "Failure or uncertain action" --> Hide["Hide old status/new actions; keep exact saved retry"] + Hide --> Read + Display --> Mutation["Existing transactional join/cancel checks decide again"] +``` + +Text alternative: the staging member page displays server-derived availability separately from stored signup history, hides old status on uncertainty and restores it only after a fresh read, while existing transactions—not the display—continue deciding joins and cancellations; hosted proof remains pending #724. + ## Uncertain staging release review — #722 ```mermaid diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js index acddc31..f6c2893 100644 --- a/functions/communityActions.emulator.test.js +++ b/functions/communityActions.emulator.test.js @@ -17,6 +17,7 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', now = Date.now(); service = createCommunityService(db, () => now); }); beforeEach(async () => { + now = Date.now(); for (const root of roots) await db.recursiveDelete(db.collection(root)); await db.doc('communityConfig/events').create({ schemaVersion: 1, eventIds: ['fixture-event'] }); await db.doc('communityEvents/fixture-event').create(freeEvent(now)); @@ -51,6 +52,20 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)', expect((await service.status(uid)).membership.entitlement).toBe('current_member'); now = paid.authority.term.endsAtMs; expect((await service.status(uid)).membership.entitlement).toBe('not_entitled'); + }, 45000); // Concurrent native transactions may outlast Jest's five-second unit default. + test('cancelled-event availability is a read-only projection and preserves own confirmed signup evidence', async () => { + const uid = 'fixture-status-member'; + await service.mutate(uid, join()); + const event = db.doc('communityEvents/fixture-event'); + const registration = event.collection('registrations').doc(uid); + await event.update({ status: 'cancelled' }); + const before = await db.getAll(event, registration); + const view = await service.status(uid); + expect(view.events[0]).toMatchObject({ status: 'cancelled', signupStatus: 'cancelled', confirmedCount: 1, registration: { status: 'confirmed', revision: 1 } }); + expect((await service.status('fixture-other')).events[0].registration.status).toBe('none'); + const after = await db.getAll(event, registration); + expect(after.map(doc => doc.data())).toEqual(before.map(doc => doc.data())); + expect(after.map(doc => doc.updateTime.toMillis())).toEqual(before.map(doc => doc.updateTime.toMillis())); }); test('officer approval/withdrawal, capacity/closure concurrency and live revocation use real transactions', async () => { const uid = 'fixture-officer'; const runner = 'fixture-member'; diff --git a/functions/communityActions.js b/functions/communityActions.js index 97ed314..36d711e 100644 --- a/functions/communityActions.js +++ b/functions/communityActions.js @@ -99,13 +99,23 @@ function createCommunityService(db, clock = Date.now) { const memberRef = (uid) => db.doc(`communityMemberships/${uid}`); const emptyMember = { schemaVersion: 1, revision: 0, applicationStatus: 'none', authority: null }; async function status(uid) { - const now = clock(); const [member, manifest] = await db.getAll(memberRef(uid), db.doc('communityConfig/events')); const ids = manifest.exists ? manifest.data().eventIds : []; check(Array.isArray(ids) && ids.length <= 20 && new Set(ids).size === ids.length && ids.every((id) => typeof id === 'string' && ID.test(id))); const refs = ids.flatMap((id) => [db.doc(`communityEvents/${id}`), db.doc(`communityEvents/${id}/registrations/${uid}`)]); const docs = refs.length ? await db.getAll(...refs) : []; - const events = ids.map((id, index) => ({ ...eventView(docs[index * 2].data(), id), registration: registrationView(docs[index * 2 + 1].data()) })); + // Read completion, not request start, determines time-based presentation. + // Mutations still independently enforce their transactional constraints. + const now = clock(); check(integer(now, 8_640_000_000_000_000)); + const events = ids.map((id, index) => { + const event = eventView(docs[index * 2].data(), id); + const signupStatus = event.status === 'cancelled' ? 'cancelled' + : event.status === 'closed' ? 'closed' + : now < event.opensAtMs ? 'not_open_yet' + : now >= event.closesAtMs ? 'ended' + : event.confirmedCount >= event.capacity ? 'full' : 'open'; + return { ...event, signupStatus, registration: registrationView(docs[index * 2 + 1].data()) }; + }); return { membership: memberView(member.exists ? member.data() : emptyMember, uid, now), events }; } diff --git a/functions/communityActions.test.js b/functions/communityActions.test.js index 9b350ea..aac12a2 100644 --- a/functions/communityActions.test.js +++ b/functions/communityActions.test.js @@ -127,6 +127,48 @@ describe('transactional membership and free-event lifecycle', () => { await service.mutate('runner-a', joining()); expect((await service.status('runner-b')).events[0].registration.status).toBe('none'); }); + test.each([ + [{}, 'open'], + [{ opensAtMs: 1_800_000_000_001 }, 'not_open_yet'], + [{ opensAtMs: 1_800_000_000_000 }, 'open'], + [{ closesAtMs: 1_800_000_000_000 }, 'ended'], + [{ status: 'closed' }, 'closed'], + [{ confirmedCount: 1 }, 'full'], + [{ status: 'cancelled', confirmedCount: 1 }, 'cancelled'], + ])('status projects existing signup constraints without writes %#', async (extra, expected) => { + docs.set('communityEvents/one-seat', freeEvent(now, extra)); + const before = JSON.stringify([...docs]); + expect((await service.status('runner-a')).events[0].signupStatus).toBe(expected); + expect(JSON.stringify([...docs])).toBe(before); + }); + test('cancelled event status retains confirmed signup history and another UID sees none', async () => { + await service.mutate('runner-a', joining()); + docs.get('communityEvents/one-seat').status = 'cancelled'; + const before = JSON.stringify([...docs]); + expect((await service.status('runner-a')).events[0]).toMatchObject({ + status: 'cancelled', signupStatus: 'cancelled', confirmedCount: 1, + registration: { status: 'confirmed', revision: 1 }, + }); + expect((await service.status('runner-b')).events[0].registration.status).toBe('none'); + expect(JSON.stringify([...docs])).toBe(before); + }); + test('server status samples time after reads so a term/window ending during I/O is not shown active/open', async () => { + const memory = memoryDb(); const began = now; let current = began; + memory.docs.set('communityMemberships/runner-a', approvedMember('runner-a', began)); + memory.docs.set('communityConfig/events', { schemaVersion: 1, eventIds: ['one-seat'] }); + memory.docs.set('communityEvents/one-seat', freeEvent(began, { closesAtMs: began + 60000 })); + const original = memory.db.getAll; + memory.db.getAll = async (...refs) => { const value = await original(...refs); current = began + 60000; return value; }; + const read = createCommunityService(memory.db, () => current); + const before = JSON.stringify([...memory.docs]); + const result = await read.status('runner-a'); + expect(result.membership.entitlement).toBe('not_entitled'); + expect(result.events[0].signupStatus).toBe('ended'); + expect(JSON.stringify([...memory.docs])).toBe(before); + }); + test('display availability cannot be injected into a signup request', () => { + expect(() => parseRequest({ ...joining(), signupStatus: 'open' })).toThrow('Request is invalid.'); + }); test('write failure rolls back all effects and a retry remains safe', async () => { const memory = memoryDb(); const command = request('membership_join'); const original = memory.db.runTransaction; diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx index 4da4ad9..de9a669 100644 --- a/src/pages/community/Community.test.tsx +++ b/src/pages/community/Community.test.tsx @@ -25,6 +25,22 @@ const state = (applicationStatus = 'none', revision = 0) => ({ }, events: [], }); +const testEvent = (extra = {}) => ({ + id: 'fixture-event', + title: 'Synthetic event', + location: 'Synthetic place', + startAtMs: 1800003600000, + opensAtMs: 1799999999000, + closesAtMs: 1800001800000, + status: 'open', + signupStatus: 'open', + capacity: 1, + confirmedCount: 0, + waiverText: 'Synthetic waiver only', + waiverVersion: 'synthetic-v1', + registration: { revision: 0, status: 'none' }, + ...extra, +}); const show = () => ( @@ -77,7 +93,7 @@ test('an uncertain request retries exactly once with the original ID, then rerea fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); await screen.findByRole('button', { name: 'Retry request' }); await waitFor(() => expect(screen.getByRole('button', { name: 'Retry request' })).toBeEnabled()); - expect(screen.getByRole('button', { name: 'Request membership' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Request membership' })).not.toBeInTheDocument(); const original = call.mock.calls[1][1]; call.mockResolvedValueOnce({ membership: { revision: 1, applicationStatus: 'pending' } }).mockResolvedValueOnce(state('pending', 1)); fireEvent.click(screen.getByRole('button', { name: 'Retry request' })); @@ -145,6 +161,7 @@ location: 'Synthetic place', opensAtMs: 1, closesAtMs: 1800000000000, status: 'open', +signupStatus: 'open', capacity: 1, confirmedCount: 0, waiverText: 'Synthetic waiver only', @@ -171,3 +188,69 @@ waiverVersion: 'synthetic-v1', expect(call.mock.calls[3][1]).toMatchObject({ action: 'event_cancel', expectedRevision: 1 }); expect(screen.getByRole('checkbox')).not.toBeChecked(); }); + +test.each([ + ['not_open_yet', 'Signup has not opened yet.'], + ['closed', 'Signup is closed.'], + ['ended', 'The signup window has ended.'], + ['full', 'No places are available for new signups.'], + [undefined, 'Signup availability could not be confirmed. Refresh status.'], + ['forged', 'Signup availability could not be confirmed. Refresh status.'], +])('unavailable or unknown server signup state %s cannot prepare a new signup', async (signupStatus, text) => { + call.mockResolvedValue({ ...state(), events: [testEvent({ signupStatus })] }); + render(show()); await screen.findByText(text); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Sign up for Synthetic event' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Sign up for Synthetic event' })); + expect(call).toHaveBeenCalledTimes(1); +}); + +test('cancelled event is explicit while stored signup and own cancellation remain separate', async () => { + const event = testEvent({ + status: 'cancelled', + signupStatus: 'cancelled', + confirmedCount: 1, + registration: { revision: 1, status: 'confirmed' }, + }); + call.mockResolvedValue({ ...state(), events: [event] }); + render(show()); await screen.findByText('Event cancelled.'); + expect(screen.getByText(/Your signup: confirmed/)).toBeInTheDocument(); + const cancel = screen.getByRole('button', { name: 'Cancel signup for Synthetic event' }); + expect(cancel).toBeEnabled(); + call.mockResolvedValueOnce({ registration: { revision: 2, status: 'cancelled' } }); + call.mockResolvedValueOnce({ ...state(), events: [{ ...event, confirmedCount: 0, registration: { revision: 2, status: 'cancelled' } }] }); + fireEvent.click(cancel); await screen.findByText(/Your signup: cancelled/); + expect(screen.getByText('Event cancelled.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign up for Synthetic event' })).toBeDisabled(); +}); + +test('failed refresh hides old active membership/events and only a successful reread restores controls', async () => { + const active = { ...state('approved', 2), membership: { ...state('approved', 2).membership, entitlement: 'current_member', accessThroughMs: 1800000060000 }, events: [testEvent()] }; + call.mockResolvedValue(active); render(show()); await screen.findByText('Test membership active.'); + call.mockRejectedValueOnce(new Error('synthetic offline')); + fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('Status could not be loaded. Try Refresh status.'); + expect(screen.queryByText('Test membership active.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel membership' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign up for Synthetic event' })).not.toBeInTheDocument(); + call.mockResolvedValueOnce({ ...state('approved', 2), events: [testEvent({ signupStatus: 'ended' })] }); + fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('The signup window has ended.'); + expect(screen.getByText('Test membership is not active.')).toBeInTheDocument(); +}); + +test('successful action followed by failed status read hides stale state but retains exact saved retry', async () => { + render(show()); await screen.findByRole('button', { name: 'Request membership' }); + call.mockResolvedValueOnce({ membership: { revision: 1, applicationStatus: 'pending' } }); + call.mockRejectedValueOnce(new Error('synthetic read failure')); + fireEvent.click(screen.getByRole('button', { name: 'Request membership' })); + await screen.findByRole('button', { name: 'Retry request' }); + expect(screen.queryByRole('button', { name: 'Request membership' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Retry request' })).toBeEnabled()); + const command = call.mock.calls[1][1]; + call.mockResolvedValueOnce({ membership: { revision: 1, applicationStatus: 'pending' } }); + call.mockResolvedValueOnce(state('pending', 1)); + fireEvent.click(screen.getByRole('button', { name: 'Retry request' })); + await screen.findByText('Request status: pending.'); + expect(call.mock.calls[3][1]).toEqual(command); +}); diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 52e140d..78fe06b 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -8,6 +8,20 @@ import { import SEO from '../../components/SEO'; import './Community.css'; +function signupMessage(event: CommunityStatus['events'][number]) { + if (event.status === 'cancelled') return 'Event cancelled.'; + if (event.status === 'closed') return 'Signup is closed.'; + switch (event.signupStatus) { + case 'open': return 'Signup is open. Availability is checked again when you submit.'; + case 'not_open_yet': return 'Signup has not opened yet.'; + case 'closed': return 'Signup is closed.'; + case 'ended': return 'The signup window has ended.'; + case 'full': return 'No places are available for new signups.'; + case 'cancelled': return 'Event cancelled.'; + default: return 'Signup availability could not be confirmed. Refresh status.'; + } +} + export default function Community() { const { services } = useServiceLocator(); const { user, isLoading } = useAuth(); @@ -50,6 +64,8 @@ export default function Community() { generation.current += 1; const {current} = generation; inFlight.current = true; setBusy(true); setMessage(''); + // Hide old status and new actions while current state is uncertain. + setSnapshot(null); setWaivers({}); let command = statusOnly ? null : pending.current; if (!statusOnly && request) { command = { ...request, accountUid: uid, requestId: crypto.randomUUID() }; @@ -66,7 +82,7 @@ export default function Community() { setMessage(command ? 'Request confirmed. Your status is shown below.' : 'Status refreshed.'); } } catch { - if (generation.current === current) { setMessage(command + if (generation.current === current) { setSnapshot(null); setWaivers({}); setMessage(command ? 'We could not confirm this request. Use Retry request; it will not create a second signup.' : 'Status could not be loaded. Try Refresh status.'); } } finally { @@ -159,6 +175,7 @@ export default function Community() { {' '} {event.location}

+

{signupMessage(event)}

{event.confirmedCount} {' '} @@ -175,14 +192,14 @@ export default function Community() { <>

{event.waiverText}