diff --git a/public/images/membership/member-panel.webp b/public/images/membership/member-panel.webp
new file mode 100644
index 00000000..a7d593b2
Binary files /dev/null and b/public/images/membership/member-panel.webp differ
diff --git a/src/components/MembershipPage.tsx b/src/components/MembershipPage.tsx
deleted file mode 100644
index e8711ae5..00000000
--- a/src/components/MembershipPage.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Box, Typography } from "@mui/material";
-import LegacyJoinSection from "./membership/LegacyJoinSection";
-import LegacyWaiverNote from "./membership/LegacyWaiverNote";
-
-const bodySx = { mb: 2, fontSize: "1.125rem", lineHeight: 1.75, color: "#374151" } as const;
-const proseSx = { ...bodySx, maxWidth: 700 } as const;
-
-function handleJoinClick(ctaLocation: "primary_cta" | "secondary_cta") {
- return () => {
- // Set the sticky flag before dispatching, for consistency with the
- // vanilla-script CTA on /supporting-member (see supporting-member.astro).
- // This page's CTA lives inside the same island as the listener, so the
- // flag isn't strictly needed here to avoid a race, but keeping the same
- // event/flag contract on both pages avoids surprises if that ever changes.
- window.__membershipRevealJoin = true;
- window.dispatchEvent(new CustomEvent("membership:reveal-join"));
- if (typeof window.plausible !== "undefined") {
- window.plausible("Membership Join Click", {
- props: { cta_location: ctaLocation, destination: "#join" },
- });
- }
- };
-}
-
-export default function MembershipPage() {
- return (
-
- {/* Supporting Member fork */}
-
-
- Don't have time to contribute directly?
-
-
- Become a Supporting Member →
-
-
-
-
- Members and Supporting Members also support T4P financially. Membership dues allow T4P to
- support our teams, and provide grants and services to projects in the Incubator through
- full-time dedicated staff. Dues are pay-what-you-can, and we suggest a monthly amount equal
- to 1 hour of income (1/2000th of your annual income).
-
-
- Whether you're a thinker, builder, leader, software developer, marketer, or activist,
- there's a place for you to contribute in your own way.
-
-
- {/* CTAs */}
-
-
- Become a member
-
-
- Become a Supporting Member
-
-
-
-
-
-
-
-
-
- Become a member
-
-
-
- );
-}
diff --git a/src/components/membership/AboutYouStep.tsx b/src/components/membership/AboutYouStep.tsx
index 9c759279..609ea859 100644
--- a/src/components/membership/AboutYouStep.tsx
+++ b/src/components/membership/AboutYouStep.tsx
@@ -1,10 +1,8 @@
import { useState, type FormEvent } from "react";
import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
+import { validateAboutYou, type AboutYouData } from "./aboutYou";
-export interface AboutYouData {
- name: string;
- email: string;
-}
+export type { AboutYouData };
interface AboutYouStepProps {
onContinue: (data: AboutYouData) => void;
@@ -25,8 +23,6 @@ interface AboutYouStepProps {
submitting?: boolean;
}
-const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
/**
* Step 1 of the membership join flow: collects name/email before the visitor
* is handed to the Qgiv payment step. Validates on submit only, matching the
@@ -49,10 +45,10 @@ export default function AboutYouStep({
const trimmedName = name.trim();
const trimmedEmail = email.trim();
- const nextNameError = trimmedName.length === 0 ? "Please enter your name." : "";
- const nextEmailError = EMAIL_PATTERN.test(trimmedEmail)
- ? ""
- : "Please enter a valid email address.";
+ const { nameError: nextNameError, emailError: nextEmailError } = validateAboutYou(
+ trimmedName,
+ trimmedEmail,
+ );
setNameError(nextNameError);
setEmailError(nextEmailError);
diff --git a/src/components/membership/JoinFlow.tsx b/src/components/membership/JoinFlow.tsx
new file mode 100644
index 00000000..3ff4818f
--- /dev/null
+++ b/src/components/membership/JoinFlow.tsx
@@ -0,0 +1,362 @@
+import { useState, type FormEvent } from "react";
+import MembershipCalculator from "./MembershipCalculator";
+import QgivJoin from "./QgivJoin";
+import { validateAboutYou, type AboutYouData } from "./aboutYou";
+import { membershipBenefits } from "../../data/membershipBenefits";
+import type { MembershipTier, QgivPrefill } from "./qgiv";
+
+const CALENDLY_URL = "https://calendly.com/d/ctpm-sw2-yvc/t4p-intro-call";
+
+type StepId = "about-you" | "tier" | "payment";
+
+const STEPS: { id: StepId; label: string }[] = [
+ { id: "about-you", label: "About you" },
+ { id: "tier", label: "Membership selection" },
+ { id: "payment", label: "Payment" },
+];
+
+/** How long the "Continue" button shows its own spinner before the next step
+ * is revealed. Without this floor the swap is instant and the button's
+ * loading state never becomes visible. Mirrors QgivJoin's own
+ * MIN_LOADING_DISPLAY_MS floor. */
+const NEXT_BUTTON_LOADING_MS = 400;
+
+const MEMBER_BENEFITS = membershipBenefits.map((b) => b.label);
+const SUPPORTING_BENEFITS = membershipBenefits.filter((b) => b.supporting).map((b) => b.label);
+
+interface JoinFlowProps {
+ /** Uses the design system's ts-* typography scale (Fraunces/Outfit) instead
+ * of plain Tailwind sizes — only correct where `design-system.css` is
+ * loaded (HomeLayout, i.e. /membership-new). The legacy /membership page
+ * uses the default plain-Tailwind rendering, since that CSS file isn't
+ * available there. */
+ designSystem?: boolean;
+}
+
+interface StyleSet {
+ stepLabel: string;
+ heading: string;
+ fieldLabel: string;
+ input: string;
+ button: string;
+ helper: string;
+ link: string;
+ tierIntro: string;
+ tierTitle: string;
+ tierDescription: string;
+ tierBenefit: string;
+ tierNote: string;
+ backLink: string;
+}
+
+function getStyles(designSystem: boolean): StyleSet {
+ if (designSystem) {
+ return {
+ stepLabel: "ts-overline text-[#157A3E]",
+ heading: "ts-heading text-ink",
+ fieldLabel: "ts-label text-ink",
+ input: "ts-body text-ink",
+ button: "ts-label rounded-pill",
+ helper: "ts-body-small text-ink-secondary",
+ link: "ts-body-small font-semibold text-[#157A3E]",
+ tierIntro: "ts-body text-ink-secondary",
+ tierTitle: "ts-body-large font-semibold text-ink",
+ tierDescription: "ts-body-small text-ink-secondary",
+ tierBenefit: "ts-body-small text-ink-secondary",
+ tierNote: "ts-caption text-ink-secondary",
+ backLink: "ts-body-small font-semibold text-ink-secondary hover:text-ink",
+ };
+ }
+ return {
+ stepLabel: "text-[11px] font-bold uppercase tracking-[0.12em] text-[#157A3E]",
+ heading: "text-2xl font-extrabold text-ink",
+ fieldLabel: "text-sm font-semibold text-ink",
+ input: "text-[15px] text-ink",
+ button: "text-[15px] font-bold rounded-full",
+ helper: "text-[13px] leading-relaxed text-ink-secondary",
+ link: "text-[13px] font-bold text-[#157A3E]",
+ tierIntro: "text-[15px] leading-relaxed text-ink-secondary",
+ tierTitle: "text-base font-bold text-ink",
+ tierDescription: "text-[15px] leading-relaxed text-ink-secondary",
+ tierBenefit: "text-[15px] text-ink-secondary",
+ tierNote: "text-[13px] leading-relaxed text-ink-secondary",
+ backLink: "text-sm font-semibold text-ink-secondary hover:text-ink",
+ };
+}
+
+function splitName(name: string): { firstName: string; lastName: string } {
+ const [firstName, ...rest] = name.trim().split(/\s+/);
+ return { firstName: firstName ?? "", lastName: rest.join(" ") };
+}
+
+interface AboutYouFormProps {
+ initialValues: AboutYouData | null;
+ submitting: boolean;
+ styles: StyleSet;
+ onContinue: (data: AboutYouData) => void;
+}
+
+function AboutYouForm({ initialValues, submitting, styles, onContinue }: AboutYouFormProps) {
+ const [name, setName] = useState(initialValues?.name ?? "");
+ const [email, setEmail] = useState(initialValues?.email ?? "");
+ const [nameError, setNameError] = useState("");
+ const [emailError, setEmailError] = useState("");
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ const trimmedName = name.trim();
+ const trimmedEmail = email.trim();
+ const { nameError: nextNameError, emailError: nextEmailError } = validateAboutYou(
+ trimmedName,
+ trimmedEmail,
+ );
+ setNameError(nextNameError);
+ setEmailError(nextEmailError);
+ if (nextNameError || nextEmailError) return;
+ onContinue({ name: trimmedName, email: trimmedEmail });
+ }
+
+ return (
+
+ );
+}
+
+interface TierCardProps {
+ tier: MembershipTier;
+ title: string;
+ description: string;
+ benefits: string[];
+ selected: boolean;
+ styles: StyleSet;
+ onSelect: () => void;
+}
+
+function TierCard({ tier, title, description, benefits, selected, styles, onSelect }: TierCardProps) {
+ return (
+
+ );
+}
+
+export default function JoinFlow({ designSystem = false }: JoinFlowProps) {
+ const styles = getStyles(designSystem);
+ const [step, setStep] = useState("about-you");
+ const [aboutYou, setAboutYou] = useState(null);
+ const [tier, setTier] = useState(null);
+ const [mountedTiers, setMountedTiers] = useState([]);
+ const [advancing, setAdvancing] = useState(false);
+
+ const stepIndex = STEPS.findIndex((s) => s.id === step);
+
+ function handleAboutYouContinue(data: AboutYouData) {
+ setAboutYou(data);
+ setAdvancing(true);
+ window.setTimeout(() => {
+ setStep("tier");
+ setAdvancing(false);
+ }, NEXT_BUTTON_LOADING_MS);
+ }
+
+ function handleTierContinue() {
+ if (!tier) return;
+ setAdvancing(true);
+ setMountedTiers((prev) => (prev.includes(tier) ? prev : [...prev, tier]));
+ window.setTimeout(() => {
+ setStep("payment");
+ setAdvancing(false);
+ }, NEXT_BUTTON_LOADING_MS);
+ }
+
+ const prefill: QgivPrefill | undefined = aboutYou
+ ? { ...splitName(aboutYou.name), email: aboutYou.email }
+ : undefined;
+
+ return (
+
+
+ Step {stepIndex + 1} of {STEPS.length}: {STEPS[stepIndex]?.label}
+
+
+ {step === "about-you" && (
+
+ )}
+
+ {step === "tier" && (
+
+
+
+ Please select your preferred membership tier. You can change this later.
+
+
+ setTier("member")}
+ />
+ setTier("supporting")}
+ />
+
+
+
+ You'll be redirected to our secure payment partner to complete your membership.
+
+
+ )}
+
+ {step === "payment" && tier && (
+
+
+
+
+
+ {mountedTiers.map((mountedTier) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/membership/LegacyJoinSection.tsx b/src/components/membership/LegacyJoinSection.tsx
index 4fa69b56..de51e188 100644
--- a/src/components/membership/LegacyJoinSection.tsx
+++ b/src/components/membership/LegacyJoinSection.tsx
@@ -14,8 +14,8 @@ interface LegacyJoinSectionProps {
/** Hides the "Become a member" CTA anchors once the join form is revealed —
* clicking them again is pointless once the form is already showing. These
- * anchors live outside this component's tree (siblings in MembershipPage.tsx,
- * or plain HTML in supporting-member.astro), so a DOM query is the simplest
+ * anchors live outside this component's tree (plain HTML in
+ * supporting-member.astro), so a DOM query is the simplest
* way to reach all of them uniformly. The reveal is one-directional (there is
* no "un-reveal" path), so there is no corresponding un-hide routine. */
function hideJoinCtas(): void {
diff --git a/src/components/membership/MembershipCalculator.tsx b/src/components/membership/MembershipCalculator.tsx
index 88908d5b..4d20180e 100644
--- a/src/components/membership/MembershipCalculator.tsx
+++ b/src/components/membership/MembershipCalculator.tsx
@@ -15,7 +15,63 @@ const CURRENCIES = [
{ code: "ZAR", symbol: "R", name: "South African Rand", usdRate: 0.055 },
];
-export default function MembershipCalculator() {
+interface MembershipCalculatorProps {
+ /** Accent theme for the border and result figures. `"brand"` (default) matches
+ * the design-system rose brand color used by `MembershipDues`; `"green"` matches
+ * the membership join flow's green accent (`JoinFlow`), which is deliberately
+ * distinct from the site's rose `brand` token. */
+ theme?: "brand" | "green";
+ /** Uses the design system's ts-* typography scale instead of plain Tailwind
+ * sizes — only correct where `design-system.css` is loaded. See the same
+ * prop on `JoinFlow`. */
+ designSystem?: boolean;
+}
+
+const THEME_CLASSES: Record<"brand" | "green", { border: string; text: string; ring: string; focusBorder: string }> = {
+ brand: {
+ border: "border-brand",
+ text: "text-brand",
+ ring: "focus:ring-brand",
+ focusBorder: "focus:border-brand",
+ },
+ green: {
+ border: "border-[#157A3E]",
+ text: "text-[#157A3E]",
+ ring: "focus:ring-[#157A3E]",
+ focusBorder: "focus:border-[#157A3E]",
+ },
+};
+
+export default function MembershipCalculator({
+ theme = "brand",
+ designSystem = false,
+}: MembershipCalculatorProps) {
+ const accent = THEME_CLASSES[theme];
+ const labelClass = designSystem ? "ts-label text-ink" : "text-sm font-medium text-ink";
+ const overlineClass = designSystem
+ ? "ts-overline text-ink-secondary"
+ : "text-xs font-medium uppercase tracking-[0.12em] text-ink-secondary";
+ const resultLabelClass = designSystem ? "ts-body-small text-ink-secondary" : "text-sm text-ink-secondary";
+ // font-serif (Fraunces) is a design-system-only choice — plain mode uses a
+ // bold sans figure instead of pulling in the redesign's editorial font.
+ const resultFigureClass = designSystem ? "ts-stat" : "text-3xl font-bold";
+ const resultSubClass = designSystem ? "ts-caption text-ink-muted" : "text-sm text-ink-muted";
+ const emptyStateClass = designSystem ? "ts-body-small text-ink-muted" : "text-sm text-ink-muted";
+ // `bg-page`/`bg-sand`/`border-butter`/`rounded-[20px]` are the design
+ // system's own surface tokens and corner radius — correct on
+ // /membership-new, but they read as the redesign bleeding into the legacy
+ // /membership page. Plain mode instead reuses the exact devices JoinFlow
+ // already established one step earlier in the same flow: a plain white
+ // card (like every other card on the page) and the same green tint
+ // TierCard uses for "this one's selected/active" — one consistent signal,
+ // not a third surface color.
+ const shellRadius = designSystem ? "rounded-[20px]" : "rounded-lg";
+ const shellBg = designSystem ? "bg-page" : "bg-white";
+ const controlBorder = designSystem ? "border-butter" : "border-ink-divider";
+ const controlBg = designSystem ? "bg-sand" : "bg-white";
+ const tileRadius = designSystem ? "rounded-[12px]" : "rounded-lg";
+ const tileBg = designSystem ? "bg-sand" : "bg-[#F0F7F2]";
+ const toggleActiveBg = designSystem ? "bg-ink" : "bg-[#157A3E]";
const [incomeType, setIncomeType] = useState<"annual" | "monthly">("monthly");
const [income, setIncome] = useState("");
const [currency, setCurrency] = useState("USD");
@@ -33,18 +89,15 @@ export default function MembershipCalculator() {
const suggestedAnnual = Math.round(suggestedMonthly * 12 * 100) / 100;
return (
-
+
{/* Header */}
-
Calculate your suggested dues
+
Calculate your suggested dues
{/* Inputs — single row on desktop */}
{/* Currency */}
-