diff --git a/astro.config.mjs b/astro.config.mjs index f59c9690..26fbd2c1 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -76,6 +76,7 @@ export default defineConfig({ "/js/web.js/", "/admin/conversions/", "/membership-new/", + "/supporting-member-new/", "/volunteer-new/", "/e4p-new/", "/help/hire-new/", diff --git a/docs/API.md b/docs/API.md index 706d46ce..1a72cbd2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,22 +2,22 @@ All routes live under `src/pages/api/` and run server-side only (`export const prerender = false` where set). See [ARCHITECTURE.md](ARCHITECTURE.md) for the middleware that wraps every response (`cache-control` forces `no-store` on all `/api/*` routes regardless of what a route sets) and [SECURITY.md](SECURITY.md) for the shared validation/CORS rules these routes follow. -| Route | Method | Auth / origin check | Upstream | Notes | -| ----------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/api/events` | GET | none (public read) | ICS feed (`fetchEvents`, `EVENTS_ICS_URL`) | Flat array; consumers group into category sections via `eventSections.ts` | -| `/api/faq` | GET | none | Notion (`fetchNotionFAQ`) | `?showAll=yes` bypasses Visibility filter | -| `/api/ideas` | GET | none | Notion (`fetchNotionIdeas`) | | -| `/api/speakers` | GET | none | Notion (`fetchNotionAgenda`) | Returns `{ agendaItems, speakers }`; resolves moderator relations to speaker profiles | -| `/api/e4p-signatories` | GET | none (public read) | Notion (Signatories DB) | Only returns rows where `Approved` is checked | -| `/api/e4p-pledge-sign` | POST | `Origin: https://techforpalestine.org` | Notion (Signatories DB) | Validates required fields, email format, LinkedIn URL, 2000-char cap; writes with `Approved: false` (manual moderation) | -| `/api/endorsement-request` | POST | `Origin: https://techforpalestine.org` | Notion (Endorsements DB) | Same validation pattern as pledge-sign | -| `/api/donation-complete` | POST + OPTIONS | Origin allowlist (prod domain + preview `*.website-aun.pages.dev`) | EmailOctopus | Tags contact `donor`; called by the QGIV donation success callback | -| `/api/membership-complete` | POST + OPTIONS | Origin allowlist (same as above) | Hub API (`HUB_API_URL`) + EmailOctopus | Invites the member to the Hub (`type: "paid"`) and tags them `member` in EmailOctopus, in parallel via `Promise.allSettled` | -| `/api/pipe` | POST | Origin allowlist (prod + `*.pages.dev`) | Plausible Analytics | Server-side proxy for the Plausible events endpoint (avoids ad-blockers hitting `plausible.io` directly from the browser); on a dropped conversion event it also writes a fallback record to the `DROPPED_CONVERSIONS` KV namespace — see [DONATIONS.md](DONATIONS.md) | -| `/api/sentry-webhook` | POST | HMAC signature (`sentry-hook-signature` header, `SENTRY_WEBHOOK_SECRET`) + rejects if an `Origin` header is present (webhooks shouldn't have one) | Mattermost | Relays Sentry issue/alert webhooks into a Mattermost channel | -| `/api/projects` | GET | none (public read) | ProjectHub (`projecthub.techforpalestine.org/api/public/projects`) | Sanitizes all URL-shaped fields to strip non-http(s) protocols (XSS guard); retries on 5xx with backoff | -| `/api/project-proxy` | GET, POST | none at this layer — adds `Authorization` server-side | Generic upstream (`PUBLIC_API_URL`) | Generic authenticated passthrough used by the volunteer/incubator application forms (`src/store/api.ts`); only forwards paths under `/api/method/`, explicit header allowlist — see [SECURITY.md](SECURITY.md) for the pattern | -| `/api/admin/conversion-stats` | GET | HTTP Basic Auth (`ADMIN_USERNAME`/`ADMIN_PASSWORD`, constant-time compare) | Plausible Stats API v2 + `DROPPED_CONVERSIONS` KV | Backs the `/admin/conversions` dashboard; merges live Plausible numbers with the KV fallback log — see [DONATIONS.md](DONATIONS.md) | +| Route | Method | Auth / origin check | Upstream | Notes | +| ----------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/api/events` | GET | none (public read) | ICS feed (`fetchEvents`, `EVENTS_ICS_URL`) | Flat array; consumers group into category sections via `eventSections.ts` | +| `/api/faq` | GET | none | Notion (`fetchNotionFAQ`) | `?showAll=yes` bypasses Visibility filter | +| `/api/ideas` | GET | none | Notion (`fetchNotionIdeas`) | | +| `/api/speakers` | GET | none | Notion (`fetchNotionAgenda`) | Returns `{ agendaItems, speakers }`; resolves moderator relations to speaker profiles | +| `/api/e4p-signatories` | GET | none (public read) | Notion (Signatories DB) | Only returns rows where `Approved` is checked | +| `/api/e4p-pledge-sign` | POST | `Origin: https://techforpalestine.org` | Notion (Signatories DB) | Validates required fields, email format, LinkedIn URL, 2000-char cap; writes with `Approved: false` (manual moderation) | +| `/api/endorsement-request` | POST | `Origin: https://techforpalestine.org` | Notion (Endorsements DB) | Same validation pattern as pledge-sign | +| `/api/donation-complete` | POST + OPTIONS | Origin allowlist (prod domain + preview `*.website-aun.pages.dev`) | EmailOctopus | Tags contact `donor`; called by the QGIV donation success callback | +| `/api/membership-complete` | POST + OPTIONS | Origin allowlist (same as above) | Hub API (`HUB_API_URL`) + EmailOctopus | Tags the contact in EmailOctopus and (for full members) invites them to the Hub (`type: "paid"`), in parallel via `Promise.allSettled`. Accepts an optional `tier` field: `"member"` (default) → tag `member` + Hub invite; `"supporting"` → tag `Supporting Member`, **no** Hub invite. The value is resolved against a server-side allowlist, so an unrecognised tier falls back to `member`. | +| `/api/pipe` | POST | Origin allowlist (prod + `*.pages.dev`) | Plausible Analytics | Server-side proxy for the Plausible events endpoint (avoids ad-blockers hitting `plausible.io` directly from the browser); on a dropped conversion event it also writes a fallback record to the `DROPPED_CONVERSIONS` KV namespace — see [DONATIONS.md](DONATIONS.md) | +| `/api/sentry-webhook` | POST | HMAC signature (`sentry-hook-signature` header, `SENTRY_WEBHOOK_SECRET`) + rejects if an `Origin` header is present (webhooks shouldn't have one) | Mattermost | Relays Sentry issue/alert webhooks into a Mattermost channel | +| `/api/projects` | GET | none (public read) | ProjectHub (`projecthub.techforpalestine.org/api/public/projects`) | Sanitizes all URL-shaped fields to strip non-http(s) protocols (XSS guard); retries on 5xx with backoff | +| `/api/project-proxy` | GET, POST | none at this layer — adds `Authorization` server-side | Generic upstream (`PUBLIC_API_URL`) | Generic authenticated passthrough used by the volunteer/incubator application forms (`src/store/api.ts`); only forwards paths under `/api/method/`, explicit header allowlist — see [SECURITY.md](SECURITY.md) for the pattern | +| `/api/admin/conversion-stats` | GET | HTTP Basic Auth (`ADMIN_USERNAME`/`ADMIN_PASSWORD`, constant-time compare) | Plausible Stats API v2 + `DROPPED_CONVERSIONS` KV | Backs the `/admin/conversions` dashboard; merges live Plausible numbers with the KV fallback log — see [DONATIONS.md](DONATIONS.md) | ## Conventions to follow for new routes diff --git a/docs/superpowers/specs/2026-08-12-membership-about-you-step-design.md b/docs/superpowers/specs/2026-08-12-membership-about-you-step-design.md new file mode 100644 index 00000000..a36b746d --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-membership-about-you-step-design.md @@ -0,0 +1,124 @@ +# Two-step "About You" gate for /membership and /supporting-member + +## Problem + +`/membership` and `/supporting-member` currently drop visitors straight into an +embedded Qgiv payment form (`LegacyJoinSection` → `QgivJoin`) with no +site-owned entry step. The team wants a lightweight "Step 1/2 – About You" +screen (name + email) in front of the existing payment step, matching this +wireframe: + +``` +Step 1/2 - About You + +Name: [____________] +Email: [__________] + +You'll be redirected to our secure payment partner to complete your membership. + +[Next button] +``` + +## Non-goals + +- No change to when EmailOctopus tagging happens. `/api/membership-complete` + keeps firing only after Qgiv's `donationComplete` event, exactly as today. + Step 1's name/email is never sent to our backend. +- No change to the Qgiv iframe embed model — payment stays embedded on-page, + it is not a full navigation to a hosted Qgiv URL. +- No change to the MembershipCalculator A/B test, waiver panel, or Hub-invite + logic. +- No persistence of Step 1 data across a page reload (sessionStorage/ + localStorage). Low stakes if a visitor has to re-enter name/email after a + refresh; not worth the extra state management. +- No analytics event for this flow (explicitly out of scope for this pass). + +## Design + +### Where it lives + +Both `/membership` (via `MembershipPage.tsx`) and `/supporting-member.astro` +already render `LegacyJoinSection` directly. The step gate is added *inside* +`LegacyJoinSection.tsx` via a new piece of state: + +```ts +const [step, setStep] = useState<"about-you" | "payment">("about-you"); +const [aboutYou, setAboutYou] = useState<{ name: string; email: string } | null>(null); +``` + +Both call sites get the new flow automatically — no changes needed at either +page or at `MembershipPage.tsx`. + +### New component: `src/components/membership/AboutYouStep.tsx` + +Props: + +```ts +interface AboutYouStepProps { + onContinue: (data: { name: string; email: string }) => void; +} +``` + +Behavior: + +- Two MUI `TextField`s: **Name** (single input, matching the wireframe) and + **Email**. Uses local `useState`, not react-hook-form — two fields don't + justify pulling in the `hook-form` scaffolding used elsewhere in the repo. +- Copy, top to bottom: "Step 1/2 – About You" heading, the two fields, the + line "You'll be redirected to our secure payment partner to complete your + membership.", then a **Next** button. +- Validation runs on submit (not per-keystroke): + - Name: required, non-empty after trim. + - Email: required, matches `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` — the same regex + `/api/membership-complete.ts` already validates against, so client and + server agree on "valid email" without sharing code across the client/ + server boundary. + - Invalid fields show inline error text beneath them; `onContinue` is not + called until both pass. +- Visual style follows the existing `LegacyJoinSection` conventions (MUI + `Box`/`Typography`, same color tokens: `#111827` headings, `#374151` body, + `#168039` brand green for the button/focus ring). + +### Step 2 (existing payment section), lightly extended + +- Renders today's calculator/waiver/`QgivJoin` layout unchanged, plus: + - A "Step 2/2" label above it, for continuity with Step 1. + - A "← Edit your info" link/button that sets `step` back to `"about-you"`. + `aboutYou` state is preserved (not cleared), so the fields are still + filled in when the visitor goes back. + +### Qgiv pre-fill + +Qgiv supports pre-filling hosted/embedded form fields via a documented URL +suffix: `/v/first_name=...,last_name=...,email=...` (confirmed against +Qgiv's public API docs). We use this so a visitor doesn't retype what they +just gave us in Step 1. + +- `qgiv.ts`: `qgivEmbedUrl(form, prefill?)` gains an optional third-ish + argument (or an options object) and appends the `/v/...` suffix, URL- + component-encoded, when `prefill` is provided. +- `QgivJoin.tsx`: gains an optional prop + `prefill?: { firstName: string; lastName: string; email: string }`, passed + through to `qgivEmbedUrl`. +- `LegacyJoinSection.tsx`: once `step === "payment"`, splits `aboutYou.name` + on the first space into `firstName`/`lastName` (best-effort — a single-word + name yields an empty `lastName`) and passes that plus `aboutYou.email` as + `QgivJoin`'s `prefill` prop. +- This only affects Qgiv pre-fill convenience. It has no effect on + validation, EmailOctopus data, or the Hub invite — those still come + entirely from whatever Qgiv reports back on `donationComplete`. +- Implementation note: Qgiv's `/v/` prefill syntax is documented for their + hosted form URLs; it hasn't been verified yet against the specific + `/embed//` iframe src path this repo uses. Verify against a live Qgiv + embed during implementation before relying on it, and fail open (render + the embed without pre-fill) if the suffix doesn't take effect. + +## Files touched + +- `src/components/membership/AboutYouStep.tsx` (new) +- `src/components/membership/LegacyJoinSection.tsx` (step state, wiring) +- `src/components/membership/QgivJoin.tsx` (optional `prefill` prop) +- `src/components/membership/qgiv.ts` (`qgivEmbedUrl` prefill support) + +No API routes, EmailOctopus config, or page-level files (`membership.astro`, +`supporting-member.astro`, `MembershipPage.tsx`) need to change. diff --git a/public/_redirects b/public/_redirects index 98fb181a..7261ed5d 100644 --- a/public/_redirects +++ b/public/_redirects @@ -71,4 +71,11 @@ # /community-call reverted to the old design; new-design page removed. /community-call-old /community-call 301 -/community-call-old/ /community-call 301 \ No newline at end of file +/community-call-old/ /community-call 301 + +# -new redesign shelved (see above) also covers the membership pages built +# after that decision landed; /supporting-member-new never launched. +/supporting-member-new /supporting-member 301 +/supporting-member-new/ /supporting-member 301 +/supporting-members /supporting-member 301 +/supporting-members/ /supporting-member 301 diff --git a/public/images/membership/members-group.webp b/public/images/membership/members-group.webp new file mode 100644 index 00000000..b2f05cc0 Binary files /dev/null and b/public/images/membership/members-group.webp differ diff --git a/public/images/membership/supporting-panel.webp b/public/images/membership/supporting-panel.webp new file mode 100644 index 00000000..49110169 Binary files /dev/null and b/public/images/membership/supporting-panel.webp differ diff --git a/src/components/MembershipCalculator.tsx b/src/components/MembershipCalculator.tsx index 1d1a0cdb..21134d1f 100644 --- a/src/components/MembershipCalculator.tsx +++ b/src/components/MembershipCalculator.tsx @@ -30,7 +30,7 @@ const rowSx = { display: "flex", alignItems: "center", gap: 1.5, - mb: 1, + mb: 1.5, }; const labelSx = { @@ -67,10 +67,11 @@ export default function MembershipCalculator() { sx={{ maxWidth: 600, mx: "auto", - borderRadius: 2, + borderRadius: 3, border: "2px solid #168039", backgroundColor: "white", - p: 1.5, + boxShadow: "0 4px 14px -6px rgba(17, 107, 47, 0.2)", + p: 2, mb: 4, }} > @@ -149,10 +150,10 @@ export default function MembershipCalculator() { sx={{ display: "flex", gap: 1, - mt: 0.5, - p: 1.5, + mt: 1, + p: 2, backgroundColor: "#f0fdf4", - borderRadius: 1.5, + borderRadius: 2, border: "2px solid #168039", }} > diff --git a/src/components/MembershipFAQ.tsx b/src/components/MembershipFAQ.tsx deleted file mode 100644 index c4839944..00000000 --- a/src/components/MembershipFAQ.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useState } from "react"; -import { Box, Typography, Accordion, AccordionSummary, AccordionDetails } from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; - -const faqData = [ - { - question: "How does membership support Palestinian liberation?", - answer: ( - - Membership brings together like-minded members and activists to lead, co-lead or support - projects and committees. Members can support the movement by contributing financially or by - getting involved directly as a working committee leader or member. - - ), - }, - { - question: "How do I get involved after signup?", - answer: ( - <> - - We will send you an email about the ways to get involved as a member: - - -
  • - Get T4P support on leveling up personal and corporate boycotts, using ethical - alternatives, and learning how to activate against tech complicity. -
  • -
  • Bring T4P resources into your companies for hiring and policy support.
  • -
  • - Meet with us one-on-one to see how your skills and interests can support the mission -
  • -
  • - Join working committees of fellow advocates taking direct action for Palestinian - liberation. -
  • -
  • Attend meetups and webinars to connect and learn from other activists.
  • -
  • Stay up-to-date by reading and sharing movement updates
  • -
    - - ), - }, - { - question: "Why are there membership dues?", - answer: ( - <> - - Dues are pay-what-you-can and help us progress towards our goal of 10,000 projects for - Palestine by supporting project work itself - grants for advocacy projects, access to - expert trainings, mentorship from startup founders, and other developmental opportunities - to help projects scale - and by supporting the infrastructure that lets us get work done, - including software and employees. - - - Tech for Palestine aims for inclusivity. If you are unable to access banking services, - please reach out to{" "} - - membership@techforpalestine.org - {" "} - to request a waiver of dues. - - - ), - }, - { - question: "Are dues tax deductible? Can I apply gift aid?", - answer: ( - - Yes, if you are in the US your dues are tax deductible. If you are in the UK, contact us at{" "} - - membership@techforpalestine.org - {" "} - after signing up, and we will ensure future donations are processed through our gift aid - partner. - - ), - }, - { - question: - "Can I pay annually or via a different payment method (DAF, cryptocurrency, foundation, etc.)?", - answer: ( - - These options will be supported in the future, and we will help you migrate to your - preferred method of giving once available. - - ), - }, -]; - -export default function MembershipFAQ() { - const [expanded, setExpanded] = useState(false); - - const handleChange = (panel: string) => (_event: React.SyntheticEvent, isExpanded: boolean) => { - setExpanded(isExpanded ? panel : false); - }; - - return ( - - - Membership FAQ - - - - {faqData.map((faq, index) => ( - - } - sx={{ - "& .MuiAccordionSummary-content": { - my: 2, - }, - }} - > - - {faq.question} - - - {faq.answer} - - ))} - - - {/* Contact */} - - - For additional questions about membership, please email{" "} - - membership@techforpalestine.org - - . - - - - {/* Community Image */} - - Tech for Palestine community event - - - ); -} diff --git a/src/components/MembershipPage.tsx b/src/components/MembershipPage.tsx index 1968d5dd..002cec92 100644 --- a/src/components/MembershipPage.tsx +++ b/src/components/MembershipPage.tsx @@ -1,331 +1,68 @@ -import { useEffect, useRef, useState } from "react"; -import { Box, Typography, Card, CardContent } from "@mui/material"; -import MembershipCalculator from "./MembershipCalculator"; +import { Box, Typography } from "@mui/material"; +import LegacyJoinSection from "./membership/LegacyJoinSection"; +import LegacyWaiverNote from "./membership/LegacyWaiverNote"; -const teams = [ - { - name: "Coaching for Palestinian Entrepreneurs", - description: "Helping Palestinian entrepreneurs build job skills for professional growth.", - }, - { - name: "Tech Accountability", - description: - "Running campaigns that hold tech companies accountable for supporting war crimes.", - }, - { - name: "Legal Aid", - description: - "Providing pro-bono legal aid to advocacy projects & cases of discrimination against Palestinians or pro-Palestine speech.", - }, - { - name: "Research Team", - description: - "Supporting research across T4P projects and initiatives, including topics such as Big Tech & tech complicity, social media suppression & content moderation, AI, digital tools employed for surveillance, and more.", - }, - { - name: "T4P Hackathons", - description: "Kickstarting software solutions to movement problems.", - }, - { - name: "Palestine Datasets", - description: - "Maintaining open datasets documenting the human toll of the genocide, so that journalists, researchers, artists, and advocates can tell the story.", - }, -]; - -function QgivEmbed() { - const scriptLoadedRef = useRef(false); - - useEffect(() => { - if (scriptLoadedRef.current) return; - scriptLoadedRef.current = true; - - const script = document.createElement("script"); - script.src = "https://secure.qgiv.com/resources/core/js/embed.js"; - script.id = "qgiv-embedjs"; - script.async = true; - document.body.appendChild(script); - }, []); - - return ( - -
    - - ); -} +const bodySx = { mb: 2, fontSize: "1.125rem", lineHeight: 1.75, color: "#374151" } as const; +const proseSx = { ...bodySx, maxWidth: 700 } as const; export default function MembershipPage() { - const [showCalculator] = useState(() => { - const urlParams = new URLSearchParams(window.location.search); - const urlParam = urlParams.get("calculator"); - if (urlParam === "yes") return true; - if (urlParam === "no") return false; - - const stored = localStorage.getItem("membership_ab_variant"); - if (stored === "Calculator") return true; - if (stored === "No Calculator") return false; - - const assigned = Math.random() < 0.5; - localStorage.setItem("membership_ab_variant", assigned ? "Calculator" : "No Calculator"); - return assigned; - }); - - useEffect(() => { - if (typeof window.plausible !== "undefined") { - window.plausible("Membership Page", { - props: { membership_variant: showCalculator ? "Calculator" : "No Calculator" }, - }); - } - }, [showCalculator]); - - useEffect(() => { - const variant = showCalculator ? "Calculator" : "No Calculator"; - - function handleDonationComplete(event: Event) { - const detail = (event as CustomEvent).detail ?? {}; - const transaction = detail?.QGIV?.transaction ?? {}; - - if (typeof window.plausible !== "undefined") { - window.plausible("Membership-complete", { - props: { - amount: transaction.total != null ? String(transaction.total) : "", - membership_variant: variant, - }, - }); - } - - const contact = detail?.QGIV?.contact ?? {}; - const email = contact.email ?? ""; - - if (email) { - fetch("/api/membership-complete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - firstName: contact.firstName ?? "", - lastName: contact.lastName ?? "", - }), - }).catch(() => {}); - } - } - - document.addEventListener("QGIV.donationComplete", handleDonationComplete); - return () => document.removeEventListener("QGIV.donationComplete", handleDonationComplete); - }, [showCalculator]); - return ( - - {/* Intro */} - - Becoming a member is the best way to support Tech for Palestine. - - - Members support T4P's work through dues and by joining teams to scale the movement. - - - Membership dues support all T4P initiatives, including the 90+ external projects we support - through the Incubator, and teams working on tech complicity. Our best known successes - include UpScrolled, Boycat, Find a Protest, Apricot, and Thaura AI. - - - You'll be invited to join Hub, our member portal, where you can: - - -
  • Join advocacy and support teams, working directly on Palestinian liberation
  • -
  • Start a team of your own, with T4P support and resources to help you grow
  • -
  • Join community events, like the T4P Book Club and other member meetups
  • -
    - - Whether you're a thinker, builder, leader, software developer, marketer, or activist, - there's a place for you to contribute in your own way. - - - {/* Team cards */} - - A few examples of our teams: - - + + {/* Supporting Member fork */} - {teams.map((team) => ( - - - - {team.name} - - - {team.description} - - - - ))} + + Don't have time to contribute directly? + + + Become a Supporting Member + - {/* Membership Dues */} - - Membership Dues + + 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). - - {showCalculator - ? "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary, which you can calculate below:" - : "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary."} + + Whether you're a thinker, builder, leader, software developer, marketer, or activist, + there's a place for you to contribute in your own way. - {showCalculator && } - - {/* Payment form + side info */} - - + {/* CTAs */} + + + Become a member + + + Become a Supporting Member + + - {/* Side info */} - - {/* Inclusivity / waiver note */} - - - Inclusivity & waivers - - - Tech for Palestine aims for inclusivity. Please contact{" "} - - membership@techforpalestine.org - {" "} - to request a waiver of dues in the following circumstances: - - -
  • Not having access to banking services/debit card
  • -
  • Being located in Gaza or the West Bank
  • -
  • Being a refugee from Gaza or the West Bank evacuated during the genocide
  • -
  • Not being able to afford membership due to personal circumstances
  • -
  • Being a T4P paid staff member
  • -
    - - If you are in the US, your dues are tax deductible. If you are in the UK, contact us - at{" "} - - membership@techforpalestine.org - {" "} - after signup and we will ensure that future donations are processed through our gift - aid partner. - - - Options to pay via DAF, cryptocurrency, foundations, and other methods will be - supported in the future. We will help you migrate to your preferred method of giving - once available. - -
    + - {/* Contact */} - - - Get in touch - - - If you have questions, set up an{" "} - - intro call - {" "} - or reach out to us at{" "} - - membership@techforpalestine.org - - ! - - -
    -
    +
    ); } diff --git a/src/components/TestimonialsMarquee.astro b/src/components/TestimonialsMarquee.astro new file mode 100644 index 00000000..6e1f3f70 --- /dev/null +++ b/src/components/TestimonialsMarquee.astro @@ -0,0 +1,153 @@ +--- +import { getTestimonials } from "../data/testimonials"; + +/** + * Legacy-design-system twin of `home/TestimonialsSection.astro`. + * + * The design-system version depends on `design-system.css` (Fraunces/Outfit and + * the `ts-*` typography scale), which `Layout.astro` deliberately does not load. + * Rather than pull that stylesheet into every legacy page, this renders the same + * shared testimonial data in the legacy grey/green idiom. + */ +interface Props { + membershipLive?: boolean; + heading?: string; + subheading?: string; +} + +const { + membershipLive = false, + heading = "What it's like to work with T4P", + subheading = "Project leaders, mentors, and members on their time inside the coalition.", +} = Astro.props; + +const testimonials = getTestimonials(membershipLive); +--- + +
    +
    +

    {heading}

    +

    {subheading}

    + +
    +
    + { + [...testimonials, ...testimonials, ...testimonials].map((t, i) => ( +
    = testimonials.length && "legacy-marquee-dupe", + ]} + > + +
    + {t.quote} +
    +
    + {t.headshots ? ( +
    + + +
    + ) : ( + + )} +
    +

    {t.name}

    +

    {t.role}

    +
    +
    +
    + )) + } +
    +
    +
    +
    + + diff --git a/src/components/home/TestimonialsSection.astro b/src/components/home/TestimonialsSection.astro index c641e7d5..f7f4e259 100644 --- a/src/components/home/TestimonialsSection.astro +++ b/src/components/home/TestimonialsSection.astro @@ -1,61 +1,26 @@ --- +import { getTestimonials } from "../../data/testimonials"; + interface Props { membershipLive?: boolean; + /** Overline above the heading. */ + eyebrow?: string; + /** Heading text. `accent` is rendered in the brand colour after it. */ + heading?: string; + accent?: string; + /** Right-hand supporting line, beside the heading on wide viewports. */ + subheading?: string; } -const { membershipLive = false } = Astro.props; -const volunteerTitle = membershipLive ? "Member" : "Volunteer"; +const { + membershipLive = false, + eyebrow = "From the people building with us", + heading = "What it's like to", + accent = "work with T4P", + subheading = "Project leaders, mentors, and members on their time inside the coalition.", +} = Astro.props; -const testimonials = [ - { - quote: - "The valuable advice and launch support from T4P was exactly what we needed to finally hit cash-flow positive and build our impact.", - name: "Hani & Said Chihabi", - role: "Founders, Thaura", - logo: "/thaura.svg", - headshots: [ - "/images/new-homepage/testimonials/hani.webp", - "/images/new-homepage/testimonials/said.webp", - ], - }, - { - quote: - "Tech for Palestine has been a true partner to UpScrolled from the early days, opening doors to their ecosystem and standing by us at every step of the journey.", - name: "Issam Hijazi", - role: "Founder, UpScrolled", - logo: "/upscrolled-icon.svg", - headshot: "/images/new-homepage/testimonials/issam-hijazi.webp", - }, - { - quote: - "With the volunteers and partnerships provided, we went from a minimally viable concept to a full-fledged global platform reaching millions of accounts each month.", - name: "Nima Akram", - role: "Founder, Newscord", - logo: "/newscord.svg", - headshot: "/images/new-homepage/testimonials/newscord.webp", - }, - { - quote: - "T4P isn't just a projects incubator, it incubated me too. As someone from Gaza, being part of a team that actively supports and advocates for Palestine makes this work feel deeply personal.", - name: "Mohanad", - role: `${volunteerTitle}, Tech for Palestine`, - headshot: "/images/new-homepage/testimonials/anonymous.svg", - }, - { - quote: - "Being part of Tech for Palestine is not a duty, it is a privilege. This is work we do to help forge a future for everyone who is being oppressed.", - name: "Veda", - role: `${volunteerTitle}, Tech for Palestine`, - headshot: "/images/new-homepage/testimonials/anonymous.svg", - }, - { - quote: - "T4P gave me a meaningful way to support the Palestinian cause and liberation. It is inspiring to connect with T4P's deeply committed people and volunteers, all united in driving positive change, through the high-impact projects we support.", - name: "Cecile", - role: `${volunteerTitle}, Tech for Palestine`, - headshot: "/images/new-homepage/testimonials/anonymous.svg", - }, -]; +const testimonials = getTestimonials(membershipLive); ---
    @@ -64,15 +29,16 @@ const testimonials = [ class="testimonials-header mb-12 grid grid-cols-1 gap-6 min-[810px]:mb-16 min-[810px]:grid-cols-[1fr_1fr] min-[810px]:items-end" >
    -

    From the people building with us

    +

    {eyebrow}

    - What it's like to work with T4P + {heading} + {accent}

    - Project leaders, mentors, and members on their time inside the coalition. + {subheading}

    diff --git a/src/components/membership/AboutYouStep.tsx b/src/components/membership/AboutYouStep.tsx new file mode 100644 index 00000000..8a632ec1 --- /dev/null +++ b/src/components/membership/AboutYouStep.tsx @@ -0,0 +1,95 @@ +import { useState, type FormEvent } from "react"; +import { Box, Typography, TextField, Button } from "@mui/material"; + +export interface AboutYouData { + name: string; + email: string; +} + +interface AboutYouStepProps { + onContinue: (data: AboutYouData) => void; + /** Pre-fills the fields when the visitor returns to this step from payment. */ + initialValues?: AboutYouData; +} + +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 + * email format `/api/membership-complete.ts` validates server-side. + */ +export default function AboutYouStep({ onContinue, initialValues }: AboutYouStepProps) { + 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 nextNameError = trimmedName.length === 0 ? "Please enter your name." : ""; + const nextEmailError = EMAIL_PATTERN.test(trimmedEmail) + ? "" + : "Please enter a valid email address."; + + setNameError(nextNameError); + setEmailError(nextEmailError); + + if (nextNameError || nextEmailError) return; + + onContinue({ name: trimmedName, email: trimmedEmail }); + } + + return ( + + setName(event.target.value)} + error={Boolean(nameError)} + helperText={nameError} + fullWidth + margin="normal" + autoComplete="name" + /> + + setEmail(event.target.value)} + error={Boolean(emailError)} + helperText={emailError} + fullWidth + margin="normal" + autoComplete="email" + /> + + + You'll be redirected to our secure payment partner to complete your membership. + + + + + + + ); +} diff --git a/src/components/membership/BenefitsTable.astro b/src/components/membership/BenefitsTable.astro new file mode 100644 index 00000000..33c06f19 --- /dev/null +++ b/src/components/membership/BenefitsTable.astro @@ -0,0 +1,95 @@ +--- +import { membershipBenefits as benefits } from "../../data/membershipBenefits"; + +/** + * Supporting Member vs Member benefit comparison. + * + * Rendered as a real table at >=810px and as stacked per-benefit cards below, + * so it stays readable on phones without turning the markup into divs. Every + * cell carries screen-reader text — an empty cell is ambiguous to a screen + * reader, so "not included" is stated explicitly. + */ +const columnHeaderClass = "ts-label px-4 py-4 text-center align-bottom text-ink"; +--- + + + + + +
      + { + benefits.map((benefit) => ( +
    • +

      {benefit.label}

      +

      + + Supporting Member:{" "} + + {benefit.supporting ? "Included" : "Not included"} + + + + Member:{" "} + + {benefit.member ? "Included" : "Not included"} + + +

      +
    • + )) + } +
    diff --git a/src/components/membership/LegacyJoinSection.tsx b/src/components/membership/LegacyJoinSection.tsx new file mode 100644 index 00000000..f65c2196 --- /dev/null +++ b/src/components/membership/LegacyJoinSection.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from "react"; +import { Box, Typography, Button } from "@mui/material"; +import MembershipCalculator from "../MembershipCalculator"; +import QgivJoin from "./QgivJoin"; +import AboutYouStep, { type AboutYouData } from "./AboutYouStep"; +import type { MembershipTier, QgivPrefill } from "./qgiv"; + +interface LegacyJoinSectionProps { + tier: MembershipTier; + /** Optional section heading. Omitted on the membership pages, which follow + * the brief's headingless prose structure. */ + heading?: string; +} + +const LINK = + "text-[#168039] underline decoration-1 underline-offset-2 transition-colors hover:text-[#116b2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#168039] focus-visible:ring-offset-2 focus-visible:rounded-sm"; +const MAIL = "mailto:membership@techforpalestine.org"; +/** Shared fixed height for the About You / payment panel, matching QgivJoin's + * own max-height cap, so switching between the two never resizes the panel. */ +const JOIN_PANEL_HEIGHT = 640; + +/** + * Payment surface for the legacy (green/grey) design system: the Qgiv embed, the + * dues calculator A/B test, and the detailed waiver / contact side panel. + * + * The design-system equivalent is `MembershipDues.tsx`. The two are kept apart + * because `Layout.astro` does not load `design-system.css`, so the `ts-*` + * typography scale the new component relies on is unavailable here. + */ +export default function LegacyJoinSection({ tier, heading }: LegacyJoinSectionProps) { + const [showCalculator, setShowCalculator] = useState(false); + const [variant, setVariant] = useState(undefined); + const [step, setStep] = useState<"about-you" | "payment">("about-you"); + const [aboutYou, setAboutYou] = useState(null); + + const runsCalculatorTest = tier === "member"; + + function splitName(name: string): { firstName: string; lastName: string } { + const [firstName, ...rest] = name.trim().split(/\s+/); + return { firstName: firstName ?? "", lastName: rest.join(" ") }; + } + + const prefill: QgivPrefill | undefined = aboutYou + ? { ...splitName(aboutYou.name), email: aboutYou.email } + : undefined; + + useEffect(() => { + if (!runsCalculatorTest) return; + + const urlParams = new URLSearchParams(window.location.search); + const urlParam = urlParams.get("calculator"); + + let assigned: boolean; + if (urlParam === "yes") { + assigned = true; + } else if (urlParam === "no") { + assigned = false; + } else { + const stored = localStorage.getItem("membership_ab_variant"); + if (stored === "Calculator") { + assigned = true; + } else if (stored === "No Calculator") { + assigned = false; + } else { + assigned = Math.random() < 0.5; + localStorage.setItem("membership_ab_variant", assigned ? "Calculator" : "No Calculator"); + } + } + + const assignedVariant = assigned ? "Calculator" : "No Calculator"; + setShowCalculator(assigned); + setVariant(assignedVariant); + + if (typeof window.plausible !== "undefined") { + window.plausible("Membership Page", { + props: { membership_variant: assignedVariant }, + }); + } + }, [runsCalculatorTest]); + + const intro = + tier === "supporting" + ? "Contribute any amount for supporting membership dues. We suggest monthly dues equal to one hour's salary." + : showCalculator + ? "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary, which you can calculate below:" + : "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary."; + + return ( + + {heading && ( + + {heading} + + )} + + {intro} + + + {showCalculator && } + + + + + + {step === "about-you" ? "Step 1/2 — About You" : "Step 2/2 — Payment"} + + {step === "payment" && ( + + )} + + + {/* QgivJoin stays mounted (hidden via CSS, never unmounted) once the + visitor first reaches payment. Unmounting it (e.g. via "Edit your + info" then back) re-runs its script-injection effect, which + re-adds Qgiv's embed.js tag; the script throws on the + redeclaration and the embed breaks. Toggling visibility keeps + the script's one-time init intact. */} + + { + setAboutYou(data); + setStep("payment"); + }} + initialValues={aboutYou ?? undefined} + /> + + {aboutYou && ( + + + + )} + + + + + + + Get in touch + + + If you are in the US, your dues are tax deductible. If you have any questions, set up + an{" "} + + intro call + {" "} + or reach out to us at{" "} + + membership@techforpalestine.org + + ! + + + + + + ); +} diff --git a/src/components/membership/LegacyWaiverNote.tsx b/src/components/membership/LegacyWaiverNote.tsx new file mode 100644 index 00000000..0155aae7 --- /dev/null +++ b/src/components/membership/LegacyWaiverNote.tsx @@ -0,0 +1,41 @@ +interface LegacyWaiverNoteProps { + className?: string; +} + +const REASONS = [ + "Being located in, or a refugee from Gaza or the West Bank", + "Not being able to afford membership due to personal circumstances", +]; + +/** + * Short inclusivity / waiver statement shown beneath the membership CTAs on the + * legacy-design pages. The fuller version sits beside the payment form in + * `LegacyJoinSection`. + * + * Plain Tailwind rather than MUI so it can render statically (no client + * directive) when used directly from an `.astro` page. + */ +export default function LegacyWaiverNote({ className = "" }: LegacyWaiverNoteProps) { + return ( +
    +

    + Tech for Palestine aims for inclusivity. Please contact{" "} + + membership@techforpalestine.org + {" "} + to request a waiver of dues in the following circumstances: +

    +
      + {REASONS.map((reason) => ( +
    • {reason}
    • + ))} +
    +

    If you are in the US, your dues are tax deductible.

    +
    + ); +} diff --git a/src/components/membership/LogoWall.astro b/src/components/membership/LogoWall.astro new file mode 100644 index 00000000..24c634da --- /dev/null +++ b/src/components/membership/LogoWall.astro @@ -0,0 +1,32 @@ +--- +/** + * Project logos shown alongside the reach stat on the Supporting Member page. + * + * The logos are illustrative of the stat rather than navigational, so each + * image is `alt=""` and the group carries a single accessible label. + */ +import { showcaseProjects as projects } from "../../data/membershipBenefits"; + +const label = `Projects supported by Tech for Palestine: ${projects.map((p) => p.name).join(", ")}`; +--- + +
      + { + projects.map((project) => ( +
    • + +
    • + )) + } +
    diff --git a/src/components/membership/MembershipCalculator.tsx b/src/components/membership/MembershipCalculator.tsx index a9747d55..88908d5b 100644 --- a/src/components/membership/MembershipCalculator.tsx +++ b/src/components/membership/MembershipCalculator.tsx @@ -129,7 +129,7 @@ export default function MembershipCalculator() {

    Monthly dues

    -

    +

    {currencyData.symbol} {suggestedMonthly.toFixed(2)}

    @@ -141,7 +141,7 @@ export default function MembershipCalculator() {

    Annual dues

    -

    +

    {currencyData.symbol} {suggestedAnnual.toFixed(2)}

    diff --git a/src/components/membership/MembershipDues.tsx b/src/components/membership/MembershipDues.tsx index cb98242b..a6ce5d62 100644 --- a/src/components/membership/MembershipDues.tsx +++ b/src/components/membership/MembershipDues.tsx @@ -1,39 +1,34 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import MembershipCalculator from "./MembershipCalculator"; +import QgivJoin from "./QgivJoin"; +import type { MembershipTier } from "./qgiv"; -function QgivEmbed() { - const scriptLoadedRef = useRef(false); - - useEffect(() => { - if (scriptLoadedRef.current) return; - scriptLoadedRef.current = true; +interface MembershipDuesProps { + tier?: MembershipTier; +} - const script = document.createElement("script"); - script.src = "https://secure.qgiv.com/resources/core/js/embed.js"; - script.id = "qgiv-embedjs"; - script.async = true; - document.body.appendChild(script); - }, []); +const MAIL = "mailto:membership@techforpalestine.org"; +const LINK = "text-brand underline hover:text-brand-hover"; - return ( -
    -
    -
    - ); -} +const WAIVER_REASONS = [ + "Not having access to banking services/debit card", + "Being located in Gaza or the West Bank", + "Being a refugee from Gaza or the West Bank evacuated during the genocide", + "Not being able to afford membership due to personal circumstances", + "Being a T4P paid staff member", +]; -export default function MembershipDues() { +export default function MembershipDues({ tier = "member" }: MembershipDuesProps) { // Default to the no-calculator variant so this renders identically during // SSR (no window/localStorage access) before the A/B assignment runs below. const [showCalculator, setShowCalculator] = useState(false); + const [variant, setVariant] = useState(undefined); + + const runsCalculatorTest = tier === "member"; useEffect(() => { + if (!runsCalculatorTest) return; + const urlParams = new URLSearchParams(window.location.search); const urlParam = urlParams.get("calculator"); @@ -54,25 +49,28 @@ export default function MembershipDues() { } } + const assignedVariant = assigned ? "Calculator" : "No Calculator"; setShowCalculator(assigned); + setVariant(assignedVariant); if (typeof window.plausible !== "undefined") { window.plausible("Membership Page", { - props: { - membership_variant: assigned ? "Calculator" : "No Calculator", - }, + props: { membership_variant: assignedVariant }, }); } - }, []); + }, [runsCalculatorTest]); + + const intro = + tier === "supporting" + ? "Contribute any amount for supporting membership dues. We suggest monthly dues equal to one hour's salary." + : showCalculator + ? "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary, which you can calculate below:" + : "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary."; return (
    {/* Intro copy — left-aligned with the section heading */} -

    - {showCalculator - ? "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary, which you can calculate below:" - : "Contribute any amount for membership dues. We suggest monthly dues equal to one hour's salary."} -

    +

    {intro}

    {/* Calculator + form — constrained width, left-aligned */}
    @@ -80,7 +78,7 @@ export default function MembershipDues() { {/* Form + side info */}
    - + {/* Side info */}
    @@ -88,22 +86,13 @@ export default function MembershipDues() {

    Inclusivity & waivers

    Tech for Palestine aims for inclusivity. Please contact{" "} - + membership@techforpalestine.org {" "} to request a waiver of dues in the following circumstances:

      - {[ - "Not having access to banking services/debit card", - "Being located in Gaza or the West Bank", - "Being a refugee from Gaza or the West Bank evacuated during the genocide", - "Not being able to afford membership due to personal circumstances", - "Being a T4P paid staff member", - ].map((item) => ( + {WAIVER_REASONS.map((item) => (
    • If you are in the US, your dues are tax deductible. If you are in the UK, contact us at{" "} - + membership@techforpalestine.org {" "} after signup and we will ensure that future donations are processed through our gift @@ -141,17 +127,14 @@ export default function MembershipDues() { If you have questions, set up an{" "} intro call {" "} or reach out to us at{" "} - + membership@techforpalestine.org ! diff --git a/src/components/membership/MembershipFAQSection.tsx b/src/components/membership/MembershipFAQSection.tsx deleted file mode 100644 index f3d0968c..00000000 --- a/src/components/membership/MembershipFAQSection.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useState } from "react"; - -export interface FAQItem { - question: string; - answer: string | { text: string; items?: string[] }; -} - -export const faqs: FAQItem[] = [ - { - question: "How does membership support Palestinian liberation?", - answer: - "Membership brings together like-minded members and activists to lead, co-lead or support projects and committees. Members can support the movement by contributing financially or by getting involved directly as a working committee leader or member.", - }, - { - question: "How do I get involved after signup?", - answer: { - text: "We will send you an email about the ways to get involved as a member:", - items: [ - "Get T4P support on leveling up personal and corporate boycotts, using ethical alternatives, and learning how to activate against tech complicity.", - "Bring T4P resources into your companies for hiring and policy support.", - "Meet with us one-on-one to see how your skills and interests can support the mission.", - "Join working committees of fellow advocates taking direct action for Palestinian liberation.", - "Attend meetups and webinars to connect and learn from other activists.", - "Stay up-to-date by reading and sharing movement updates.", - ], - }, - }, - { - question: "Why are there membership dues?", - answer: - "Dues are pay-what-you-can and help us progress towards our goal of 10,000 projects for Palestine by supporting project work itself — grants for advocacy projects, access to expert trainings, mentorship from startup founders, and other developmental opportunities to help projects scale — and by supporting the infrastructure that lets us get work done, including software and employees. Tech for Palestine aims for inclusivity. If you are unable to access banking services, please reach out to membership@techforpalestine.org to request a waiver of dues.", - }, - { - question: "Are dues tax deductible? Can I apply gift aid?", - answer: - "Yes, if you are in the US your dues are tax deductible. If you are in the UK, contact us at membership@techforpalestine.org after signing up, and we will ensure future donations are processed through our gift aid partner.", - }, - { - question: - "Can I pay annually or via a different payment method (DAF, cryptocurrency, foundation, etc.)?", - answer: - "These options will be supported in the future, and we will help you migrate to your preferred method of giving once available.", - }, -]; - -function linkifyEmail(text: string) { - const parts = text.split("membership@techforpalestine.org"); - return parts.map((part, i) => - i < parts.length - 1 ? ( - - {part} - - membership@techforpalestine.org - - - ) : ( - {part} - ) - ); -} - -function FAQItem({ question, answer }: FAQItem) { - const [open, setOpen] = useState(false); - - return ( -
      - -
      -
      -
      - {typeof answer === "string" ? ( -

      {linkifyEmail(answer)}

      - ) : ( - <> -

      {answer.text}

      - {answer.items && ( -
        - {answer.items.map((item) => ( -
      • -
      • - ))} -
      - )} - - )} -
      -
      -
      -
      - ); -} - -export default function MembershipFAQSection() { - return ( -
      - {faqs.map((faq) => ( - - ))} -
      - ); -} diff --git a/src/components/membership/QgivJoin.tsx b/src/components/membership/QgivJoin.tsx new file mode 100644 index 00000000..3ddffe9e --- /dev/null +++ b/src/components/membership/QgivJoin.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef, useState } from "react"; +import { + QGIV_EMBED_SCRIPT, + QGIV_FORMS, + qgivEmbedUrl, + type MembershipTier, + type QgivPrefill, +} from "./qgiv"; + +const QGIV_EMBED_SCRIPT_ID = "qgiv-embedjs"; +/** Fail-open cap on the loading overlay: hide it even if we never observe + * Qgiv populate the container, so a detection miss doesn't block the embed. */ +const EMBED_LOAD_TIMEOUT_MS = 8000; +/** Floor on how long the loading overlay stays up: when the script is already + * cached, Qgiv can populate the container within a few milliseconds, which + * makes the overlay flash and disappear before a visitor perceives it. */ +const MIN_LOADING_DISPLAY_MS = 500; + +function prefillKey(prefill?: QgivPrefill): string { + return prefill ? JSON.stringify([prefill.firstName, prefill.lastName, prefill.email]) : ""; +} + +interface QgivJoinProps { + tier: MembershipTier; + /** + * Plausible prop recording which membership-page A/B variant the visitor saw. + * Only meaningful for the `member` tier. + */ + variant?: string; + className?: string; + prefill?: QgivPrefill; +} + +interface QgivTransactionDetail { + QGIV?: { + transaction?: { total?: number | string }; + contact?: { email?: string; firstName?: string; lastName?: string }; + }; +} + +/** + * Embeds the Qgiv payment form for a membership tier and reports completions. + * + * Qgiv fires a `QGIV.donationComplete` DOM event when a transaction succeeds. + * That event is the only signal the site gets, so this component owns both the + * embed and the listener — keeping them apart is what caused /membership-new to + * silently stop sending Hub invites and EmailOctopus tags. + */ +export default function QgivJoin({ tier, variant, className, prefill }: QgivJoinProps) { + const form = QGIV_FORMS[tier]; + const scriptLoadedRef = useRef(false); + const containerRef = useRef(null); + const iframeRef = useRef(null); + const appliedPrefillKeyRef = useRef(null); + const [embedLoaded, setEmbedLoaded] = useState(false); + + useEffect(() => { + if (!form.embedId || scriptLoadedRef.current) return; + scriptLoadedRef.current = true; + + // Guard against a duplicate diff --git a/src/pages/supporting-member.astro b/src/pages/supporting-member.astro new file mode 100644 index 00000000..61509677 --- /dev/null +++ b/src/pages/supporting-member.astro @@ -0,0 +1,207 @@ +--- +import Layout from "../layouts/Layout.astro"; +import PageHeader from "../components/PageHeader.astro"; +import LegacyJoinSection from "../components/membership/LegacyJoinSection"; +import LegacyWaiverNote from "../components/membership/LegacyWaiverNote"; +import TestimonialsMarquee from "../components/TestimonialsMarquee.astro"; +import { membershipBenefits, showcaseProjects } from "../data/membershipBenefits"; +import "../styles/base.css"; + +const logoWallLabel = `Projects supported by Tech for Palestine: ${showcaseProjects + .map((p) => p.name) + .join(", ")}`; +--- + + +
      + + +
      + +
      +

      + Tech for Palestine's coalition of founders, engineers, investors and other professionals + are helping Palestine advocates scale their work. Tens of thousands of Israeli hasbara (propaganda) projects exist, so we're creating thousands of initiatives for Palestinian + liberation. +

      + + A speaker addressing the audience during a Tech for Palestine panel discussion +
      + + +

      + 10M+ Users Across 90+ Projects +

      +
        + { + showcaseProjects.map((project) => ( +
      • + +
      • + )) + } +
      + + +

      + Want to support T4P's work, but don't have time to get involved in teams or join the + community? Become a Supporting Member to support our work financially, receive invites to + events and get updates about our progress: +

      + + + + +
        + { + membershipBenefits.map((benefit) => ( +
      • +

        {benefit.label}

        +

        + + Supporting Member:{" "} + + {benefit.supporting ? "Included" : "Not included"} + + + + Member:{" "} + + {benefit.member ? "Included" : "Not included"} + + +

        +
      • + )) + } +
      + + + + + + +
      + +
      +
      + + +
      + + +
      diff --git a/src/styles/base.css b/src/styles/base.css index 03af62c2..847549b9 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -4,6 +4,9 @@ @tailwind utilities; @layer base { + html { + scroll-behavior: smooth; + } a { @apply text-blue-900; } @@ -15,3 +18,9 @@ justify-content: space-between; } } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +}