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 ( +
+

Become a Member

+ + + setName(e.target.value)} + placeholder="Your full name" + autoComplete="name" + disabled={submitting} + className={`mb-1 w-full rounded-lg border border-ink-divider bg-white px-3.5 py-3 focus:border-[#157A3E] focus:outline-none focus:ring-1 focus:ring-[#157A3E] disabled:opacity-60 ${styles.input}`} + /> + {nameError &&

{nameError}

} + {!nameError &&
} + + + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + disabled={submitting} + className={`mb-1 w-full rounded-lg border border-ink-divider bg-white px-3.5 py-3 focus:border-[#157A3E] focus:outline-none focus:ring-1 focus:ring-[#157A3E] disabled:opacity-60 ${styles.input}`} + /> + {emailError &&

{emailError}

} + {!emailError &&
} + + + +

+ Dues are pay-what-you-can · Waivers available · Tax deductible in the US +

+ + Talk to membership first → + + + ); +} + +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 */}
-