+ {/* Several descriptions open with a short heading line, which would
+ spend the three-line clamp on almost no content. Flattening the
+ whitespace while collapsed gives three full lines of preview. */}
+ {isCollapsed ? role.description.replace(/\s+/g, " ") : role.description}
+
+ {visibleRoles.length === 1 ? "1 open role" : `${visibleRoles.length} open roles`}
+ {activeSkill !== ALL_SKILLS && ` in ${activeSkill}`}
+
+
+
+ {visibleRoles.map((role) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/volunteer/openRoleData.ts b/src/components/volunteer/openRoleData.ts
new file mode 100644
index 00000000..5addbafa
--- /dev/null
+++ b/src/components/volunteer/openRoleData.ts
@@ -0,0 +1,108 @@
+/**
+ * Shape and normalization helpers for open volunteer roles coming from the
+ * T4P Hub (`/api/public/open-roles`, proxied through `/api/open-roles`).
+ *
+ * The upstream payload is nested (`project.name` / `team.name`) and carries
+ * fields the website has no use for. Normalizing at the boundary keeps the UI
+ * free of optional-chaining noise and means a shape change upstream fails in
+ * one place.
+ */
+
+/** How a role is staffed, which decides where its apply button points. */
+export type OpenRoleType = "project" | "team";
+
+export interface OpenRole {
+ id: string;
+ title: string;
+ description: string;
+ /** Name of the coalition project or T4P team the role sits in. */
+ orgName: string;
+ type: OpenRoleType;
+ skillCategories: string[];
+ areasOfInterest: string[];
+ /** Free text, e.g. "5 hours a week". Absent on most roles. */
+ timeCommitment: string;
+ createdAt: string;
+}
+
+export const VOLUNTEER_FORM_URL = "https://techforpalestine.org/volunteer-form";
+export const MEMBERSHIP_URL = "/membership";
+
+/** Upstream descriptions are free text; cap them so one bad row can't bloat the page. */
+const MAX_DESCRIPTION_LENGTH = 4000;
+
+function toTrimmedString(value: unknown, maxLength = 500): string {
+ return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
+}
+
+function toStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.map((entry) => toTrimmedString(entry, 120)).filter(Boolean);
+}
+
+function toOrgName(raw: Record): string {
+ const nested = (key: "project" | "team") => {
+ const container = raw[key];
+ if (!container || typeof container !== "object") return "";
+ return toTrimmedString((container as Record).name, 200);
+ };
+ return nested("project") || nested("team");
+}
+
+/**
+ * Converts one upstream row into an `OpenRole`, or returns null when the row
+ * lacks the fields the UI needs to render anything meaningful.
+ */
+export function normalizeOpenRole(raw: unknown): OpenRole | null {
+ if (!raw || typeof raw !== "object") return null;
+ const row = raw as Record;
+
+ const id = toTrimmedString(row.id, 100);
+ const title = toTrimmedString(row.title, 200);
+ if (!id || !title) return null;
+
+ return {
+ id,
+ title,
+ description: toTrimmedString(row.description, MAX_DESCRIPTION_LENGTH),
+ orgName: toOrgName(row),
+ type: row.type === "team" ? "team" : "project",
+ skillCategories: toStringArray(row.skillCategories),
+ areasOfInterest: toStringArray(row.areasOfInterest),
+ timeCommitment: toTrimmedString(row.timeCommitment, 120),
+ createdAt: toTrimmedString(row.createdAt, 40),
+ };
+}
+
+export function normalizeOpenRoles(raw: unknown): OpenRole[] {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map(normalizeOpenRole)
+ .filter((role): role is OpenRole => role !== null)
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+}
+
+/** 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: VOLUNTEER_FORM_URL, label: "Apply to volunteer" };
+}
+
+export interface SkillFilter {
+ name: string;
+ count: number;
+}
+
+/** Skill categories present in the given roles, most common first. */
+export function getSkillFilters(roles: OpenRole[]): SkillFilter[] {
+ const counts = new Map();
+ for (const role of roles) {
+ for (const skill of role.skillCategories) {
+ counts.set(skill, (counts.get(skill) ?? 0) + 1);
+ }
+ }
+ return [...counts.entries()]
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
+}
diff --git a/src/components/volunteer/openRoleSkins.ts b/src/components/volunteer/openRoleSkins.ts
new file mode 100644
index 00000000..23ea7226
--- /dev/null
+++ b/src/components/volunteer/openRoleSkins.ts
@@ -0,0 +1,92 @@
+/**
+ * The open-roles list appears on two pages built in two different visual
+ * systems: `/volunteer` uses `Layout.astro` (indigo/green, white cards, system
+ * font) and `/volunteer-new` uses `HomeLayout.astro` (cream/sand surfaces, rose
+ * brand accent, Fraunces/Outfit via the `ts-*` scale, which is only loaded by
+ * that layout). Rather than give the section a third identity of its own, each
+ * skin borrows the tokens of the page hosting it.
+ */
+export type OpenRolesSkin = "classic" | "brand";
+
+export interface SkinTokens {
+ heading: string;
+ overline: string;
+ intro: string;
+ count: string;
+ chip: string;
+ chipActive: string;
+ chipIdle: string;
+ card: string;
+ org: string;
+ orgMarker: string;
+ title: string;
+ pill: string;
+ body: string;
+ detailLabel: string;
+ disclosure: string;
+ applyProject: string;
+ applyTeam: string;
+ link: string;
+ skeleton: string;
+ notice: string;
+ focus: string;
+}
+
+const CLASSIC: SkinTokens = {
+ heading: "text-3xl font-bold text-gray-800",
+ overline: "",
+ intro: "text-lg text-gray-600",
+ count: "text-sm text-gray-500",
+ chip: "inline-flex min-h-[44px] items-center rounded-full border px-4 text-sm font-medium transition-colors",
+ chipActive: "border-indigo-600 bg-indigo-600 text-white",
+ chipIdle: "border-gray-300 bg-white text-gray-600 hover:border-indigo-400 hover:text-gray-900",
+ card: "rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-colors hover:border-indigo-300",
+ org: "text-sm font-semibold text-indigo-600",
+ orgMarker: "rounded-full border border-green-600 px-2 py-0.5 text-xs font-medium text-green-700",
+ title: "text-xl font-bold text-gray-900",
+ pill: "inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600",
+ body: "text-base leading-relaxed text-gray-600",
+ detailLabel: "text-sm font-semibold text-gray-700",
+ disclosure:
+ "inline-flex min-h-[44px] items-center gap-1.5 text-sm font-semibold text-indigo-600 hover:underline",
+ applyProject:
+ "inline-flex min-h-[44px] items-center rounded-full bg-indigo-600 px-5 text-sm font-semibold text-white transition hover:bg-indigo-700",
+ applyTeam:
+ "inline-flex min-h-[44px] items-center rounded-full bg-green-600 px-5 text-sm font-semibold text-white transition hover:bg-green-700",
+ link: "font-medium text-indigo-600 hover:underline",
+ skeleton: "rounded-2xl border border-gray-200 bg-gray-50",
+ notice: "rounded-2xl border border-gray-200 bg-white p-6 text-gray-600",
+ focus: "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
+};
+
+const BRAND: SkinTokens = {
+ heading: "ts-heading text-ink",
+ overline: "ts-overline text-ink-secondary",
+ intro: "ts-body-large text-ink-secondary",
+ count: "ts-body-small text-ink-secondary",
+ chip: "ts-caption inline-flex min-h-[44px] items-center rounded-pill border px-4 transition-colors",
+ chipActive: "border-brand bg-brand text-white",
+ chipIdle:
+ "border-ink-divider bg-transparent text-ink-secondary hover:border-brand/40 hover:text-ink",
+ card: "rounded-[20px] border border-butter bg-sand p-6 transition-colors hover:border-brand/30 hover:bg-cream min-[810px]:p-8",
+ org: "ts-body-small font-medium text-brand",
+ orgMarker: "ts-caption rounded-pill border border-ink-divider px-2 py-0.5 text-ink-secondary",
+ title: "ts-quote text-ink",
+ pill: "ts-caption inline-flex items-center gap-1.5 rounded-pill border border-ink-divider px-3 py-1 text-ink-secondary",
+ body: "ts-body leading-relaxed text-ink-secondary",
+ detailLabel: "ts-body-small font-medium text-ink",
+ disclosure:
+ "ts-body-small inline-flex min-h-[44px] items-center gap-1.5 text-brand hover:underline",
+ applyProject:
+ "ts-label inline-flex min-h-[44px] items-center rounded-pill border border-transparent bg-brand px-5 text-white transition-all duration-150 hover:bg-brand-hover active:scale-[0.98]",
+ applyTeam:
+ "ts-label inline-flex min-h-[44px] items-center rounded-pill border border-ink bg-transparent px-5 text-ink transition-all duration-150 hover:bg-ink/5 active:scale-[0.98]",
+ link: "font-medium text-brand hover:underline",
+ skeleton: "rounded-[20px] border border-butter bg-sand",
+ notice: "ts-body rounded-[20px] border border-butter bg-sand p-6 text-ink-secondary",
+ focus: "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
+};
+
+export function getSkinTokens(skin: OpenRolesSkin): SkinTokens {
+ return skin === "brand" ? BRAND : CLASSIC;
+}
diff --git a/src/pages/api/open-roles.ts b/src/pages/api/open-roles.ts
new file mode 100644
index 00000000..4a1d3e92
--- /dev/null
+++ b/src/pages/api/open-roles.ts
@@ -0,0 +1,88 @@
+import type { APIRoute } from "astro";
+import * as Sentry from "@sentry/astro";
+import { reportError } from "../../lib/report-error";
+import { normalizeOpenRoles } from "../../components/volunteer/openRoleData";
+
+export const prerender = false;
+
+const HUB_OPEN_ROLES_URL = "https://hub.techforpalestine.org/api/public/open-roles";
+
+/** Upstream can cold-start; one retry on a 5xx covers it without stalling the page. */
+const MAX_RETRIES = 1;
+const RETRY_DELAY_MS = 500;
+
+async function fetchOpenRoles(): Promise {
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const response = await fetch(HUB_OPEN_ROLES_URL, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ "User-Agent": "T4P-Website/1.0",
+ },
+ });
+
+ // 4xx is a real answer — retrying won't change it.
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
+ return response;
+ }
+ lastError = new Error(`Hub API returned ${response.status}`);
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+ }
+
+ if (attempt < MAX_RETRIES) {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
+ }
+ }
+
+ throw lastError ?? new Error("Failed to reach Hub API");
+}
+
+export const GET: APIRoute = async ({ locals }) => {
+ const ctx = locals.runtime?.ctx;
+
+ try {
+ const response = await fetchOpenRoles();
+ if (!response.ok) {
+ throw new Error(`Hub API returned ${response.status}: ${response.statusText}`);
+ }
+
+ const payload = await response.json();
+ // Tolerate both a bare array and a wrapped envelope.
+ const rows = Array.isArray(payload)
+ ? payload
+ : Array.isArray(payload?.data)
+ ? payload.data
+ : Array.isArray(payload?.roles)
+ ? payload.roles
+ : [];
+
+ const roles = normalizeOpenRoles(rows);
+
+ return new Response(JSON.stringify({ roles }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ // Read-only endpoint over public data.
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET",
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (error) {
+ reportError(error, { context: "open-roles" });
+ ctx?.waitUntil(Promise.resolve(Sentry.flush(2000)));
+
+ return new Response(JSON.stringify({ error: "Failed to fetch open roles" }), {
+ status: 502,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "no-store",
+ },
+ });
+ }
+};
diff --git a/src/pages/volunteer-new.astro b/src/pages/volunteer-new.astro
index 0091b89b..ca96f411 100644
--- a/src/pages/volunteer-new.astro
+++ b/src/pages/volunteer-new.astro
@@ -4,6 +4,7 @@ import HomeLayout from "../layouts/HomeLayout.astro";
import HomeNavbar from "../components/home/HomeNavbar.astro";
import Button from "../components/ui/Button.astro";
import SignUpForm from "../structures/SignUpForm.astro";
+import OpenRoles from "../components/volunteer/OpenRoles.tsx";
const membershipLive = getEnv("MEMBERSHIP_LIVE", Astro.locals) === "true";
---
@@ -36,15 +37,28 @@ const membershipLive = getEnv("MEMBERSHIP_LIVE", Astro.locals) === "true";
>
Apply to Volunteer Now
-
+
+
+
+
+
+
+
+
+
Open roles
+
Current volunteer needs
+
+ Roles open right now across coalition projects and Tech for Palestine teams. Project
+ roles go through our volunteer application; T4P team roles are filled through
+ membership.
+
+
+
@@ -162,9 +176,7 @@ const membershipLive = getEnv("MEMBERSHIP_LIVE", Astro.locals) === "true";
to get matched with the right project or team—even if you're not sure how you want to contribute!{
" "
}
- Check out specific roles we're looking for right now!
@@ -346,13 +358,6 @@ const membershipLive = getEnv("MEMBERSHIP_LIVE", Astro.locals) === "true";
>
Apply to Volunteer Now
-
diff --git a/src/pages/volunteer.astro b/src/pages/volunteer.astro
index 39156889..7e86c969 100644
--- a/src/pages/volunteer.astro
+++ b/src/pages/volunteer.astro
@@ -1,5 +1,6 @@
---
import Socials from "../components/Socials.astro";
+import OpenRoles from "../components/volunteer/OpenRoles.tsx";
import Layout from "../layouts/Layout.astro";
import "../styles/base.css";
---
@@ -20,19 +21,25 @@ import "../styles/base.css";
-
+ Roles open right now across coalition projects and Tech for Palestine teams. Project roles
+ go through our volunteer application; T4P team roles are filled through membership.
+
+
+
@@ -106,10 +113,7 @@ import "../styles/base.css";
href="https://techforpalestine.org/volunteer-form"
class="font-medium text-indigo-600 hover:underline">volunteer application form to get matched with the right project or team—even if you're not sure how you want to
- contribute!
+ contribute!
(Check out specific roles we're looking for right now!)
@@ -171,19 +175,13 @@ import "../styles/base.css";