From 56bfee07a194d6afb2bc838e8a8d923b1512a3a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 00:09:22 +0000 Subject: [PATCH] Add a public Events page with RSVPs, private until launch - /[locale]/events: Upcoming / Past, one card per event (flyer that enlarges, tag, date and time in Pacific, online or address, host, description, optional RSVP count) and an RSVP form on the card. After RSVPing: "You're in!", Add to Google Calendar and an .ics download. - Private until EVENTS_PUBLIC is exactly "true": a 404 for the public, a "Private preview" banner for board admins of either hub, noindex, no nav link, home strip or sitemap entry, and the RSVP API refuses. Once public: "Events" in the header and the next-event strip on home. - RSVP confirmations and "Send reminder to everyone" go out like board invites (SMTP on 2525, Brevo API fallback, outbox for tests) from the chapter's sender, with a PUBLISH .ics. The meeting link is stored on the event and only appears in those emails. A repeat RSVP with the same email resends the confirmation instead of adding a second one. - Hub: an admin-only Events tab on both hubs to add, edit and delete events (flyers use the hub photo upload), with each event's RSVP list, Copy emails, Download CSV and the reminder (with a confirm step). Changes go in the change log and can be restored. - Seeds the first event (Negotiation Workshop with Emily Epstein, Nov 19, 6-7 PM, online), with the meeting link and flyer left for an admin to add. - Strings are in en.json for the other languages to fill in. - BOARD_SETUP.md explains how to launch and how to hide it again. Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 + BOARD_SETUP.md | 46 +++ app/[locale]/events/page.tsx | 50 +++ app/[locale]/layout.tsx | 3 +- app/[locale]/page.tsx | 3 + app/api/board/events/[id]/csv/route.ts | 24 ++ app/api/board/events/[id]/remind/route.ts | 35 ++ app/api/board/events/[id]/route.ts | 46 +++ app/api/board/events/route.ts | 37 ++ app/api/events/[id]/flyer/route.ts | 21 + app/api/events/[id]/ics/route.ts | 20 + app/api/events/next/route.ts | 8 + app/api/events/rsvp/route.ts | 79 ++++ app/board/board.css | 18 + app/redesign-overrides.css | 312 ++++++++++++++ app/sitemap.ts | 6 +- components/board/AnnouncementForm.tsx | 4 +- components/board/BoardApp.tsx | 9 +- components/board/Editors.tsx | 4 +- components/board/EventsTab.tsx | 428 ++++++++++++++++++++ components/layout/SiteHeader.tsx | 7 +- components/sections/events/EventsList.tsx | 289 +++++++++++++ components/sections/home/NextEventStrip.tsx | 51 +++ lib/board/changelog.ts | 1 + lib/board/ics.ts | 15 +- lib/board/invites.ts | 117 ++++-- lib/board/setup.ts | 28 ++ lib/board/types.ts | 4 +- lib/events/admin.ts | 98 +++++ lib/events/email.ts | 107 +++++ lib/events/flag.ts | 5 + lib/events/format.ts | 61 +++ lib/events/ics.ts | 55 +++ lib/events/server.ts | 144 +++++++ lib/events/types.ts | 55 +++ lib/i18n/dictionaries/en.json | 68 ++++ lib/i18n/dictionary.ts | 5 +- scripts/board-schema.sql | 39 ++ 38 files changed, 2245 insertions(+), 62 deletions(-) create mode 100644 app/[locale]/events/page.tsx create mode 100644 app/api/board/events/[id]/csv/route.ts create mode 100644 app/api/board/events/[id]/remind/route.ts create mode 100644 app/api/board/events/[id]/route.ts create mode 100644 app/api/board/events/route.ts create mode 100644 app/api/events/[id]/flyer/route.ts create mode 100644 app/api/events/[id]/ics/route.ts create mode 100644 app/api/events/next/route.ts create mode 100644 app/api/events/rsvp/route.ts create mode 100644 components/board/EventsTab.tsx create mode 100644 components/sections/events/EventsList.tsx create mode 100644 components/sections/home/NextEventStrip.tsx create mode 100644 lib/events/admin.ts create mode 100644 lib/events/email.ts create mode 100644 lib/events/flag.ts create mode 100644 lib/events/format.ts create mode 100644 lib/events/ics.ts create mode 100644 lib/events/server.ts create mode 100644 lib/events/types.ts diff --git a/.env.example b/.env.example index 09f7db9..b16a20c 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,8 @@ BOARD_SESSION_SECRET= BREVO_SMTP_LOGIN= BREVO_SMTP_KEY= BOARD_INVITE_FROM=board@getvindicated.org + +# Public Events page (/events). Leave unset until launch: the page is a 404 +# except for board admins, and there's no nav link, home strip or sitemap +# entry. Set to exactly "true" to launch. See BOARD_SETUP.md. +EVENTS_PUBLIC= diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index d779640..5511582 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -135,6 +135,52 @@ Use "Send me a test invite" in the form to check one before sending to the board. Send it to an address other than the organizer's: Gmail doesn't show RSVP buttons on an invite sent to its own organizer. +## Public events (/events) + +Admins add workshops and meetups on each hub's **Events** tab (admins only). +They show on the public Events page, where anyone can RSVP. Each event has: +- a title, tag, date and time (Pacific) +- online or in person, with a meeting link or an address +- a host and a description +- a flyer, uploaded like the hub photos +- an optional "Show RSVP count" + +An event belongs to the hub it's made on. Its emails come from that +chapter's sender (above), with reply-to set to the chapter's organizer. + +- **RSVPs:** each RSVP gets a confirmation email with the details, the + meeting link and a calendar file. RSVPing again with the same email + doesn't add a second RSVP; it just resends the confirmation. +- **The meeting link is private.** It's never on the page or in anything + the page loads. It only goes out in confirmations and reminders, so add it + before people RSVP, or send a reminder once it's in. +- **RSVP list:** each event's RSVPs (name, email, school, question, whether + they want future emails, time) are on the Events tab, with **Copy + emails**, **Download CSV** and **Send reminder to everyone**. The + reminder asks first, showing how many people it goes to. Nothing is sent + on a schedule; reminders only go out when an admin presses the button. +- **Change log:** adding, editing and deleting events goes in the Changes + tab, like everything else. Sending a reminder is noted there too. + +### Launching the page + +Until launch the page is private: +- `/events` shows the normal "page not found" to the public, and the RSVP + form refuses. +- An admin signed in to either hub can open the page to test it (RSVPs + included), with a "Private preview" banner. +- There's no "Events" link in the header, no next-event strip on the home + page, no sitemap entry, and the page tells search engines not to index it. + +To **launch**, add the Railway variable `EVENTS_PUBLIC` set to exactly +`true` (website service → **Variables**) and deploy. Railway offers to +redeploy when you save a variable; the header and home page only pick up +the change on that new deploy. This adds "Events" to the header (between +Team and Learning Resources) and the next-event strip to the home page. + +To **hide it again**, delete `EVENTS_PUBLIC` (or set it to anything but +`true`) and redeploy. Events and RSVPs stay saved on the hub. + ## One-time setup in Railway 1. **Database.** In the Railway project, add a **Postgres** service. In the diff --git a/app/[locale]/events/page.tsx b/app/[locale]/events/page.tsx new file mode 100644 index 0000000..11b81c7 --- /dev/null +++ b/app/[locale]/events/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; +import { PageHero } from "@/components/sections/shared/PageHero"; +import { EventsList, type ShownEvent } from "@/components/sections/events/EventsList"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import { getRouteMetadata } from "@/lib/i18n/metadata"; +import { isValidLocale, type Locale } from "@/lib/i18n/config"; +import { eventsAdmin, eventsPublic, listPublicEvents } from "@/lib/events/server"; +import { eventLabels } from "@/lib/events/format"; + +// Reads the board session cookie (admins can preview before launch), so it +// renders on every request. +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const meta = await getRouteMetadata(locale, "events", "/events"); + // Kept out of search engines until launch (see BOARD_SETUP.md). + return eventsPublic() ? meta : { ...meta, robots: { index: false, follow: false } }; +} + +export default async function EventsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!isValidLocale(locale)) notFound(); + const isPublic = eventsPublic(); + if (!isPublic && !eventsAdmin(await headers())) notFound(); + + const d = (await getDictionary(locale as Locale)).eventsPage; + const events: ShownEvent[] = (await listPublicEvents().catch((e) => { + console.error("[events] Couldn't load events:", e instanceof Error ? e.message : e); + return []; + })).map((e) => ({ ...e, ...eventLabels(e, locale) })); + + return ( +
+ {!isPublic && ( +
+ {d.preview} +
+ )} + +
+
+ +
+
+
+ ); +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 9f22d23..3f4f272 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { SiteFooter } from "@/components/layout/SiteFooter"; import { locales, isValidLocale, isRtl, type Locale } from "@/lib/i18n/config"; import { getDictionary } from "@/lib/i18n/get-dictionary"; import { getRootMetadata } from "@/lib/i18n/metadata"; +import { eventsPublic } from "@/lib/events/flag"; // import { CarCursor } from "@/components/ui/CarCursor"; const lora = Lora({ @@ -80,7 +81,7 @@ export default async function RootLayout({ /> {/* */} {/* */} - +
{children}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f95812a..e82a0b4 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -6,6 +6,8 @@ import { CampusChapters } from "@/components/sections/home/CampusChapters"; import { FollowLinks } from "@/components/sections/home/FollowLinks"; import { Pillars } from "@/components/sections/home/Pillars"; import { FreeResources } from "@/components/sections/home/FreeResources"; +import { NextEventStrip } from "@/components/sections/home/NextEventStrip"; +import { eventsPublic } from "@/lib/events/flag"; import { getDictionary } from "@/lib/i18n/get-dictionary"; import { SHARE_IMAGE } from "@/lib/i18n/metadata"; import { @@ -64,6 +66,7 @@ export default async function HomePage({ return (
+ {eventsPublic() && } }) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const id = idFrom((await ctx.params).id); + if (!id) return notFound(); + const sql = db(); + const [event] = await sql`SELECT title, date FROM board_events WHERE id = ${id} AND chapter = ${user.chapter}`; + if (!event) return notFound(); + const rows = await sql`SELECT * FROM board_event_rsvps WHERE event_id = ${id} AND chapter = ${user.chapter} ORDER BY created_at, id`; + const slug = String(event.title).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 50) || "event"; + return new Response(rsvpCsv(rows.map(toRsvp)), { + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="rsvps-${event.date}-${slug}.csv"`, + "cache-control": "no-store", + }, + }); +} diff --git a/app/api/board/events/[id]/remind/route.ts b/app/api/board/events/[id]/remind/route.ts new file mode 100644 index 0000000..74d5cac --- /dev/null +++ b/app/api/board/events/[id]/remind/route.ts @@ -0,0 +1,35 @@ +import { bad, guard, idFrom, notFound } from "@/lib/board/api"; +import { db } from "@/lib/board/db"; +import { actorOf, logEntry } from "@/lib/board/changelog"; +import { inviteSetupProblem } from "@/lib/board/invites"; +import { getFullEvent } from "@/lib/events/server"; +import { sendEventEmails } from "@/lib/events/email"; + +// "Send reminder to everyone" (admins): emails every RSVP the event details +// and meeting link again. Noted in the change log. +export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const id = idFrom((await ctx.params).id); + const event = id ? await getFullEvent(id) : null; + if (!event || event.chapter !== user.chapter) return notFound(); + const problem = inviteSetupProblem(user.chapter); + if (problem) return bad(problem, 503); + const rsvps = await db()`SELECT name, email FROM board_event_rsvps WHERE event_id = ${event.id} ORDER BY created_at, id`; + if (rsvps.length === 0) return bad("No one has RSVPed yet, so there's no one to remind."); + const r = await sendEventEmails( + event, + "reminder", + rsvps.map((x) => ({ name: x.name as string, email: x.email as string })), + ); + await logEntry( + db(), + user, + actorOf(request), + "note", + `Sent a reminder for "${event.title}" to ${r.sent.length} of ${rsvps.length} ${rsvps.length === 1 ? "person" : "people"}`, + [], + ); + if (r.sent.length === 0) return bad("The reminders didn't go through. Try again.", 502); + return Response.json({ ok: true, sent: r.sent.length, failed: r.failed }); +} diff --git a/app/api/board/events/[id]/route.ts b/app/api/board/events/[id]/route.ts new file mode 100644 index 0000000..3eb03b3 --- /dev/null +++ b/app/api/board/events/[id]/route.ts @@ -0,0 +1,46 @@ +import { bad, guard, idFrom, notFound, readJson } from "@/lib/board/api"; +import { db } from "@/lib/board/db"; +import { actorOf, logEntry, snapshot } from "@/lib/board/changelog"; +import { parseEvent } from "@/lib/events/admin"; + +type Ctx = { params: Promise<{ id: string }> }; + +// Edits one of this chapter's events (admins). Logged. +export async function PATCH(request: Request, ctx: Ctx) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const id = idFrom((await ctx.params).id); + if (!id) return notFound(); + const body = await readJson(request); + if (!body) return bad("Check the form."); + const cols = await parseEvent(body, user.chapter); + if ("error" in cols) return bad(cols.error); + const done = await db().begin(async (tx) => { + const before = await snapshot(tx, "board_events", user.chapter, id); + if (!before) return false; + await tx`UPDATE board_events SET ${tx(cols)} WHERE id = ${id} AND chapter = ${user.chapter}`; + const after = await snapshot(tx, "board_events", user.chapter, id); + await logEntry(tx, user, actorOf(request), "update", `Edited the event "${cols.title}"`, [{ table: "board_events", id, before, after }]); + return true; + }); + return done ? Response.json({ ok: true }) : notFound(); +} + +// Deletes an event (admins). Its RSVPs stay in the database, so restoring +// the event from the change log brings them back. +export async function DELETE(request: Request, ctx: Ctx) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const id = idFrom((await ctx.params).id); + if (!id) return notFound(); + const done = await db().begin(async (tx) => { + const before = await snapshot(tx, "board_events", user.chapter, id); + if (!before) return false; + await tx`DELETE FROM board_events WHERE id = ${id} AND chapter = ${user.chapter}`; + await logEntry(tx, user, actorOf(request), "delete", `Deleted the event "${before.title}"`, [ + { table: "board_events", id, before, after: null }, + ]); + return true; + }); + return done ? Response.json({ ok: true }) : notFound(); +} diff --git a/app/api/board/events/route.ts b/app/api/board/events/route.ts new file mode 100644 index 0000000..b48a5b7 --- /dev/null +++ b/app/api/board/events/route.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; +import { bad, guard, readJson } from "@/lib/board/api"; +import { db } from "@/lib/board/db"; +import { actorOf, logEntry, snapshot } from "@/lib/board/changelog"; +import { listAdminEvents } from "@/lib/events/server"; +import { parseEvent } from "@/lib/events/admin"; + +// The hub's Events tab (admins only): this chapter's public events with +// their RSVPs, and creating a new one. Every change goes in the change log. +export async function GET(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + return Response.json({ events: await listAdminEvents(user.chapter) }, { headers: { "cache-control": "no-store" } }); +} + +export async function POST(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const body = await readJson(request); + if (!body) return bad("Check the form."); + const cols = await parseEvent(body, user.chapter); + if ("error" in cols) return bad(cols.error); + const sql = db(); + const id = await sql.begin(async (tx) => { + const [row] = await tx`INSERT INTO board_events ${tx({ + ...cols, + chapter: user.chapter, + uid: `${user.chapter}-${randomUUID()}@getvindicated.org`, + })} RETURNING id`; + const after = await snapshot(tx, "board_events", user.chapter, row.id); + await logEntry(tx, user, actorOf(request), "create", `Added the event "${cols.title}"`, [ + { table: "board_events", id: row.id, before: null, after }, + ]); + return row.id as number; + }); + return Response.json({ ok: true, id }, { status: 201 }); +} diff --git a/app/api/events/[id]/flyer/route.ts b/app/api/events/[id]/flyer/route.ts new file mode 100644 index 0000000..e0fa58a --- /dev/null +++ b/app/api/events/[id]/flyer/route.ts @@ -0,0 +1,21 @@ +import { idFrom } from "@/lib/board/api"; +import { db } from "@/lib/board/db"; +import { canSeeEvents, eventsPublic } from "@/lib/events/server"; + +// An event's flyer, for the public page. Flyers are uploaded on the hub and +// stored with the hub photos; only a photo an event points at is served here. +export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) { + if (!canSeeEvents(request.headers) || !process.env.DATABASE_URL) return new Response("Not found", { status: 404 }); + const id = idFrom((await ctx.params).id); + if (!id) return new Response("Not found", { status: 404 }); + const [row] = await db()`SELECT p.content_type, p.data FROM board_events e + JOIN board_photos p ON p.id = e.flyer_id AND p.chapter = e.chapter WHERE e.id = ${id}`; + if (!row) return new Response("Not found", { status: 404 }); + return new Response(new Uint8Array(row.data as Buffer), { + headers: { + "content-type": row.content_type, + // Short, so a new flyer shows up soon after an admin changes it. + "cache-control": eventsPublic() ? "public, max-age=300" : "private, max-age=300", + }, + }); +} diff --git a/app/api/events/[id]/ics/route.ts b/app/api/events/[id]/ics/route.ts new file mode 100644 index 0000000..7c49f95 --- /dev/null +++ b/app/api/events/[id]/ics/route.ts @@ -0,0 +1,20 @@ +import { idFrom } from "@/lib/board/api"; +import { canSeeEvents, getFullEvent } from "@/lib/events/server"; +import { buildEventIcs } from "@/lib/events/ics"; + +// The "Download for Apple / Outlook" file. Anyone who can see the page can +// fetch it, so it never has the meeting link (that's in the confirmation +// email's copy). +export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) { + if (!canSeeEvents(request.headers)) return new Response("Not found", { status: 404 }); + const id = idFrom((await ctx.params).id); + const event = id ? await getFullEvent(id) : null; + if (!event) return new Response("Not found", { status: 404 }); + return new Response(buildEventIcs(event, null), { + headers: { + "content-type": "text/calendar; charset=utf-8", + "content-disposition": `attachment; filename="vindicated-event-${event.id}.ics"`, + "cache-control": "no-store", + }, + }); +} diff --git a/app/api/events/next/route.ts b/app/api/events/next/route.ts new file mode 100644 index 0000000..8b8caf8 --- /dev/null +++ b/app/api/events/next/route.ts @@ -0,0 +1,8 @@ +import { eventsPublic, nextPublicEvent } from "@/lib/events/server"; + +// The next event for the home page strip, once events are public. +export async function GET() { + if (!eventsPublic()) return new Response("Not found", { status: 404 }); + const event = await nextPublicEvent().catch(() => null); + return Response.json({ event }, { headers: { "cache-control": "public, max-age=60" } }); +} diff --git a/app/api/events/rsvp/route.ts b/app/api/events/rsvp/route.ts new file mode 100644 index 0000000..2344cec --- /dev/null +++ b/app/api/events/rsvp/route.ts @@ -0,0 +1,79 @@ +import { db } from "@/lib/board/db"; +import { idFrom, readJson } from "@/lib/board/api"; +import { isEmail } from "@/lib/board/submit-rules"; +import { inviteSetupProblem } from "@/lib/board/invites"; +import { canSeeEvents, getFullEvent } from "@/lib/events/server"; +import { sendEventEmails } from "@/lib/events/email"; +import { SCHOOL_LABELS, SCHOOLS, type School } from "@/lib/events/types"; + +// RSVPs from the public Events page. Before launch (EVENTS_PUBLIC isn't +// "true") only a board admin session gets through; everyone else gets the +// same 404 as the page. One RSVP per email per event: sending again just +// resends the confirmation. The reply never includes the meeting link. + +// 20 RSVPs per IP per 10 minutes. Kept in memory, which is enough for one +// Railway instance. +const WINDOW_MS = 10 * 60 * 1000; +const MAX = 20; +const recent = new Map(); + +function limited(request: Request) { + const ip = + request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown"; + const now = Date.now(); + const r = recent.get(ip); + if (!r || now - r.since > WINDOW_MS) { + recent.set(ip, { n: 1, since: now }); + return false; + } + r.n++; + return r.n > MAX; +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +export async function POST(request: Request) { + if (!canSeeEvents(request.headers)) return new Response("Not found", { status: 404 }); + if (limited(request)) return bad("Too many RSVPs from here. Try again in a few minutes.", 429); + const body = await readJson(request); + if (!body) return bad("Check the form."); + const str = (k: string, max: number) => (typeof body[k] === "string" ? (body[k] as string).trim().slice(0, max) : ""); + const id = idFrom(String(body.eventId ?? "")); + const name = str("name", 100); + const email = str("email", 254).toLowerCase(); + const school = str("school", 20) as School; + const question = str("question", 1000); + const optIn = body.optIn !== false; + if (!id) return bad("That event isn't here anymore.", 404); + if (!name) return bad("name", 422); + if (!isEmail(email)) return bad("email", 422); + if (!SCHOOLS.includes(school)) return bad("school", 422); + + const event = await getFullEvent(id); + if (!event) return bad("That event isn't here anymore.", 404); + if (event.past) return bad("closed", 409); + + const sql = db(); + const [created] = await sql`INSERT INTO board_event_rsvps ${sql({ + chapter: event.chapter, + event_id: id, + name, + email, + school: SCHOOL_LABELS[school], + question, + opt_in: optIn, + })} ON CONFLICT (event_id, lower(email)) DO NOTHING RETURNING id`; + // A repeat RSVP keeps the first one and gets the confirmation again. + const [rsvp] = created + ? [{ name }] + : await sql`SELECT name FROM board_event_rsvps WHERE event_id = ${id} AND lower(email) = ${email}`; + + let emailed = false; + const problem = inviteSetupProblem(event.chapter); + if (problem) console.error(`[events] RSVP confirmation not sent: ${problem}`); + else emailed = (await sendEventEmails(event, "confirm", [{ name: rsvp.name as string, email }])).sent.length > 0; + + const first = (rsvp.name as string).split(/\s+/)[0]; + const count = event.count === null ? null : event.count + (created ? 1 : 0); + return Response.json({ ok: true, repeat: !created, first, email, emailed, count }, { status: created ? 201 : 200 }); +} diff --git a/app/board/board.css b/app/board/board.css index 858155d..3695959 100644 --- a/app/board/board.css +++ b/app/board/board.css @@ -891,3 +891,21 @@ grid-template-columns: 1fr; } } + +/* Events tab (admins) */ +.board .event-form label { + align-content: start; +} +.board .event-item .rsvps { + margin-top: 10px; +} +.board .flyer-edit { + display: flex; + gap: 12px; + align-items: flex-start; +} +.board .flyer-edit img { + width: 160px; + height: auto; + border: 1px solid var(--rule); +} diff --git a/app/redesign-overrides.css b/app/redesign-overrides.css index 2a078f7..6c3a774 100644 --- a/app/redesign-overrides.css +++ b/app/redesign-overrides.css @@ -759,3 +759,315 @@ justify-self: start; } } + +/* ---------- Events ---------- */ +.rd .ev-preview { + background: var(--ink); + color: #fff; + text-align: center; + font-weight: 700; + padding: 12px 20px; +} +.rd .ev-toggle { + display: inline-flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + background: var(--tint); + margin-bottom: 32px; +} +.rd .ev-toggle button { + border: 0; + background: transparent; + border-radius: 999px; + padding: 10px 22px; + font: inherit; + font-weight: 700; + color: var(--purple-deep); + cursor: pointer; +} +.rd .ev-toggle button[aria-pressed="true"] { + background: var(--purple); + color: #fff; +} +.rd .ev-empty { + background: var(--tint); + border-radius: 22px; + padding: 32px; + font-weight: 600; +} +.rd .ev-list { + display: flex; + flex-direction: column; + gap: 28px; +} +.rd .ev-card { + display: grid; + grid-template-columns: minmax(0, 300px) minmax(0, 1fr); + gap: 36px; + align-items: start; + background: #fff; + border: 1.5px solid var(--line); + border-radius: 28px; + padding: 28px; + scroll-margin-top: 100px; +} +.rd .ev-card:target { + border-color: var(--purple); +} +.rd .ev-flyer-btn { + display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + cursor: zoom-in; + border-radius: 18px; +} +.rd .ev-flyer-btn img { + display: block; + width: 100%; + height: auto; + border-radius: 18px; +} +.rd .ev-datebox { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 76px; + height: 76px; + border-radius: 18px; + background: var(--purple); + color: #fff; + line-height: 1; +} +.rd .ev-datebox span { + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.rd .ev-datebox b { + font-size: 2rem; + font-weight: 800; + margin-top: 4px; +} +.rd .ev-datebox-lg { + width: 100%; + height: auto; + aspect-ratio: 4 / 5; + border-radius: 22px; + background: var(--tint); + color: var(--purple-deep); +} +.rd .ev-datebox-lg span { + font-size: 1.3rem; +} +.rd .ev-datebox-lg b { + font-size: 5.5rem; + color: var(--purple); +} +.rd .ev-body h2 { + font-size: clamp(1.6rem, 3vw, 2.2rem); + margin: 14px 0 18px; +} +.rd .ev-facts { + list-style: none; + padding: 0; + margin: 0 0 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.rd .ev-facts li { + display: flex; + flex-direction: column; + padding-inline-start: 14px; + border-inline-start: 3px solid var(--tint-2); + line-height: 1.45; +} +.rd .ev-facts span { + color: var(--purple-deep); + font-size: 0.95rem; +} +.rd .ev-desc { + font-size: 1.05rem; + margin-bottom: 18px; +} +.rd .ev-count { + font-weight: 700; + color: var(--purple); + margin-bottom: 18px; +} +.rd .ev-ended { + font-weight: 700; + color: var(--purple-deep); +} +.rd .ev-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + background: var(--tint); + border-radius: 22px; + padding: 22px; +} +.rd .ev-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 700; + font-size: 0.95rem; +} +.rd .ev-form .ev-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.rd .ev-form .ev-wide, +.rd .ev-form .ctas, +.rd .ev-form > .ev-err { + grid-column: 1 / -1; +} +.rd .ev-form input:not([type="checkbox"]), +.rd .ev-form select, +.rd .ev-form textarea { + font: inherit; + font-weight: 500; + color: var(--ink); + background: #fff; + border: 1.5px solid var(--line); + border-radius: 14px; + padding: 12px 14px; + width: 100%; +} +.rd .ev-form [aria-invalid="true"] { + border-color: var(--red); +} +.rd .ev-form textarea { + resize: vertical; +} +.rd .ev-form .ev-check { + flex-direction: row; + align-items: center; + gap: 10px; + font-weight: 600; +} +.rd .ev-form .ev-check input { + width: 20px; + height: 20px; + accent-color: var(--purple); +} +.rd .ev-err { + color: var(--red); + font-weight: 600; + font-size: 0.9rem; +} +.rd .ev-done { + background: var(--tint); + border-radius: 22px; + padding: 24px; +} +.rd .ev-done:focus { + outline: none; +} +.rd .ev-done h3 { + font-size: 1.6rem; + color: var(--purple); + margin-bottom: 8px; +} +.rd .ev-done .ctas { + margin-top: 18px; +} +.rd .ev-dialog { + /* The site's reset zeroes margins; auto centers the open dialog. */ + margin: auto; + border: 0; + padding: 0; + background: transparent; + max-width: min(92vw, 760px); + max-height: 92vh; +} +.rd .ev-dialog::backdrop { + background: rgba(42, 15, 49, 0.8); +} +.rd .ev-dialog img { + display: block; + max-width: 100%; + max-height: calc(92vh - 72px); + margin: 0 auto; + border-radius: 18px; +} +.rd .ev-dialog-close { + display: flex; + margin: 14px auto 0; +} +.rd .ev-strip-section { + padding: 0 0 24px; +} +.rd .ev-strip { + display: flex; + align-items: center; + gap: 20px; + background: var(--tint); + border-radius: 28px; + padding: 18px 22px; +} +.rd .ev-strip-text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + line-height: 1.4; +} +.rd .ev-strip-label { + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--purple); +} +.rd .ev-strip-title { + font-weight: 800; + font-size: 1.15rem; +} +.rd .ev-strip-title:hover { + text-decoration: underline; +} +.rd .ev-strip-meta { + color: var(--purple-deep); + font-weight: 600; +} +@media (max-width: 760px) { + .rd .ev-card { + grid-template-columns: 1fr; + gap: 22px; + padding: 20px; + border-radius: 24px; + } + .rd .ev-flyer { + max-width: 320px; + } + .rd .ev-datebox-lg { + aspect-ratio: auto; + flex-direction: row; + gap: 14px; + padding: 18px 22px; + justify-content: flex-start; + } + .rd .ev-datebox-lg b { + font-size: 2.6rem; + margin: 0; + } + .rd .ev-form { + grid-template-columns: 1fr; + padding: 18px; + } + .rd .ev-strip { + flex-wrap: wrap; + } + .rd .ev-strip-btn { + width: 100%; + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 37e6455..30d3293 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,5 +1,6 @@ import type { MetadataRoute } from "next"; import { locales, localizedPathnames, localizeHref } from "@/lib/i18n/config"; +import { eventsPublic } from "@/lib/events/flag"; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://getvindicated.org"; @@ -15,6 +16,7 @@ const routes = [ { href: "/map/methods", changeFrequency: "yearly", priority: 0.5 }, { href: "/research", changeFrequency: "monthly", priority: 0.6 }, { href: "/team", changeFrequency: "monthly", priority: 0.6 }, + { href: "/events", changeFrequency: "weekly", priority: 0.7 }, { href: "/join", changeFrequency: "monthly", priority: 0.6 }, { href: "/volunteer", changeFrequency: "monthly", priority: 0.6 }, { href: "/contact", changeFrequency: "yearly", priority: 0.5 }, @@ -34,7 +36,9 @@ function absoluteAlternates(href: string) { } export default function sitemap(): MetadataRoute.Sitemap { - return routes.flatMap((route) => + // Events is left out until it launches (EVENTS_PUBLIC). + const shown = routes.filter((r) => r.href !== "/events" || eventsPublic()); + return shown.flatMap((route) => locales.map((locale) => ({ url: absolute(localizeHref(locale, route.href)), changeFrequency: route.changeFrequency, diff --git a/components/board/AnnouncementForm.tsx b/components/board/AnnouncementForm.tsx index 6ceea17..8d3ded8 100644 --- a/components/board/AnnouncementForm.tsx +++ b/components/board/AnnouncementForm.tsx @@ -2,7 +2,7 @@ import { useId, useState } from "react"; import { isEmail } from "@/lib/board/submit-rules"; -import { TABS, type Announcement, type Tab } from "@/lib/board/types"; +import { ADMIN_TABS, TABS, type Announcement, type Tab } from "@/lib/board/types"; import { EVERYONE, inAudience, type Audience } from "@/lib/board/audience"; import { useBoard } from "./BoardApp"; import { api, apiCall, listNames } from "./util"; @@ -140,7 +140,7 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen Link to a tab set(i, { tab: e.target.value as QuickLink["tab"] })}> - {TABS.filter((t) => t !== "changes").map((t) => ( + {TABS.filter((t) => !ADMIN_TABS.includes(t)).map((t) => ( diff --git a/components/board/EventsTab.tsx b/components/board/EventsTab.tsx new file mode 100644 index 0000000..78f6e04 --- /dev/null +++ b/components/board/EventsTab.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { useCallback, useEffect, useId, useState, type FormEvent } from "react"; +import type { AdminEvent } from "@/lib/events/types"; +import { longDate, timeRange } from "@/lib/board/schedule"; +import { useBoard } from "./BoardApp"; +import { INVITE_TIMEOUT, apiCall, chapterUrl, photoSrc } from "./util"; + +// Admins only: the chapter's public events (the /events page) with their +// RSVPs. RSVP names and emails are shown here and nowhere public. + +type Form = { + title: string; + tag: string; + date: string; + startTime: string; + endTime: string; + online: boolean; + meetingLink: string; + address: string; + hostName: string; + hostTitle: string; + description: string; + flyerId: number | null; + showCount: boolean; +}; + +const blank = (tag: string): Form => ({ + title: "", + tag, + date: "", + startTime: "18:00", + endTime: "19:00", + online: true, + meetingLink: "", + address: "", + hostName: "", + hostTitle: "", + description: "", + flyerId: null, + showCount: false, +}); + +const toForm = (e: AdminEvent): Form => ({ + title: e.title, + tag: e.tag, + date: e.date, + startTime: e.startTime, + endTime: e.endTime, + online: e.online, + meetingLink: e.meetingLink, + address: e.address, + hostName: e.hostName, + hostTitle: e.hostTitle, + description: e.description, + flyerId: e.flyerId, + showCount: e.showCount, +}); + +const people = (n: number) => `${n} ${n === 1 ? "person" : "people"}`; + +export function EventsTab() { + const { chapter } = useBoard(); + const [events, setEvents] = useState(null); + const [loadError, setLoadError] = useState(""); + const [editing, setEditing] = useState(null); + + const load = useCallback(async () => { + const { error, data } = await apiCall("/api/board/events", "GET"); + if (error) return setLoadError(error); + setLoadError(""); + setEvents((data?.events as AdminEvent[]) ?? []); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const upcoming = events?.filter((e) => !e.past).reverse() ?? []; + const past = events?.filter((e) => e.past) ?? []; + + return ( +
+

Events

+

+ Public workshops and meetups on the Events page. Only admins see this tab. RSVP names and + emails stay here; the page only shows a count, and only for events with “Show RSVP count” on. Meeting + links are private: they only go out in RSVP confirmations and reminders. +

+ {editing === "new" ? ( + (setEditing(null), saved && load())} /> + ) : ( +
+ +
+ )} + {loadError && ( +

+ {loadError} +

+ )} + {events === null && !loadError &&

Loading…

} + {events && ( + <> +

Upcoming

+ {upcoming.length === 0 &&

No upcoming events.

} + {upcoming.map((e) => + editing === e.id ? ( + (setEditing(null), saved && load())} /> + ) : ( + setEditing(e.id)} onChanged={load} /> + ), + )} + {past.length > 0 &&

Past

} + {past.map((e) => + editing === e.id ? ( + (setEditing(null), saved && load())} /> + ) : ( + setEditing(e.id)} onChanged={load} /> + ), + )} + + )} +
+ ); +} + +function EventItem({ event: e, onEdit, onChanged }: { event: AdminEvent; onEdit: () => void; onChanged: () => void }) { + const [showRsvps, setShowRsvps] = useState(false); + const [confirm, setConfirm] = useState<"delete" | "remind" | null>(null); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + const n = e.rsvps.length; + + async function remove() { + setBusy(true); + const { error } = await apiCall(`/api/board/events/${e.id}`, "DELETE"); + setBusy(false); + setConfirm(null); + if (error) return setMsg({ kind: "err", text: error }); + onChanged(); + } + + async function remind() { + setBusy(true); + setMsg(null); + const { error, data } = await apiCall(`/api/board/events/${e.id}/remind`, "POST", {}, INVITE_TIMEOUT); + setBusy(false); + setConfirm(null); + if (error) return setMsg({ kind: "err", text: error }); + const failed = (data?.failed as string[]) ?? []; + setMsg({ + kind: failed.length ? "err" : "ok", + text: `Reminder sent to ${people(Number(data?.sent ?? 0))}.${failed.length ? ` It didn't reach ${failed.join(", ")}.` : ""}`, + }); + } + + async function copyEmails() { + const list = e.rsvps.map((r) => r.email).join(", "); + try { + await navigator.clipboard.writeText(list); + setMsg({ kind: "ok", text: `Copied ${n === 1 ? "1 email" : `${n} emails`}.` }); + } catch { + setMsg({ kind: "err", text: "Your browser didn't allow copying. Use Download CSV instead." }); + } + } + + return ( +
+ {e.title} +
+ {longDate(e.date)} · {timeRange(e.startTime, e.endTime)} (Pacific) · {e.online ? "Online" : e.address} + {e.tag && ` · ${e.tag}`} +
+
+ {people(n)} RSVPed{e.showCount ? " · count shown on the page" : ""} + {e.online && !e.meetingLink && !e.past && " · meeting link to add"} + {!e.flyerId && " · no flyer yet"} +
+
+ + + +
+ {confirm === "delete" && ( +
+ Delete “{e.title}”? It comes off the Events page. You can restore it from the Changes tab. + + +
+ )} + {showRsvps && ( +
+ {n === 0 ? ( +

No RSVPs yet.

+ ) : ( +
+ + + + + + + + + + + + + {e.rsvps.map((r) => ( + + + + + + + + + ))} + +
NameEmailSchoolQuestionUpdatesRSVPed
{r.name}{r.email}{r.school}{r.question}{r.optIn ? "Yes" : "No"} + {new Date(r.at).toLocaleString("en-US", { + timeZone: "America/Los_Angeles", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} +
+
+ )} + {n > 0 && ( +
+ + + Download CSV + + {!e.past && ( + + )} +
+ )} + {confirm === "remind" && ( +
+ Email a reminder with the {e.online ? "meeting link" : "address"} to {people(n)}? + + +
+ )} + {e.online && !e.meetingLink && confirm === "remind" && ( +

This event has no meeting link yet. Add it (Edit) before sending, or the reminder won't have it.

+ )} +
+ )} + {msg && ( +

+ {msg.text} +

+ )} +
+ ); +} + +function EventForm({ id: eventId, initial, chapterTitle, onDone }: { id?: number; initial: Form; chapterTitle: string; onDone: (saved: boolean) => void }) { + const [f, setF] = useState
(initial); + const [busy, setBusy] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const id = useId(); + const set = (k: K, v: Form[K]) => setF((x) => ({ ...x, [k]: v })); + + async function upload(file: File | undefined) { + if (!file) return; + setUploading(true); + setError(""); + const form = new FormData(); + form.append("file", file); + const { error: err, data } = await apiCall("/api/board/photos", "POST", form); + setUploading(false); + const photo = typeof data?.src === "string" ? Number(data.src.split("/").pop()) : NaN; + if (err || !Number.isInteger(photo)) return setError(err ?? "The upload didn't work."); + set("flyerId", photo); + } + + async function save(ev: FormEvent) { + ev.preventDefault(); + setBusy(true); + setError(""); + const { error: err } = await apiCall(eventId ? `/api/board/events/${eventId}` : "/api/board/events", eventId ? "PATCH" : "POST", f); + setBusy(false); + if (err) return setError(err); + onDone(true); + } + + return ( +
+

{eventId ? "Edit the event" : "Add an event"}

+ + +
+ + +
+
+ +
+ + +
+
+
+ Where + + +
+ {f.online ? ( + + ) : ( + + )} +
+ + +
+