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; diff --git a/src/pages/index.astro b/src/pages/index.astro index c62ca0ff..7a2dd25a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,6 +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 { HOMEPAGE_EXPERIMENT, pickHomepageVariant } from "../utils/homepageExperiment"; import "../styles/base.css"; const stats = [ @@ -153,22 +154,16 @@ 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}`; -// The hero's two paths: each card is one link, a sentence then the action. -const heroPaths = [ - { - href: "/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", - 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}`; +// 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 +// in the cache window would get the same variant. +const variant = pickHomepageVariant(); +Astro.response.headers.set("Cache-Control", "no-store"); 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}`; --- @@ -197,33 +192,60 @@ 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 === "A" ? ( + <> +

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

+ + ) + } -
- { - heroPaths.map((path) => ( - - - {path.body} - - - {path.action} - - - )) - } +
@@ -518,6 +540,10 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi writePopupState, } from "../utils/newsletterPopup"; import { watchSignupSuccess } from "../utils/watchSignupSuccess"; + import { trackHeroCtas } from "../utils/homepageExperiment"; + + const heroCtas = document.getElementById("hero-ctas"); + if (heroCtas) trackHeroCtas(heroCtas); const bottomForm = document.getElementById(FOOTER_SECTION_ID); if (bottomForm) { diff --git a/src/utils/homepageExperiment.test.ts b/src/utils/homepageExperiment.test.ts new file mode 100644 index 00000000..34331c98 --- /dev/null +++ b/src/utils/homepageExperiment.test.ts @@ -0,0 +1,124 @@ +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", () => { + expect(pickHomepageVariant(() => 0)).toBe("A"); + expect(pickHomepageVariant(() => 0.499)).toBe("A"); + }); + + it("returns B for the upper half of the range", () => { + expect(pickHomepageVariant(() => 0.5)).toBe("B"); + 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 new file mode 100644 index 00000000..b8abcada --- /dev/null +++ b/src/utils/homepageExperiment.ts @@ -0,0 +1,66 @@ +export const HOMEPAGE_EXPERIMENT = "homepage-description"; +export const EXPOSURE_EVENT = "Homepage Description Shown"; +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() < VARIANT_A_SHARE ? "A" : "B"; +} + +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 interface TrackHeroCtasOptions { + track?: TrackEvent; + doc?: PrerenderAwareDocument; +} + +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. +// +// 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; + + 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); + container.removeEventListener("auxclick", onClick); + track(CTA_CLICK_EVENT, { experiment, variant, cta }); + }; + container.addEventListener("click", onClick); + container.addEventListener("auxclick", onClick); +}