Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ node_modules/
dist/
.wrangler/
.dev.vars*
.env.local
.env.*.local
.vscode/
**.env
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ Images and other local assets belong beside the Markdown that uses them. Past-te

The attached classroom image used on the home and About pages is `public/assets/images/about-classroom.png`. Sponsor marks and student portraits are also local files under `public/assets/images/`.

## Mailing list

The signup forms submit to the same-origin `POST /api/subscribe` Worker endpoint. The Worker validates the request, verifies the Turnstile token, and idempotently stores a normalized email in the D1 `subscribers` table. It does not expose a subscriber list or send bulk email. Successful signups receive a signed `/unsubscribe` link.

One-time Cloudflare setup:

```sh
npx wrangler d1 create simc-mailing-list
# Copy the returned database_id into wrangler.jsonc.
npx wrangler d1 migrations apply simc-mailing-list --remote
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put UNSUBSCRIBE_SECRET
```

Set `VITE_TURNSTILE_SITE_KEY` as a public build-time variable in the Cloudflare Workers Build settings. For local development, put the matching local/test sitekey in the ignored `.env.local` file and set `TURNSTILE_SECRET` plus `UNSUBSCRIBE_SECRET` in the ignored `.dev.vars` file. Then run:

```sh
npm run build
npx wrangler d1 migrations apply simc-mailing-list --local
npx wrangler dev
```

Run the remote migration command before deploying any future migration. The current Cloudflare Workers Build command builds and deploys the Worker, but does not apply D1 migrations automatically.

## Deployment

Cloudflare Workers Builds watches the production `main` branch and runs `npm run build` followed by `npx wrangler deploy` on every push.
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
<meta name="description" content="Seattle Infinity Math Circle" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://challenges.cloudflare.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;600&family=Lora:wght@600;700&family=Source+Serif+4:wght@600&display=swap" rel="stylesheet" />
<link rel="icon" href="/int.svg" type="image/svg+xml" />
<title>Seattle Infinity Math Circle</title>
</head>
<body>
<div id="root"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" defer></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
7 changes: 7 additions & 0 deletions migrations/0001_create_subscribers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS subscribers (
id TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
subscribed INTEGER NOT NULL DEFAULT 1 CHECK (subscribed IN (0, 1)),
unsubscribe_token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
167 changes: 167 additions & 0 deletions src/components/mailing-list-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type RefObject } from 'react';

const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY?.trim();

type SubscriptionStatus = 'idle' | 'submitting' | 'success' | 'error';

export interface MailingListSubscriptionState {
email: string;
onEmailChange: (value: string) => void;
status: SubscriptionStatus;
message: string;
unsubscribeUrl: string | null;
isSubmitting: boolean;
turnstileContainerRef: RefObject<HTMLDivElement | null>;
submit: (event: FormEvent<HTMLFormElement>) => Promise<void>;
}

function useTurnstile(siteKey: string | undefined) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [token, setToken] = useState('');

useEffect(() => {
if (!siteKey) return undefined;

let cancelled = false;
let pollId: number | undefined;
let timeoutId: number | undefined;

const renderWidget = () => {
const turnstile = window.turnstile;
if (cancelled || widgetIdRef.current !== null || !containerRef.current || !turnstile) {
return widgetIdRef.current !== null;
}

widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
appearance: 'interaction-only',
callback: (nextToken) => {
if (!cancelled) setToken(nextToken);
},
'expired-callback': () => {
if (!cancelled) setToken('');
},
'error-callback': () => {
if (!cancelled) setToken('');
},
});
return true;
};

if (!renderWidget()) {
pollId = window.setInterval(() => {
if (renderWidget() && pollId !== undefined) window.clearInterval(pollId);
}, 50);
timeoutId = window.setTimeout(() => {
if (pollId !== undefined) window.clearInterval(pollId);
}, 10000);
}

return () => {
cancelled = true;
if (pollId !== undefined) window.clearInterval(pollId);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
if (widgetIdRef.current !== null && window.turnstile) window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
};
}, [siteKey]);

const reset = useCallback(() => {
setToken('');
if (widgetIdRef.current !== null && window.turnstile) window.turnstile.reset(widgetIdRef.current);
}, []);

return { containerRef, token, reset };
}

export function useMailingListSubscription(): MailingListSubscriptionState {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<SubscriptionStatus>('idle');
const [message, setMessage] = useState('');
const [unsubscribeUrl, setUnsubscribeUrl] = useState<string | null>(null);
const turnstile = useTurnstile(TURNSTILE_SITE_KEY);

const onEmailChange = useCallback((value: string) => {
setEmail(value);
setStatus('idle');
setMessage('');
setUnsubscribeUrl(null);
}, []);

const submit = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (status === 'submitting') return;

const form = event.currentTarget;
if (!form.reportValidity()) return;

if (!TURNSTILE_SITE_KEY) {
setStatus('error');
setMessage('The mailing list is not configured yet.');
return;
}

if (!turnstile.token) {
setStatus('error');
setMessage('Please complete the security check and try again.');
return;
}

setStatus('submitting');
setMessage('');
setUnsubscribeUrl(null);

try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: email.trim().toLowerCase(), turnstileToken: turnstile.token }),
});
const result = await response.json().catch(() => null);

if (!response.ok || result?.ok !== true) {
if (response.status === 503) throw new Error('The mailing list is temporarily unavailable. Please try again later.');
throw new Error('We could not add you to the mailing list. Please try again.');
}

setEmail('');
setStatus('success');
setMessage("You're on the list.");
setUnsubscribeUrl(typeof result.unsubscribeUrl === 'string' ? result.unsubscribeUrl : null);
} catch (error) {
setStatus('error');
setMessage(error instanceof Error ? error.message : 'We could not add you to the mailing list. Please try again.');
} finally {
turnstile.reset();
}
}, [email, status, turnstile.reset, turnstile.token]);

return {
email,
onEmailChange,
status,
message,
unsubscribeUrl,
isSubmitting: status === 'submitting',
turnstileContainerRef: turnstile.containerRef,
submit,
};
}

export function MailingListFeedback({
state,
statusId,
}: {
state: MailingListSubscriptionState;
statusId: string;
}) {
if (!state.message) return null;

return (
<p id={statusId} className={`mailing-list-status mailing-list-status-${state.status}`} role={state.status === 'error' ? 'alert' : 'status'}>
{state.message}
{state.unsubscribeUrl && <>{' '}<a href={state.unsubscribeUrl}>Unsubscribe</a></>}
</p>
);
}
21 changes: 13 additions & 8 deletions src/components/site-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { MailingListFeedback, useMailingListSubscription } from './mailing-list-form';
import { DISCORD_URL, INSTAGRAM_URL, SPONSORS } from '../data/site';

type ButtonTone = 'primary' | 'light' | 'outline';
Expand All @@ -11,13 +12,14 @@ export interface ButtonProps {
external?: boolean;
type?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
form?: string;
disabled?: boolean;
}

export function Button({ href, children, tone = 'primary', external = false, type = 'button', form }: ButtonProps) {
export function Button({ href, children, tone = 'primary', external = false, type = 'button', form, disabled = false }: ButtonProps) {
const className = `button button-${tone}`;
if (external) return <a className={className} href={href} target="_blank" rel="noreferrer">{children}</a>;
if (href) return <Link className={className} to={href}>{children}</Link>;
return <button className={className} type={type} form={form}>{children}</button>;
return <button className={className} type={type} form={form} disabled={disabled}>{children}</button>;
}

function Logo({ full = false, inverse = false }: { full?: boolean; inverse?: boolean }) {
Expand Down Expand Up @@ -45,20 +47,23 @@ function Header() {
}

export function SignupBanner() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const subscription = useMailingListSubscription();
return (
<section className="signup-banner" aria-label="Join the SIMC community">
<div className="signup-copy">
<h2>Stay in the loop.</h2>
<p>Sign up for our mailing list so you don't miss out!</p>
</div>
<div className="signup-spacer" aria-hidden="true" />
<form id="banner-email-form" className="signup-form" onSubmit={(event) => { event.preventDefault(); if (email.trim()) setSubmitted(true); }}>
<form id="banner-email-form" className="signup-form" onSubmit={subscription.submit}>
<label className="sr-only" htmlFor="banner-email">Email address</label>
<input id="banner-email" type="email" value={email} onChange={(event) => { setEmail(event.target.value); setSubmitted(false); }} placeholder="your@email.com" required />
<input id="banner-email" type="email" value={subscription.email} onChange={(event) => subscription.onEmailChange(event.target.value)} placeholder="your@email.com" aria-describedby="banner-email-status" disabled={subscription.isSubmitting} required />
<div ref={subscription.turnstileContainerRef} className="turnstile-container" />
<MailingListFeedback state={subscription} statusId="banner-email-status" />
</form>
<button className="button button-primary" type="submit" form="banner-email-form">{submitted ? 'You\'ve been added!' : 'Join mailing list'}</button>
<button className="button button-primary" type="submit" form="banner-email-form" disabled={subscription.isSubmitting}>
{subscription.isSubmitting ? 'Joining...' : subscription.status === 'success' ? 'You\'ve been added!' : 'Join mailing list'}
</button>
<Button href={DISCORD_URL} external tone="outline">Join our Discord</Button>
</section>
);
Expand Down
1 change: 0 additions & 1 deletion src/data/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { EVENT_CONTENT, type ContentRecord } from '../content';

export const EMAIL = 'seattleinfinitymathcircle@gmail.com';
export const DISCORD_URL = 'https://discord.gg/wwyZnWB2tw';
export const MAILING_LIST_URL = 'https://forms.gle/FDvWGo1FHqQSGkNK6';
export const INSTAGRAM_URL = 'https://www.instagram.com/seattleinfinitymathcircle/';
export const CALENDAR_EMBED_URL = 'https://calendar.google.com/calendar/embed?height=600&wkst=1&bgcolor=%23ffffff&ctz=America%2FLos_Angeles&showTitle=1&showCalendars=1&mode=AGENDA&src=YTkxNzhhNzU4ZGRjYjhmM2FjM2ZmNGQ1MWQ2OGNiNTcwNjdmMDUyODljZTc4YmUyNDliMDM0MWJhNmQyYzY3MUBncm91cC5jYWxlbmRhci5nb29nbGUuY29t&color=%23C0CA33';

Expand Down
17 changes: 9 additions & 8 deletions src/pages/static-pages.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { PersonCard } from '../components/content-card';
import { MailingListFeedback, useMailingListSubscription } from '../components/mailing-list-form';
import { MarkdownBody } from '../components/markdown-content';
import { Intro } from '../components/page-primitives';
import { Button, SignupBanner } from '../components/site-shell';
import { PAGE_CONTENT_BY_SLUG, type ContentRecord } from '../content';
import { CALENDAR_EMBED_URL, DISCORD_URL, EMAIL, MAILING_LIST_URL, PEOPLE } from '../data/site';
import { CALENDAR_EMBED_URL, DISCORD_URL, EMAIL, PEOPLE } from '../data/site';

interface MarkdownPageProps {
record?: ContentRecord;
Expand Down Expand Up @@ -45,17 +45,18 @@ export function PotmPage() {
}

function ContactMailingForm() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const subscription = useMailingListSubscription();
return (
<form className="contact-option" onSubmit={(event) => { event.preventDefault(); if (email.trim()) setSubmitted(true); }}>
<form id="contact-email-form" className="contact-option" onSubmit={subscription.submit}>
<h2>Join our mailing list</h2>
<p><a href={MAILING_LIST_URL} target="_blank" rel="noreferrer">Sign up for our mailing list</a> so you don't miss out on any of our fun events!</p>
<p>Sign up for our mailing list so you don't miss out on any of our fun events!</p>
<div className="contact-form-row">
<label className="sr-only" htmlFor="contact-email">Email address</label>
<input id="contact-email" type="email" placeholder="your@email.com" value={email} onChange={(event) => { setEmail(event.target.value); setSubmitted(false); }} required />
<Button type="submit">{submitted ? 'You are in' : 'Join mailing list'}</Button>
<input id="contact-email" type="email" placeholder="your@email.com" value={subscription.email} onChange={(event) => subscription.onEmailChange(event.target.value)} aria-describedby="contact-email-status" disabled={subscription.isSubmitting} required />
<Button type="submit" disabled={subscription.isSubmitting}>{subscription.isSubmitting ? 'Joining...' : subscription.status === 'success' ? 'You are in' : 'Join mailing list'}</Button>
</div>
<div ref={subscription.turnstileContainerRef} className="turnstile-container" />
<MailingListFeedback state={subscription} statusId="contact-email-status" />
</form>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/press-releases/newsletters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ blurb: See past and current SIMC Newsletters

# Newsletters

Join our [mailing list](https://forms.gle/FDvWGo1FHqQSGkNK6) to stay updated on our events!
Join our [mailing list](/contact) to stay updated on our events!

## 2023-2024 Newsletters
- [**January**](http://eepurl.com/iHasYA) http://eepurl.com/iHasYA
Expand Down
12 changes: 10 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si
.button-outline { border-color: var(--accent); background: var(--surface); color: var(--on-surface); }
.button-primary:hover { background: var(--accent-soft); color: var(--on-surface); }
.button-light:hover, .button-outline:hover { background: var(--accent-soft); }
.button:disabled { cursor: wait; opacity: .7; transform: none; }
.main-nav .button { width: 140px; min-width: 140px; }

.source-image { display: block; object-fit: cover; }
Expand Down Expand Up @@ -195,13 +196,19 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si
.results-year-rule span { flex: 0 0 auto; color: var(--award-gold); font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-size: 20px; font-weight: 600; line-height: 26px; white-space: nowrap; }
.results-awards-grid { display: flex; width: 100%; height: calc(var(--results-rows) * 49px); flex-wrap: wrap; align-content: center; align-items: center; justify-content: center; column-gap: 40px; overflow: hidden; padding: 0 20px; }

.signup-banner { display: flex; width: 100%; height: 132px; align-items: center; gap: 20px; padding: 24px 80px; background: var(--accent-soft); }
.signup-banner { display: flex; width: 100%; min-height: 132px; height: auto; align-items: center; gap: 20px; padding: 24px 80px; background: var(--accent-soft); }
.signup-copy { display: flex; height: 84px; flex-direction: column; justify-content: center; gap: 6px; color: var(--text); }
.signup-copy h2 { font-family: 'Source Serif 4', Georgia, serif; font-size: 36px; font-weight: 600; line-height: normal; white-space: nowrap; }
.signup-copy p { line-height: 21px; white-space: nowrap; }
.signup-spacer { width: auto; min-width: 1px; flex: 1 1 0; }
.signup-form { width: 424px; flex: 0 0 424px; }
.signup-form { display: flex; width: 424px; flex: 0 0 424px; flex-direction: column; gap: 6px; }
.signup-banner > .button { width: 205px; min-width: 205px; }
.turnstile-container { width: 100%; min-height: 0; }
.turnstile-container:empty { display: none; }
.mailing-list-status { margin: 0; color: var(--muted); font-size: 13px; line-height: 18px; }
.mailing-list-status a { color: inherit; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
.mailing-list-status-error { color: #a33b32; }
:root[data-theme='dark'] .mailing-list-status-error { color: #ffb4ab; }

.page-intro { display: flex; width: 100%; height: 243px; flex-direction: column; gap: 20px; padding: 60px 80px; background: var(--accent); color: var(--on-accent); }
.page-intro h1 { color: var(--on-accent); }
Expand Down Expand Up @@ -302,6 +309,7 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si
.contact-form-row { display: grid; width: 100%; grid-template-columns: 340px 205px; gap: 15px; margin-top: auto; }

input { width: 100%; height: 48px; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); color: var(--input-text); outline: none; }
input:disabled { cursor: wait; opacity: .75; }
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }

.about-hero { display: grid; width: 100%; height: 426px; grid-template-columns: 550px minmax(0, 1fr); gap: 48px; padding: 80px; background: var(--accent); }
Expand Down
Loading