From 5879ae376332a0f71c716a6d1d65043917056b92 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Thu, 10 Sep 2026 11:38:56 +0300 Subject: [PATCH 1/9] feat(membership): redesign /membership and /membership-new, add 3-step join flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two-step join wizard with a 3-step flow (About You → Membership Selection → Payment) so visitors pick Member vs Supporting Member inline instead of being routed to a separate page, and adopts a new hero layout with an always-visible sticky signup card. - New JoinFlow.tsx drives the 3-step wizard; keeps both tiers' QgivJoin embeds mounted so switching tiers never re-injects Qgiv's embed.js. - New MembershipMain.astro is shared by both pages, rendering with the design system's ts-* typography/tokens on /membership-new and plain Tailwind on the legacy /membership (Layout.astro doesn't load design-system.css). - Reworded three membershipBenefits.ts labels to match the new tier card copy (also updates the /supporting-member comparison table). - QgivJoin re-scans via QGIV.Embed.initializeEmbeds() so a second tier's embed mounts correctly after switching tiers mid-flow. - Removed the now-unused MembershipPage.tsx. Co-Authored-By: Claude Sonnet 5 --- src/components/MembershipPage.tsx | 98 ---- src/components/membership/AboutYouStep.tsx | 16 +- src/components/membership/JoinFlow.tsx | 362 +++++++++++++++ .../membership/LegacyJoinSection.tsx | 4 +- .../membership/MembershipCalculator.tsx | 72 ++- .../membership/MembershipMain.astro | 430 ++++++++++++++++++ src/components/membership/QgivJoin.tsx | 15 +- src/components/membership/aboutYou.ts | 24 + src/data/membershipBenefits.ts | 6 +- src/env.d.ts | 10 + src/pages/membership-new.astro | 202 +------- src/pages/membership.astro | 44 +- 12 files changed, 911 insertions(+), 372 deletions(-) delete mode 100644 src/components/MembershipPage.tsx create mode 100644 src/components/membership/JoinFlow.tsx create mode 100644 src/components/membership/MembershipMain.astro create mode 100644 src/components/membership/aboutYou.ts 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..fbe46896 --- /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 tracking-tight text-ink", + fieldLabel: "text-sm font-semibold text-ink", + input: "text-sm text-ink", + button: "text-[15px] font-bold rounded-full", + helper: "text-xs leading-relaxed text-ink-secondary", + link: "text-[13px] font-bold text-[#157A3E]", + tierIntro: "text-sm leading-relaxed text-ink-secondary", + tierTitle: "text-base font-bold text-ink", + tierDescription: "text-sm leading-relaxed text-ink-secondary", + tierBenefit: "text-sm text-ink-secondary", + tierNote: "text-xs 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..c8e30766 100644 --- a/src/components/membership/MembershipCalculator.tsx +++ b/src/components/membership/MembershipCalculator.tsx @@ -15,7 +15,46 @@ 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"; + const resultFigureClass = designSystem ? "ts-stat" : "font-serif text-3xl"; + 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"; const [incomeType, setIncomeType] = useState<"annual" | "monthly">("monthly"); const [income, setIncome] = useState(""); const [currency, setCurrency] = useState("USD"); @@ -33,18 +72,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 */}
-
@@ -427,4 +455,27 @@ const cx = designSystem } }); }); + + const COPY_CONFIRM_MS = 1500; + + // mailto: links silently do nothing when the browser has no default mail + // client registered — copying the address is a fallback that always gives + // the visitor something, without blocking the native mailto navigation for + // visitors who do have one. + document.querySelectorAll("[data-copy-email]").forEach((anchor) => { + const confirm = anchor.parentElement?.querySelector("[data-copy-confirm]"); + anchor.addEventListener("click", () => { + const email = anchor.getAttribute("data-copy-email"); + if (!email || !confirm) return; + navigator.clipboard + .writeText(email) + .then(() => { + confirm.hidden = false; + window.setTimeout(() => { + confirm.hidden = true; + }, COPY_CONFIRM_MS); + }) + .catch(() => {}); + }); + }); From 4d4ee55f08bad03386c6853fc16c80bd5cf4da65 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Thu, 10 Sep 2026 14:42:29 +0300 Subject: [PATCH 3/9] feat(membership): add floating "Become a member" CTA once the hero scrolls past MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join card is only sticky within the hero section's own grid row — once a visitor scrolls past it, there's no persistent way back to the form until the next inline "Join now" CTA further down the page. Adds a small floating pill (bottom-right on desktop, bottom bar on mobile) that appears via IntersectionObserver once #join leaves the viewport and scrolls back to the real form on click, rather than opening a second one. Reuses the existing green pill button styling already established on the page instead of introducing a new visual treatment. Co-Authored-By: Claude Sonnet 5 --- .../membership/MembershipMain.astro | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/membership/MembershipMain.astro b/src/components/membership/MembershipMain.astro index efe83066..49edbfd7 100644 --- a/src/components/membership/MembershipMain.astro +++ b/src/components/membership/MembershipMain.astro @@ -405,6 +405,16 @@ const cx = designSystem
+ +