Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/components/volunteer/OpenRoleCard.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -118,12 +117,9 @@ export default function OpenRoleCard({ role, tokens }: OpenRoleCardProps) {
<h3 className={`${tokens.title} break-words`}>{role.title}</h3>
</div>

<a
href={apply.href}
className={`${isTeamRole ? tokens.applyTeam : tokens.applyProject} ${tokens.focus} shrink-0`}
>
{apply.label}
<span className="sr-only"> as {role.title}</span>
<a href={MEMBERSHIP_URL} className={`${tokens.applyTeam} ${tokens.focus} shrink-0`}>
Become a member
<span className="sr-only"> to apply as {role.title}</span>
</a>
</div>

Expand Down
10 changes: 5 additions & 5 deletions src/components/volunteer/OpenRoles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
getPageCount,
getPageMarkers,
getSkillFilters,
MEMBERSHIP_URL,
normalizeOpenRoles,
VOLUNTEER_FORM_URL,
type OpenRole,
} from "./openRoleData";
import { getSkinTokens, type OpenRolesSkin } from "./openRoleSkins";
Expand Down Expand Up @@ -106,8 +106,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) {
<div className={tokens.notice}>
<p>
We couldn&rsquo;t load open roles just now. Try again, or{" "}
<a href={VOLUNTEER_FORM_URL} className={`${tokens.link} ${tokens.focus}`}>
apply to volunteer
<a href={MEMBERSHIP_URL} className={`${tokens.link} ${tokens.focus}`}>
become a member
</a>{" "}
and we&rsquo;ll match you to a role.
</p>
Expand All @@ -126,8 +126,8 @@ export default function OpenRoles({ skin = "classic" }: OpenRolesProps) {
return (
<p className={tokens.notice}>
No roles are open right now.{" "}
<a href={VOLUNTEER_FORM_URL} className={`${tokens.link} ${tokens.focus}`}>
Apply anyway
<a href={MEMBERSHIP_URL} className={`${tokens.link} ${tokens.focus}`}>
Become a member
</a>{" "}
and we&rsquo;ll get in touch when one opens up.
</p>
Expand Down
34 changes: 0 additions & 34 deletions src/components/volunteer/openRoleData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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",
Expand Down
23 changes: 1 addition & 22 deletions src/components/volunteer/openRoleData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
110 changes: 68 additions & 42 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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}`;
---

Expand Down Expand Up @@ -197,33 +192,60 @@ const cardBase = `group relative flex flex-col rounded-lg border border-ink-divi
Join the tech movement behind <span class="text-grove">90+ projects for Palestine</span>
</h1>

<p
class="mt-5 max-w-[58ch] text-[19px] leading-[1.6] text-ink-secondary min-[810px]:text-[21px]"
>
Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for
Palestinian liberation.
</p>
<p
class="mt-3 max-w-[58ch] text-[17px] leading-[1.6] text-ink-secondary min-[810px]:text-[18px]"
>
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.
</p>
{
variant === "A" ? (
<>
<p class="mt-5 max-w-[58ch] text-[19px] leading-[1.6] text-ink-secondary min-[810px]:text-[21px]">
Tech for Palestine is a 501(c)(3) nonprofit starting and scaling initiatives for
Palestinian liberation.
</p>
<p class="mt-3 max-w-[58ch] text-[17px] leading-[1.6] text-ink-secondary min-[810px]:text-[18px]">
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.
</p>
</>
) : (
<>
<p class="mt-5 max-w-[58ch] text-[19px] leading-[1.6] text-ink-secondary min-[810px]:text-[21px]">
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.
</p>
<p class="mt-3 max-w-[58ch] text-[17px] leading-[1.6] text-ink-secondary min-[810px]:text-[18px]">
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.
</p>
<p class="mt-3 max-w-[58ch] text-[17px] leading-[1.6] text-ink-secondary min-[810px]:text-[18px]">
Whether you're a tech worker, founder, marketer, activist - there's a place for you
to contribute at T4P.
</p>
<p class="mt-3 max-w-[58ch] text-[17px] leading-[1.6] text-ink-secondary min-[810px]:text-[18px]">
<strong class="font-bold text-ink">Our Vision:</strong> A free Palestine, a tech
industry that actively participates in liberation, and a world where all people
enjoy equal dignity, justice, and human rights.
</p>
</>
)
}

<div class="mt-8 grid max-w-[44rem] grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-3">
{
heroPaths.map((path) => (
<a href={path.href} class:list={[heroCard, path.tone]}>
<span class="text-[16px] leading-[1.55] opacity-90 min-[810px]:text-[17px]">
{path.body}
</span>
<span class="mt-auto pt-4 text-[19px] font-extrabold leading-[1.2]">
{path.action}
</span>
</a>
))
}
<div
id="hero-ctas"
data-experiment={HOMEPAGE_EXPERIMENT}
data-variant={variant}
class="mt-8 flex flex-col items-start gap-3"
>
<a href="/membership" data-cta="membership" class={heroPrimary}>
Join the community, build projects, and start your own through T4P:
<strong class="font-bold">Become a Member</strong>
</a>
<a href="/incubator" data-cta="incubator" class={heroGhost}>
If you run your own pro-Palestine project, and want help scaling:
<strong class="font-bold">Apply to the Incubator</strong>
</a>
</div>
</div>

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading