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 ? ( + + ) : ( + + )} +
+ + +
+