From b987e78f9634bc13120f3f3e56f38ba25e0b4cb2 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Mon, 19 Oct 2026 16:06:21 +0300 Subject: [PATCH 01/11] feat(home): cookieless homepage CTA text test via GrowthBook Hero CTA island reads the homepage-cta-text flag (50% rollout plus a control force rule, preview only). Assignment uses an in-memory crypto.randomUUID id with the SDK payload cache disabled, so nothing is written to cookies or storage. Exposure and CTA click events go to Plausible once per page view with experiment and variant props. --- src/components/home/HeroCta.tsx | 56 +++++++++++++++++++++++++++++++++ src/lib/growthbook.ts | 11 +++++-- src/pages/index.astro | 5 ++- src/utils/ctaExperiment.ts | 12 +++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/components/home/HeroCta.tsx create mode 100644 src/utils/ctaExperiment.ts diff --git a/src/components/home/HeroCta.tsx b/src/components/home/HeroCta.tsx new file mode 100644 index 00000000..f3ce840b --- /dev/null +++ b/src/components/home/HeroCta.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import { useFeature } from "@growthbook/growthbook-react"; +import GrowthBookProvider from "../shared/GrowthBookProvider"; +import { + CTA_CLICK_EVENT, + CTA_DEFAULT_TEXT, + CTA_FEATURE_KEY, + EXPOSURE_EVENT, + trackExperimentEvent, +} from "../../utils/ctaExperiment"; + +interface Props { + className: string; +} + +function CtaLink({ className }: Props) { + const feature = useFeature(CTA_FEATURE_KEY); + const text = feature.value ?? CTA_DEFAULT_TEXT; + // The test is a 50% rollout rule plus a catch-all "force" control rule (GrowthBook's + // API can't create inline experiments without a datasource), so there is no + // experimentResult. source === "force" means a rule bucketed this visitor; a disabled + // flag or a failed fetch reports "defaultValue"/"unknownFeature" and is not tracked. + const variant = + feature.source === "force" ? (text === CTA_DEFAULT_TEXT ? "control" : "treatment") : null; + const hasTrackedExposure = useRef(false); + const hasTrackedClick = useRef(false); + + // Fires once per page view, only after the assigned variant is on screen. + useEffect(() => { + if (variant === null || hasTrackedExposure.current) return; + hasTrackedExposure.current = true; + trackExperimentEvent(EXPOSURE_EVENT, CTA_FEATURE_KEY, variant); + }, [variant]); + + const handleClick = () => { + if (variant === null || hasTrackedClick.current) return; + hasTrackedClick.current = true; + trackExperimentEvent(CTA_CLICK_EVENT, CTA_FEATURE_KEY, variant); + }; + + return ( + + {text} + + ); +} + +// Hero CTA for the homepage text experiment. Rendered with client:only, so the static +// control link in index.astro (slot="fallback") shows until this hydrates. +export default function HeroCta({ className }: Props) { + return ( + + + + ); +} diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts index ddd836f7..06cdf0ed 100644 --- a/src/lib/growthbook.ts +++ b/src/lib/growthbook.ts @@ -1,4 +1,4 @@ -import { GrowthBook } from "@growthbook/growthbook-react"; +import { GrowthBook, configureCache } from "@growthbook/growthbook-react"; const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY; @@ -10,10 +10,17 @@ if (!clientKey) { ); } +// Consent-free by design: nothing is written to cookies or storage. The SDK would +// otherwise cache the feature payload in localStorage, so switch that off. +configureCache({ disableCache: true }); + export const growthbook = new GrowthBook({ apiHost: "https://cdn.growthbook.io", clientKey, - enableDevMode: true, + enableDevMode: import.meta.env.DEV, + // A fresh random id per page load, held in memory only. Visitors are re-bucketed on + // every page view, so an experiment can only measure same-page-view outcomes. + attributes: { id: crypto.randomUUID() }, }); // streaming: false — fetch the payload once on load. Flag changes then land on the diff --git a/src/pages/index.astro b/src/pages/index.astro index fdcd0ab5..d4d39e43 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro"; import SignUpForm from "../structures/SignUpForm.astro"; import CommunityCallBanner from "../components/CommunityCallBanner.astro"; import NewsletterPopup from "../components/home/NewsletterPopup"; +import HeroCta from "../components/home/HeroCta"; import HackathonPopup from "../components/home/HackathonPopup.astro"; import "../styles/base.css"; @@ -197,7 +198,9 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi

diff --git a/src/utils/ctaExperiment.ts b/src/utils/ctaExperiment.ts new file mode 100644 index 00000000..93c74c05 --- /dev/null +++ b/src/utils/ctaExperiment.ts @@ -0,0 +1,12 @@ +export const CTA_FEATURE_KEY = "homepage-cta-text"; +export const CTA_DEFAULT_TEXT = "Apply to the Incubator"; +export const EXPOSURE_EVENT = "Experiment Exposure"; +export const CTA_CLICK_EVENT = "CTA Click"; + +interface PlausibleWindow { + plausible?: (name: string, options?: { props: Record }) => void; +} + +export function trackExperimentEvent(name: string, experiment: string, variant: string): void { + (window as PlausibleWindow).plausible?.(name, { props: { experiment, variant } }); +} From b885d4a949a020c080ecec337ba9bec7b0f35d2c Mon Sep 17 00:00:00 2001 From: mohanadft Date: Mon, 19 Oct 2026 16:10:45 +0300 Subject: [PATCH 02/11] fix(home): hide hero CTA until the flag loads to avoid text flip --- src/components/home/HeroCta.tsx | 41 ++++++++++++++++++++++++++++----- src/pages/index.astro | 4 +++- src/utils/ctaExperiment.ts | 2 ++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/home/HeroCta.tsx b/src/components/home/HeroCta.tsx index f3ce840b..14a584ae 100644 --- a/src/components/home/HeroCta.tsx +++ b/src/components/home/HeroCta.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useFeature } from "@growthbook/growthbook-react"; import GrowthBookProvider from "../shared/GrowthBookProvider"; +import { ready } from "../../lib/growthbook"; import { CTA_CLICK_EVENT, CTA_DEFAULT_TEXT, CTA_FEATURE_KEY, + CTA_READY_TIMEOUT_MS, EXPOSURE_EVENT, trackExperimentEvent, } from "../../utils/ctaExperiment"; @@ -13,15 +15,38 @@ interface Props { className: string; } +type Status = "loading" | "ready" | "timeout"; + +function useFeatureStatus(): Status { + const [status, setStatus] = useState("loading"); + useEffect(() => { + const timer = setTimeout( + () => setStatus((current) => (current === "loading" ? "timeout" : current)), + CTA_READY_TIMEOUT_MS + ); + ready.then(() => setStatus((current) => (current === "loading" ? "ready" : current))); + return () => clearTimeout(timer); + }, []); + return status; +} + function CtaLink({ className }: Props) { const feature = useFeature(CTA_FEATURE_KEY); - const text = feature.value ?? CTA_DEFAULT_TEXT; + const status = useFeatureStatus(); + // Hidden (not unmounted, so layout doesn't shift) until the flag has loaded, so a + // visitor never sees the control text flip to the variant. If the fetch is slow we + // show the control and stop tracking, since the flag may still resolve later. + const text = status === "ready" ? (feature.value ?? CTA_DEFAULT_TEXT) : CTA_DEFAULT_TEXT; // The test is a 50% rollout rule plus a catch-all "force" control rule (GrowthBook's // API can't create inline experiments without a datasource), so there is no // experimentResult. source === "force" means a rule bucketed this visitor; a disabled // flag or a failed fetch reports "defaultValue"/"unknownFeature" and is not tracked. const variant = - feature.source === "force" ? (text === CTA_DEFAULT_TEXT ? "control" : "treatment") : null; + status === "ready" && feature.source === "force" + ? text === CTA_DEFAULT_TEXT + ? "control" + : "treatment" + : null; const hasTrackedExposure = useRef(false); const hasTrackedClick = useRef(false); @@ -39,14 +64,18 @@ function CtaLink({ className }: Props) { }; return ( - + {text} ); } -// Hero CTA for the homepage text experiment. Rendered with client:only, so the static -// control link in index.astro (slot="fallback") shows until this hydrates. +// Hero CTA for the homepage text experiment. Rendered with client:only, so the hidden +// placeholder link in index.astro (slot="fallback") reserves its space until this hydrates. export default function HeroCta({ className }: Props) { return ( diff --git a/src/pages/index.astro b/src/pages/index.astro index d4d39e43..64c2b550 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -199,7 +199,9 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi diff --git a/src/utils/ctaExperiment.ts b/src/utils/ctaExperiment.ts index 93c74c05..c7e075f6 100644 --- a/src/utils/ctaExperiment.ts +++ b/src/utils/ctaExperiment.ts @@ -1,5 +1,7 @@ export const CTA_FEATURE_KEY = "homepage-cta-text"; export const CTA_DEFAULT_TEXT = "Apply to the Incubator"; +// Longest we hide the CTA waiting for the flag payload before showing the control. +export const CTA_READY_TIMEOUT_MS = 1500; export const EXPOSURE_EVENT = "Experiment Exposure"; export const CTA_CLICK_EVENT = "CTA Click"; From 99c87a95c7545aa8231e6110982ab855c7b766be Mon Sep 17 00:00:00 2001 From: mohanadft Date: Mon, 19 Oct 2026 16:26:21 +0300 Subject: [PATCH 03/11] refactor(home): assign the CTA variant on the server instead of via GrowthBook --- src/components/home/HeroCta.tsx | 85 --------------------------------- src/lib/growthbook.ts | 11 +---- src/pages/index.astro | 24 +++++++--- src/utils/ctaExperiment.test.ts | 18 +++++++ src/utils/ctaExperiment.ts | 31 ++++++++++-- 5 files changed, 65 insertions(+), 104 deletions(-) delete mode 100644 src/components/home/HeroCta.tsx create mode 100644 src/utils/ctaExperiment.test.ts diff --git a/src/components/home/HeroCta.tsx b/src/components/home/HeroCta.tsx deleted file mode 100644 index 14a584ae..00000000 --- a/src/components/home/HeroCta.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useFeature } from "@growthbook/growthbook-react"; -import GrowthBookProvider from "../shared/GrowthBookProvider"; -import { ready } from "../../lib/growthbook"; -import { - CTA_CLICK_EVENT, - CTA_DEFAULT_TEXT, - CTA_FEATURE_KEY, - CTA_READY_TIMEOUT_MS, - EXPOSURE_EVENT, - trackExperimentEvent, -} from "../../utils/ctaExperiment"; - -interface Props { - className: string; -} - -type Status = "loading" | "ready" | "timeout"; - -function useFeatureStatus(): Status { - const [status, setStatus] = useState("loading"); - useEffect(() => { - const timer = setTimeout( - () => setStatus((current) => (current === "loading" ? "timeout" : current)), - CTA_READY_TIMEOUT_MS - ); - ready.then(() => setStatus((current) => (current === "loading" ? "ready" : current))); - return () => clearTimeout(timer); - }, []); - return status; -} - -function CtaLink({ className }: Props) { - const feature = useFeature(CTA_FEATURE_KEY); - const status = useFeatureStatus(); - // Hidden (not unmounted, so layout doesn't shift) until the flag has loaded, so a - // visitor never sees the control text flip to the variant. If the fetch is slow we - // show the control and stop tracking, since the flag may still resolve later. - const text = status === "ready" ? (feature.value ?? CTA_DEFAULT_TEXT) : CTA_DEFAULT_TEXT; - // The test is a 50% rollout rule plus a catch-all "force" control rule (GrowthBook's - // API can't create inline experiments without a datasource), so there is no - // experimentResult. source === "force" means a rule bucketed this visitor; a disabled - // flag or a failed fetch reports "defaultValue"/"unknownFeature" and is not tracked. - const variant = - status === "ready" && feature.source === "force" - ? text === CTA_DEFAULT_TEXT - ? "control" - : "treatment" - : null; - const hasTrackedExposure = useRef(false); - const hasTrackedClick = useRef(false); - - // Fires once per page view, only after the assigned variant is on screen. - useEffect(() => { - if (variant === null || hasTrackedExposure.current) return; - hasTrackedExposure.current = true; - trackExperimentEvent(EXPOSURE_EVENT, CTA_FEATURE_KEY, variant); - }, [variant]); - - const handleClick = () => { - if (variant === null || hasTrackedClick.current) return; - hasTrackedClick.current = true; - trackExperimentEvent(CTA_CLICK_EVENT, CTA_FEATURE_KEY, variant); - }; - - return ( - - {text} - - ); -} - -// Hero CTA for the homepage text experiment. Rendered with client:only, so the hidden -// placeholder link in index.astro (slot="fallback") reserves its space until this hydrates. -export default function HeroCta({ className }: Props) { - return ( - - - - ); -} diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts index 06cdf0ed..ddd836f7 100644 --- a/src/lib/growthbook.ts +++ b/src/lib/growthbook.ts @@ -1,4 +1,4 @@ -import { GrowthBook, configureCache } from "@growthbook/growthbook-react"; +import { GrowthBook } from "@growthbook/growthbook-react"; const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY; @@ -10,17 +10,10 @@ if (!clientKey) { ); } -// Consent-free by design: nothing is written to cookies or storage. The SDK would -// otherwise cache the feature payload in localStorage, so switch that off. -configureCache({ disableCache: true }); - export const growthbook = new GrowthBook({ apiHost: "https://cdn.growthbook.io", clientKey, - enableDevMode: import.meta.env.DEV, - // A fresh random id per page load, held in memory only. Visitors are re-bucketed on - // every page view, so an experiment can only measure same-page-view outcomes. - attributes: { id: crypto.randomUUID() }, + enableDevMode: true, }); // streaming: false — fetch the payload once on load. Flag changes then land on the diff --git a/src/pages/index.astro b/src/pages/index.astro index 64c2b550..b86486d0 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,8 +3,8 @@ import Layout from "../layouts/Layout.astro"; import SignUpForm from "../structures/SignUpForm.astro"; import CommunityCallBanner from "../components/CommunityCallBanner.astro"; import NewsletterPopup from "../components/home/NewsletterPopup"; -import HeroCta from "../components/home/HeroCta"; import HackathonPopup from "../components/home/HackathonPopup.astro"; +import { CTA_EXPERIMENT, CTA_TEXT, pickCtaVariant } from "../utils/ctaExperiment"; import "../styles/base.css"; const stats = [ @@ -154,6 +154,12 @@ const press = [ const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove"; const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`; + +// Homepage CTA text test. The variant is chosen per request and rendered into the HTML, so +// there is no flicker and nothing is stored. The page must not be cached or every visitor +// in the cache window would get the same variant. +const ctaVariant = pickCtaVariant(); +Astro.response.headers.set("Cache-Control", "no-store"); const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`; const cardBase = `group relative flex flex-col rounded-lg border border-ink-divider bg-paper transition-[border-color,box-shadow] duration-150 hover:border-grove hover:shadow-sm ${focusRing}`; --- @@ -198,11 +204,13 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi

@@ -496,6 +504,10 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi writePopupState, } from "../utils/newsletterPopup"; import { watchSignupSuccess } from "../utils/watchSignupSuccess"; + import { trackCtaLink } from "../utils/ctaExperiment"; + + const heroCta = document.getElementById("hero-cta"); + if (heroCta) trackCtaLink(heroCta); const bottomForm = document.getElementById(FOOTER_SECTION_ID); if (bottomForm) { diff --git a/src/utils/ctaExperiment.test.ts b/src/utils/ctaExperiment.test.ts new file mode 100644 index 00000000..e339e280 --- /dev/null +++ b/src/utils/ctaExperiment.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { CTA_TEXT, pickCtaVariant } from "./ctaExperiment"; + +describe("pickCtaVariant", () => { + it("returns control for the lower half of the range", () => { + expect(pickCtaVariant(() => 0)).toBe("control"); + expect(pickCtaVariant(() => 0.499)).toBe("control"); + }); + + it("returns treatment for the upper half of the range", () => { + expect(pickCtaVariant(() => 0.5)).toBe("treatment"); + expect(pickCtaVariant(() => 0.999)).toBe("treatment"); + }); + + it("has distinct text per variant", () => { + expect(CTA_TEXT.control).not.toBe(CTA_TEXT.treatment); + }); +}); diff --git a/src/utils/ctaExperiment.ts b/src/utils/ctaExperiment.ts index c7e075f6..ea3b77fd 100644 --- a/src/utils/ctaExperiment.ts +++ b/src/utils/ctaExperiment.ts @@ -1,10 +1,20 @@ -export const CTA_FEATURE_KEY = "homepage-cta-text"; -export const CTA_DEFAULT_TEXT = "Apply to the Incubator"; -// Longest we hide the CTA waiting for the flag payload before showing the control. -export const CTA_READY_TIMEOUT_MS = 1500; +export const CTA_EXPERIMENT = "homepage-cta-text"; export const EXPOSURE_EVENT = "Experiment Exposure"; export const CTA_CLICK_EVENT = "CTA Click"; +export type CtaVariant = "control" | "treatment"; + +export const CTA_TEXT: Record = { + control: "Apply to the Incubator", + treatment: "Join the Incubator", +}; + +// Assigned per request on the server: nothing is stored, so a visitor can see a +// different variant on each page view. That is the price of needing no consent banner. +export function pickCtaVariant(random: () => number = Math.random): CtaVariant { + return random() < 0.5 ? "control" : "treatment"; +} + interface PlausibleWindow { plausible?: (name: string, options?: { props: Record }) => void; } @@ -12,3 +22,16 @@ interface PlausibleWindow { export function trackExperimentEvent(name: string, experiment: string, variant: string): void { (window as PlausibleWindow).plausible?.(name, { props: { experiment, variant } }); } + +// Fires one exposure per page view, and at most one click per page view, for the link +// carrying data-experiment / data-variant. +export function trackCtaLink(link: HTMLElement): void { + const experiment = link.dataset.experiment; + const variant = link.dataset.variant; + if (!experiment || !variant) return; + + trackExperimentEvent(EXPOSURE_EVENT, experiment, variant); + link.addEventListener("click", () => trackExperimentEvent(CTA_CLICK_EVENT, experiment, variant), { + once: true, + }); +} From ea287c08bafcc09a31fa81e3ba225a2579b64255 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Mon, 19 Oct 2026 17:13:53 +0300 Subject: [PATCH 04/11] fix(home): disable the GrowthBook localStorage cache --- src/lib/growthbook.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts index ddd836f7..02cf0624 100644 --- a/src/lib/growthbook.ts +++ b/src/lib/growthbook.ts @@ -1,4 +1,4 @@ -import { GrowthBook } from "@growthbook/growthbook-react"; +import { GrowthBook, configureCache } from "@growthbook/growthbook-react"; const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY; @@ -10,6 +10,10 @@ if (!clientKey) { ); } +// Keep the homepage free of browser storage: by default the SDK caches the flag payload +// in localStorage. Disabling that costs one refetch per page load. +configureCache({ disableCache: true }); + export const growthbook = new GrowthBook({ apiHost: "https://cdn.growthbook.io", clientKey, From 35716d512af1dedb15809ea68b206c22ac89b3d6 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Mon, 19 Oct 2026 17:19:50 +0300 Subject: [PATCH 05/11] fix(home): stop the GrowthBook SDK writing its payload to localStorage --- src/lib/growthbook.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts index 02cf0624..75f24a48 100644 --- a/src/lib/growthbook.ts +++ b/src/lib/growthbook.ts @@ -1,4 +1,4 @@ -import { GrowthBook, configureCache } from "@growthbook/growthbook-react"; +import { GrowthBook, setPolyfills } from "@growthbook/growthbook-react"; const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY; @@ -10,9 +10,10 @@ if (!clientKey) { ); } -// Keep the homepage free of browser storage: by default the SDK caches the flag payload -// in localStorage. Disabling that costs one refetch per page load. -configureCache({ disableCache: true }); +// Keep the homepage free of browser storage. The SDK persists its flag payload to +// localStorage on every fetch, and configureCache({ disableCache }) does not stop that +// write, so swap in a no-op storage. The payload is then refetched on every page load. +setPolyfills({ localStorage: { getItem: () => null, setItem: () => {} } }); export const growthbook = new GrowthBook({ apiHost: "https://cdn.growthbook.io", From dc48dfe71aaae8dbb115fa94e3b6121dc75ede67 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 15:51:19 +0200 Subject: [PATCH 06/11] feat(home): test the homepage description instead of the CTA text Replace the CTA text test with an A/B test of the hero description. The control keeps the current two paragraphs; the treatment uses the new member-driven copy. Assignment stays per request on the server and nothing is stored. The CTA reverts to "Apply to the Incubator" and its clicks remain the conversion event. --- src/pages/index.astro | 66 +++++++++++++------ src/utils/ctaExperiment.test.ts | 18 ----- src/utils/homepageExperiment.test.ts | 14 ++++ ...ctaExperiment.ts => homepageExperiment.ts} | 11 +--- 4 files changed, 63 insertions(+), 46 deletions(-) delete mode 100644 src/utils/ctaExperiment.test.ts create mode 100644 src/utils/homepageExperiment.test.ts rename src/utils/{ctaExperiment.ts => homepageExperiment.ts} (78%) diff --git a/src/pages/index.astro b/src/pages/index.astro index b86486d0..5bc86fbf 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,7 +4,7 @@ import SignUpForm from "../structures/SignUpForm.astro"; import CommunityCallBanner from "../components/CommunityCallBanner.astro"; import NewsletterPopup from "../components/home/NewsletterPopup"; import HackathonPopup from "../components/home/HackathonPopup.astro"; -import { CTA_EXPERIMENT, CTA_TEXT, pickCtaVariant } from "../utils/ctaExperiment"; +import { HOMEPAGE_EXPERIMENT, pickHomepageVariant } from "../utils/homepageExperiment"; import "../styles/base.css"; const stats = [ @@ -155,10 +155,10 @@ const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove"; const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`; -// Homepage CTA text test. The variant is chosen per request and rendered into the HTML, so -// there is no flicker and nothing is stored. The page must not be cached or every visitor +// Homepage description test. The variant is chosen per request and rendered into the HTML, +// so there is no flicker and nothing is stored. The page must not be cached or every visitor // in the cache window would get the same variant. -const ctaVariant = pickCtaVariant(); +const variant = pickHomepageVariant(); Astro.response.headers.set("Cache-Control", "no-store"); const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`; const cardBase = `group relative flex flex-col rounded-lg border border-ink-divider bg-paper transition-[border-color,box-shadow] duration-150 hover:border-grove hover:shadow-sm ${focusRing}`; @@ -189,27 +189,53 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi Join the tech movement behind 90+ projects for Palestine -

- Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for - Palestinian liberation. -

-

- We support anyone with a meaningful idea, whether it is working against media censorship, - using tech to aid protests, scaling boycotts, pushing for tech divestment, supporting - Palestinian entrepreneurs, or anything else that contributes to a free Palestine. -

+ { + variant === "control" ? ( + <> +

+ Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for + Palestinian liberation. +

+

+ We support anyone with a meaningful idea, whether it is working against media + censorship, using tech to aid protests, scaling boycotts, pushing for tech + divestment, supporting Palestinian entrepreneurs, or anything else that contributes + to a free Palestine. +

+ + ) : ( + <> +

+ Tech for Palestine is a member-driven organization that brings together people, + skills and resources. Through technology and collective action, our members build + tools and solutions that support the movement for Palestine and help people take + action against Israel's occupation, apartheid and genocide. +

+

+ These projects include: apps to organize protests, tools that make boycotting + companies easier, support for Palestinian students and businesses, alternatives to + mainstream tech platforms, and many more. +

+

+ Whether you're a tech worker, founder, marketer, activist - there's a place for you + to contribute at T4P. +

+

+ Our Vision: A free Palestine, a tech + industry that actively participates in liberation, and a world where all people + enjoy equal dignity, justice, and human rights. +

+ + ) + }
{CTA_TEXT[ctaVariant]}Apply to the Incubator
@@ -504,7 +530,7 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi writePopupState, } from "../utils/newsletterPopup"; import { watchSignupSuccess } from "../utils/watchSignupSuccess"; - import { trackCtaLink } from "../utils/ctaExperiment"; + import { trackCtaLink } from "../utils/homepageExperiment"; const heroCta = document.getElementById("hero-cta"); if (heroCta) trackCtaLink(heroCta); diff --git a/src/utils/ctaExperiment.test.ts b/src/utils/ctaExperiment.test.ts deleted file mode 100644 index e339e280..00000000 --- a/src/utils/ctaExperiment.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CTA_TEXT, pickCtaVariant } from "./ctaExperiment"; - -describe("pickCtaVariant", () => { - it("returns control for the lower half of the range", () => { - expect(pickCtaVariant(() => 0)).toBe("control"); - expect(pickCtaVariant(() => 0.499)).toBe("control"); - }); - - it("returns treatment for the upper half of the range", () => { - expect(pickCtaVariant(() => 0.5)).toBe("treatment"); - expect(pickCtaVariant(() => 0.999)).toBe("treatment"); - }); - - it("has distinct text per variant", () => { - expect(CTA_TEXT.control).not.toBe(CTA_TEXT.treatment); - }); -}); diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts new file mode 100644 index 00000000..89330e5c --- /dev/null +++ b/src/utils/homepageExperiment.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { pickHomepageVariant } from "./homepageExperiment"; + +describe("pickHomepageVariant", () => { + it("returns control for the lower half of the range", () => { + expect(pickHomepageVariant(() => 0)).toBe("control"); + expect(pickHomepageVariant(() => 0.499)).toBe("control"); + }); + + it("returns treatment for the upper half of the range", () => { + expect(pickHomepageVariant(() => 0.5)).toBe("treatment"); + expect(pickHomepageVariant(() => 0.999)).toBe("treatment"); + }); +}); diff --git a/src/utils/ctaExperiment.ts b/src/utils/homepageExperiment.ts similarity index 78% rename from src/utils/ctaExperiment.ts rename to src/utils/homepageExperiment.ts index ea3b77fd..e1e59a74 100644 --- a/src/utils/ctaExperiment.ts +++ b/src/utils/homepageExperiment.ts @@ -1,17 +1,12 @@ -export const CTA_EXPERIMENT = "homepage-cta-text"; +export const HOMEPAGE_EXPERIMENT = "homepage-description"; export const EXPOSURE_EVENT = "Experiment Exposure"; export const CTA_CLICK_EVENT = "CTA Click"; -export type CtaVariant = "control" | "treatment"; - -export const CTA_TEXT: Record = { - control: "Apply to the Incubator", - treatment: "Join the Incubator", -}; +export type HomepageVariant = "control" | "treatment"; // Assigned per request on the server: nothing is stored, so a visitor can see a // different variant on each page view. That is the price of needing no consent banner. -export function pickCtaVariant(random: () => number = Math.random): CtaVariant { +export function pickHomepageVariant(random: () => number = Math.random): HomepageVariant { return random() < 0.5 ? "control" : "treatment"; } From 7a94cbd18be437030c807395abc38ff8b52547b5 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 16:00:22 +0200 Subject: [PATCH 07/11] refactor(home): rename description test events and variants Events become "Homepage Description Shown" / "Homepage Description CTA Click" and variants become A / B. --- src/pages/index.astro | 2 +- src/utils/homepageExperiment.test.ts | 12 ++++++------ src/utils/homepageExperiment.ts | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index 5bc86fbf..4accb4e6 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -190,7 +190,7 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi { - variant === "control" ? ( + variant === "A" ? ( <>

Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts index 89330e5c..c4ef725b 100644 --- a/src/utils/homepageExperiment.test.ts +++ b/src/utils/homepageExperiment.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest"; import { pickHomepageVariant } from "./homepageExperiment"; describe("pickHomepageVariant", () => { - it("returns control for the lower half of the range", () => { - expect(pickHomepageVariant(() => 0)).toBe("control"); - expect(pickHomepageVariant(() => 0.499)).toBe("control"); + it("returns A for the lower half of the range", () => { + expect(pickHomepageVariant(() => 0)).toBe("A"); + expect(pickHomepageVariant(() => 0.499)).toBe("A"); }); - it("returns treatment for the upper half of the range", () => { - expect(pickHomepageVariant(() => 0.5)).toBe("treatment"); - expect(pickHomepageVariant(() => 0.999)).toBe("treatment"); + it("returns B for the upper half of the range", () => { + expect(pickHomepageVariant(() => 0.5)).toBe("B"); + expect(pickHomepageVariant(() => 0.999)).toBe("B"); }); }); diff --git a/src/utils/homepageExperiment.ts b/src/utils/homepageExperiment.ts index e1e59a74..768267f5 100644 --- a/src/utils/homepageExperiment.ts +++ b/src/utils/homepageExperiment.ts @@ -1,13 +1,13 @@ export const HOMEPAGE_EXPERIMENT = "homepage-description"; -export const EXPOSURE_EVENT = "Experiment Exposure"; -export const CTA_CLICK_EVENT = "CTA Click"; +export const EXPOSURE_EVENT = "Homepage Description Shown"; +export const CTA_CLICK_EVENT = "Homepage Description CTA Click"; -export type HomepageVariant = "control" | "treatment"; +export type HomepageVariant = "A" | "B"; // Assigned per request on the server: nothing is stored, so a visitor can see a // different variant on each page view. That is the price of needing no consent banner. export function pickHomepageVariant(random: () => number = Math.random): HomepageVariant { - return random() < 0.5 ? "control" : "treatment"; + return random() < 0.5 ? "A" : "B"; } interface PlausibleWindow { From c58b386bf9b69cae454af8b2fb53d4f7fb21b177 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 16:13:10 +0200 Subject: [PATCH 08/11] fix(home): only count homepage test exposures the visitor actually sees Layout prerenders / from every page and prerendered pages run their scripts, so the exposure event now waits for prerenderingchange. Middle-clicks on the hero cards now count as clicks. Tracking takes an injectable tracker and document so it is unit tested. Drop the GrowthBook localStorage polyfill: the test no longer uses GrowthBook, the homepage still writes localStorage elsewhere, and the polyfill cost a CDN fetch on every page load. --- src/lib/growthbook.ts | 7 +- src/utils/homepageExperiment.test.ts | 114 ++++++++++++++++++++++++++- src/utils/homepageExperiment.ts | 46 ++++++++--- 3 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/lib/growthbook.ts b/src/lib/growthbook.ts index 75f24a48..ddd836f7 100644 --- a/src/lib/growthbook.ts +++ b/src/lib/growthbook.ts @@ -1,4 +1,4 @@ -import { GrowthBook, setPolyfills } from "@growthbook/growthbook-react"; +import { GrowthBook } from "@growthbook/growthbook-react"; const clientKey = import.meta.env.PUBLIC_GROWTHBOOK_CLIENT_KEY; @@ -10,11 +10,6 @@ if (!clientKey) { ); } -// Keep the homepage free of browser storage. The SDK persists its flag payload to -// localStorage on every fetch, and configureCache({ disableCache }) does not stop that -// write, so swap in a no-op storage. The payload is then refetched on every page load. -setPolyfills({ localStorage: { getItem: () => null, setItem: () => {} } }); - export const growthbook = new GrowthBook({ apiHost: "https://cdn.growthbook.io", clientKey, diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts index c4ef725b..34331c98 100644 --- a/src/utils/homepageExperiment.test.ts +++ b/src/utils/homepageExperiment.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "vitest"; -import { pickHomepageVariant } from "./homepageExperiment"; +import { describe, expect, it, vi } from "vitest"; +import { + CTA_CLICK_EVENT, + EXPOSURE_EVENT, + pickHomepageVariant, + trackHeroCtas, + type PrerenderAwareDocument, +} from "./homepageExperiment"; describe("pickHomepageVariant", () => { it("returns A for the lower half of the range", () => { @@ -12,3 +18,107 @@ describe("pickHomepageVariant", () => { expect(pickHomepageVariant(() => 0.999)).toBe("B"); }); }); + +// Vitest runs in node, so the container and document are minimal fakes. +type Listener = (event: MouseEvent) => void; + +function fakeContainer(dataset: Record) { + const listeners = new Map>(); + const container = { + dataset, + addEventListener: (type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(listener); + }, + removeEventListener: (type: string, listener: Listener) => { + listeners.get(type)?.delete(listener); + }, + }; + const fire = (type: string, cta: string | null, button = 0) => { + const link = cta ? { dataset: { cta } } : null; + const event = { type, button, target: { closest: () => link } } as unknown as MouseEvent; + listeners.get(type)?.forEach((listener) => listener(event)); + }; + return { container: container as unknown as HTMLElement, fire }; +} + +function fakeDocument(prerendering: boolean) { + let activate: (() => void) | undefined; + const doc: PrerenderAwareDocument = { + prerendering, + addEventListener: (_type, listener) => { + activate = listener; + }, + }; + return { doc, activate: () => activate?.() }; +} + +const props = { experiment: "homepage-description", variant: "B" }; + +describe("trackHeroCtas", () => { + it("records one exposure straight away when the page is visible", () => { + const track = vi.fn(); + const { container } = fakeContainer(props); + + trackHeroCtas(container, { track, doc: fakeDocument(false).doc }); + + expect(track).toHaveBeenCalledOnce(); + expect(track).toHaveBeenCalledWith(EXPOSURE_EVENT, props); + }); + + it("waits for a prerendered page to be shown before recording the exposure", () => { + const track = vi.fn(); + const { container } = fakeContainer(props); + const { doc, activate } = fakeDocument(true); + + trackHeroCtas(container, { track, doc }); + expect(track).not.toHaveBeenCalled(); + + activate(); + expect(track).toHaveBeenCalledWith(EXPOSURE_EVENT, props); + }); + + it("does nothing without experiment and variant data attributes", () => { + const track = vi.fn(); + const { container, fire } = fakeContainer({}); + + trackHeroCtas(container, { track, doc: fakeDocument(false).doc }); + fire("click", "membership"); + + expect(track).not.toHaveBeenCalled(); + }); + + it("records the clicked card and only the first click", () => { + const track = vi.fn(); + const { container, fire } = fakeContainer(props); + + trackHeroCtas(container, { track, doc: fakeDocument(false).doc }); + fire("click", "incubator"); + fire("click", "membership"); + + expect(track).toHaveBeenCalledTimes(2); + expect(track).toHaveBeenLastCalledWith(CTA_CLICK_EVENT, { ...props, cta: "incubator" }); + }); + + it("ignores clicks outside the cards", () => { + const track = vi.fn(); + const { container, fire } = fakeContainer(props); + + trackHeroCtas(container, { track, doc: fakeDocument(false).doc }); + fire("click", null); + + expect(track).toHaveBeenCalledOnce(); + }); + + it("counts middle-clicks but not right-clicks", () => { + const track = vi.fn(); + const { container, fire } = fakeContainer(props); + + trackHeroCtas(container, { track, doc: fakeDocument(false).doc }); + fire("auxclick", "membership", 2); + expect(track).toHaveBeenCalledOnce(); + + fire("auxclick", "membership", 1); + expect(track).toHaveBeenLastCalledWith(CTA_CLICK_EVENT, { ...props, cta: "membership" }); + }); +}); diff --git a/src/utils/homepageExperiment.ts b/src/utils/homepageExperiment.ts index c76a1c02..b8abcada 100644 --- a/src/utils/homepageExperiment.ts +++ b/src/utils/homepageExperiment.ts @@ -4,35 +4,63 @@ export const CTA_CLICK_EVENT = "Homepage Description CTA Click"; export type HomepageVariant = "A" | "B"; +const VARIANT_A_SHARE = 0.5; +const MIDDLE_BUTTON = 1; + // Assigned per request on the server: nothing is stored, so a visitor can see a // different variant on each page view. That is the price of needing no consent banner. export function pickHomepageVariant(random: () => number = Math.random): HomepageVariant { - return random() < 0.5 ? "A" : "B"; + return random() < VARIANT_A_SHARE ? "A" : "B"; } -interface PlausibleWindow { - plausible?: (name: string, options?: { props: Record }) => void; +export type TrackEvent = (name: string, props: Record) => void; + +// The parts of Document this needs. `prerendering` is not in TypeScript's DOM lib yet. +export interface PrerenderAwareDocument { + prerendering?: boolean; + addEventListener( + type: "prerenderingchange", + listener: () => void, + options?: AddEventListenerOptions + ): void; } -export function trackExperimentEvent(name: string, props: Record): void { - (window as PlausibleWindow).plausible?.(name, { props }); +export interface TrackHeroCtasOptions { + track?: TrackEvent; + doc?: PrerenderAwareDocument; } -// Fires one exposure per page view for the container carrying data-experiment / +const trackWithPlausible: TrackEvent = (name, props) => { + window.plausible?.(name, { props }); +}; + +// Records one exposure per page view for the container carrying data-experiment / // data-variant, and at most one click per page view across its data-cta links, tagged with // which link was clicked. -export function trackHeroCtas(container: HTMLElement): void { +// +// Layout prerenders "/" from every page, and prerendered pages run their scripts, so the +// exposure waits until the page is actually shown. Middle-clicks (open in new tab) count too. +export function trackHeroCtas(container: HTMLElement, options: TrackHeroCtasOptions = {}): void { + const { track = trackWithPlausible, doc = document as PrerenderAwareDocument } = options; const { experiment, variant } = container.dataset; if (!experiment || !variant) return; - trackExperimentEvent(EXPOSURE_EVENT, { experiment, variant }); + const recordExposure = (): void => track(EXPOSURE_EVENT, { experiment, variant }); + if (doc.prerendering) { + doc.addEventListener("prerenderingchange", recordExposure, { once: true }); + } else { + recordExposure(); + } const onClick = (event: MouseEvent): void => { + if (event.type === "auxclick" && event.button !== MIDDLE_BUTTON) return; const link = (event.target as Element | null)?.closest("a[data-cta]"); const cta = link?.dataset.cta; if (!cta) return; container.removeEventListener("click", onClick); - trackExperimentEvent(CTA_CLICK_EVENT, { experiment, variant, cta }); + container.removeEventListener("auxclick", onClick); + track(CTA_CLICK_EVENT, { experiment, variant, cta }); }; container.addEventListener("click", onClick); + container.addEventListener("auxclick", onClick); } From 63d2d5561922b9492da610f70cb508c6cbef903d Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 16:17:11 +0200 Subject: [PATCH 09/11] feat(home): simplify hero CTAs to two one-line buttons Replace the two hero cards with a primary "Become a Member" pill and an outlined "Apply to the Incubator" pill. Click tracking is unchanged. --- src/pages/index.astro | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index e4f442c1..940ff14c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -154,30 +154,13 @@ const press = [ const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove"; const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`; +const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`; // Homepage description test. The variant is chosen per request and rendered into the HTML, // so there is no flicker and nothing is stored. The page must not be cached or every visitor // in the cache window would get the same variant. const variant = pickHomepageVariant(); Astro.response.headers.set("Cache-Control", "no-store"); -// The hero's two paths: each card is one link, a sentence then the action. -const heroPaths = [ - { - href: "/membership", - cta: "membership", - body: "Join the community, build projects, and start your own through T4P:", - action: "Become a Member", - tone: "border-grove bg-grove text-white hover:border-grove-hover hover:bg-grove-hover", - }, - { - href: "/incubator", - cta: "incubator", - body: "If you run your own pro-Palestine project, and want help scaling:", - action: "Apply to the Incubator", - tone: "border-ink-divider bg-paper text-ink hover:border-grove hover:shadow-sm", - }, -]; -const heroCard = `flex flex-col rounded-lg border p-6 transition-[background-color,border-color,box-shadow] duration-150 ${focusRing}`; const cardBase = `group relative flex flex-col rounded-lg border border-ink-divider bg-paper transition-[border-color,box-shadow] duration-150 hover:border-grove hover:shadow-sm ${focusRing}`; --- @@ -250,20 +233,10 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi id="hero-ctas" data-experiment={HOMEPAGE_EXPERIMENT} data-variant={variant} - class="mt-8 grid max-w-[44rem] grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-3" + class="mt-8 flex flex-wrap gap-3" > - { - heroPaths.map((path) => ( - - - {path.body} - - - {path.action} - - - )) - } + Become a Member + Apply to the Incubator From 20e6994bb7c5632f53bd8976ef22c6083988ea1e Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 16:22:56 +0200 Subject: [PATCH 10/11] feat(volunteer): point volunteer form links to membership Open-role buttons and the open-roles error/empty fallbacks linked to the Formbricks volunteer survey. They now all go to /membership. Remove the unused survey URL, /volunteer-form constant and role link builder. --- src/components/volunteer/OpenRoleCard.tsx | 12 +++---- src/components/volunteer/OpenRoles.tsx | 10 +++--- src/components/volunteer/openRoleData.test.ts | 34 ------------------- src/components/volunteer/openRoleData.ts | 23 +------------ 4 files changed, 10 insertions(+), 69 deletions(-) diff --git a/src/components/volunteer/OpenRoleCard.tsx b/src/components/volunteer/OpenRoleCard.tsx index 73559494..0331e2f2 100644 --- a/src/components/volunteer/OpenRoleCard.tsx +++ b/src/components/volunteer/OpenRoleCard.tsx @@ -1,6 +1,6 @@ import React, { useId, useMemo, useState } from "react"; import { parseDescriptionHtml, type DescriptionNode, type DescriptionTag } from "./descriptionHtml"; -import { getApplyTarget, type OpenRole } from "./openRoleData"; +import { MEMBERSHIP_URL, type OpenRole } from "./openRoleData"; import type { SkinTokens } from "./openRoleSkins"; /** Descriptions shorter than this read fine in full, so they get no toggle. */ @@ -89,7 +89,6 @@ interface OpenRoleCardProps { export default function OpenRoleCard({ role, tokens }: OpenRoleCardProps) { const [expanded, setExpanded] = useState(false); const descriptionId = useId(); - const apply = getApplyTarget(role); const isTeamRole = role.type === "team"; const canCollapse = role.description.length > CLAMP_THRESHOLD; const isCollapsed = canCollapse && !expanded; @@ -118,12 +117,9 @@ export default function OpenRoleCard({ role, tokens }: OpenRoleCardProps) {

{role.title}

- - {apply.label} - as {role.title} + + Become a member + to apply as {role.title} diff --git a/src/components/volunteer/OpenRoles.tsx b/src/components/volunteer/OpenRoles.tsx index 1f438449..5a5e17f5 100644 --- a/src/components/volunteer/OpenRoles.tsx +++ b/src/components/volunteer/OpenRoles.tsx @@ -4,8 +4,8 @@ import { getPageCount, getPageMarkers, getSkillFilters, + MEMBERSHIP_URL, normalizeOpenRoles, - VOLUNTEER_FORM_URL, type OpenRole, } from "./openRoleData"; import { getSkinTokens, type OpenRolesSkin } from "./openRoleSkins"; @@ -106,8 +106,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) {

We couldn’t load open roles just now. Try again, or{" "} - - apply to volunteer + + become a member {" "} and we’ll match you to a role.

@@ -126,8 +126,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) { return (

No roles are open right now.{" "} - - Apply anyway + + Become a member {" "} and we’ll get in touch when one opens up.

diff --git a/src/components/volunteer/openRoleData.test.ts b/src/components/volunteer/openRoleData.test.ts index e9c6c6c1..4c37819e 100644 --- a/src/components/volunteer/openRoleData.test.ts +++ b/src/components/volunteer/openRoleData.test.ts @@ -2,13 +2,9 @@ import { describe, expect, it } from "vitest"; import { attachProjectLinks, clampPage, - getApplyTarget, getPageCount, getPageMarkers, - getRoleApplicationUrl, - MEMBERSHIP_URL, normalizeOpenRoles, - VOLUNTEER_SURVEY_URL, type OpenRole, } from "./openRoleData"; @@ -26,36 +22,6 @@ const projectRole: OpenRole = { createdAt: "2026-10-14T18:35:10.338Z", }; -describe("getRoleApplicationUrl", () => { - it("passes the role id, title and project name as encoded survey params", () => { - const url = new URL(getRoleApplicationUrl(projectRole)); - - expect(`${url.origin}${url.pathname}`).toBe(VOLUNTEER_SURVEY_URL); - expect(url.searchParams.get("role_id")).toBe(projectRole.id); - expect(url.searchParams.get("role_title")).toBe("Data Entry & Research"); - expect(url.searchParams.get("org_name")).toBe("Demilitarise Education"); - }); - - it("omits org_name when the role has no project or team name", () => { - const url = new URL(getRoleApplicationUrl({ ...projectRole, orgName: "" })); - - expect(url.searchParams.has("org_name")).toBe(false); - }); -}); - -describe("getApplyTarget", () => { - it("sends project roles to the volunteer survey with the role attached", () => { - const target = getApplyTarget(projectRole); - - expect(target.label).toBe("Apply to volunteer"); - expect(target.href).toBe(getRoleApplicationUrl(projectRole)); - }); - - it("sends team roles to membership", () => { - expect(getApplyTarget({ ...projectRole, type: "team" }).href).toBe(MEMBERSHIP_URL); - }); -}); - describe("normalizeOpenRoles", () => { const hubRow = { id: "14a4bd4a-a8ab-4dec-9c05-730f062a56dc", diff --git a/src/components/volunteer/openRoleData.ts b/src/components/volunteer/openRoleData.ts index 4767451c..b10da72c 100644 --- a/src/components/volunteer/openRoleData.ts +++ b/src/components/volunteer/openRoleData.ts @@ -36,23 +36,9 @@ export interface OpenRole { createdAt: string; } -export const VOLUNTEER_FORM_URL = "https://techforpalestine.org/volunteer-form"; +/** Every role, project or team, is applied for by becoming a member. */ export const MEMBERSHIP_URL = "/membership"; -/** - * The Formbricks survey `/volunteer-form` redirects to. Role links point here - * directly because that Cloudflare redirect drops the query string, and the - * survey reads `role_id`, `role_title` and `org_name` as passed-in hidden fields. - */ -export const VOLUNTEER_SURVEY_URL = "https://app.formbricks.com/s/clzavvkp80002yqhejhqgoa3a"; - -/** Volunteer survey link carrying the role, so the response records what was applied for. */ -export function getRoleApplicationUrl(role: OpenRole): string { - const params = new URLSearchParams({ role_id: role.id, role_title: role.title }); - if (role.orgName) params.set("org_name", role.orgName); - return `${VOLUNTEER_SURVEY_URL}?${params.toString()}`; -} - /** Upstream descriptions are free text; cap them so one bad row can't bloat the page. */ const MAX_DESCRIPTION_LENGTH = 4000; /** Markup roughly doubles a description's length, so its cap is looser. */ @@ -147,13 +133,6 @@ export function attachProjectLinks( })); } -/** Where a role's apply button points, per how the role is staffed. */ -export function getApplyTarget(role: OpenRole): { href: string; label: string } { - return role.type === "team" - ? { href: MEMBERSHIP_URL, label: "Become a member" } - : { href: getRoleApplicationUrl(role), label: "Apply to volunteer" }; -} - export interface SkillFilter { name: string; count: number; From 45aa6bb441bc4a04954f4db25a78d0cea4aa10af Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sun, 25 Oct 2026 16:45:31 +0200 Subject: [PATCH 11/11] feat(home): put each hero CTA's sentence inside its button The hero buttons now read as one line of text with the action in bold: "Join the community, build projects, and start your own through T4P: Become a Member" and "If you run your own pro-Palestine project, and want help scaling: Apply to the Incubator". Click tracking is unchanged. --- src/pages/index.astro | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index 940ff14c..7a2dd25a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -154,7 +154,10 @@ const press = [ const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-grove"; const pillPrimary = `inline-flex items-center justify-center rounded-pill bg-grove px-7 py-3.5 text-[15px] font-bold text-white transition-colors duration-150 hover:bg-grove-hover ${focusRing}`; -const pillGhost = `inline-flex items-center justify-center rounded-pill border border-ink-divider px-7 py-3.5 text-[15px] font-bold text-ink transition-colors duration-150 hover:border-grove hover:text-grove ${focusRing}`; +// Hero buttons carry a sentence then the action, so they wrap as running text, not flex items. +const heroButton = `inline-block rounded-pill px-7 py-3.5 text-left text-[15px] leading-[1.45] transition-colors duration-150 ${focusRing}`; +const heroPrimary = `${heroButton} bg-grove text-white hover:bg-grove-hover`; +const heroGhost = `${heroButton} border border-ink-divider text-ink hover:border-grove hover:text-grove`; // Homepage description test. The variant is chosen per request and rendered into the HTML, // so there is no flicker and nothing is stored. The page must not be cached or every visitor @@ -233,10 +236,16 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi id="hero-ctas" data-experiment={HOMEPAGE_EXPERIMENT} data-variant={variant} - class="mt-8 flex flex-wrap gap-3" + class="mt-8 flex flex-col items-start gap-3" > - Become a Member - Apply to the Incubator + + Join the community, build projects, and start your own through T4P: + Become a Member + + + If you run your own pro-Palestine project, and want help scaling: + Apply to the Incubator +