diff --git a/src/components/volunteer/OpenRoleCard.tsx b/src/components/volunteer/OpenRoleCard.tsx new file mode 100644 index 00000000..6aa5e33b --- /dev/null +++ b/src/components/volunteer/OpenRoleCard.tsx @@ -0,0 +1,130 @@ +import React, { useId, useState } from "react"; +import { getApplyTarget, type OpenRole } from "./openRoleData"; +import type { SkinTokens } from "./openRoleSkins"; + +/** Descriptions shorter than this read fine in full, so they get no toggle. */ +const CLAMP_THRESHOLD = 260; + +function ClockIcon() { + return ( + + ); +} + +function Chevron({ expanded }: { expanded: boolean }) { + return ( + + ); +} + +interface OpenRoleCardProps { + role: OpenRole; + tokens: SkinTokens; +} + +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; + + return ( +
+
+
+ {(role.orgName || isTeamRole) && ( +

+ {role.orgName && {role.orgName}} + {isTeamRole && T4P team} +

+ )} +

{role.title}

+
+ + + {apply.label} + as {role.title} + +
+ + {(role.skillCategories.length > 0 || role.timeCommitment) && ( + + )} + + {role.description && ( + <> +

+ {/* 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} +

+ + {canCollapse && ( + + )} + + )} + + {expanded && role.areasOfInterest.length > 0 && ( +

+ Areas of interest: + {role.areasOfInterest.join(", ")} +

+ )} +
+ ); +} diff --git a/src/components/volunteer/OpenRoles.tsx b/src/components/volunteer/OpenRoles.tsx new file mode 100644 index 00000000..72a6432d --- /dev/null +++ b/src/components/volunteer/OpenRoles.tsx @@ -0,0 +1,174 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + getSkillFilters, + normalizeOpenRoles, + VOLUNTEER_FORM_URL, + type OpenRole, +} from "./openRoleData"; +import { getSkinTokens, type OpenRolesSkin } from "./openRoleSkins"; +import OpenRoleCard from "./OpenRoleCard"; + +type LoadState = "loading" | "ready" | "error"; + +interface OpenRolesProps { + skin?: OpenRolesSkin; +} + +const ALL_SKILLS = "all"; + +/** + * The Hub reports ~15 skill categories, most with a single role. Showing all of + * them at once buries the list under filters, so the long tail sits behind a + * toggle — the same treatment TagFilter gives project tags. + */ +const MAX_VISIBLE_FILTERS = 8; + +export default function OpenRoles({ skin = "classic" }: OpenRolesProps) { + const tokens = getSkinTokens(skin); + const [roles, setRoles] = useState([]); + const [state, setState] = useState("loading"); + const [activeSkill, setActiveSkill] = useState(ALL_SKILLS); + const [showAllFilters, setShowAllFilters] = useState(false); + + const load = useCallback(async () => { + setState("loading"); + try { + const response = await fetch("/api/open-roles", { cache: "no-cache" }); + if (!response.ok) throw new Error(`Request failed: ${response.status}`); + const payload = await response.json(); + setRoles(normalizeOpenRoles(payload.roles)); + setState("ready"); + } catch { + setState("error"); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const skillFilters = useMemo(() => getSkillFilters(roles), [roles]); + + const visibleRoles = useMemo( + () => + activeSkill === ALL_SKILLS + ? roles + : roles.filter((role) => role.skillCategories.includes(activeSkill)), + [roles, activeSkill] + ); + + if (state === "loading") { + return ( +
+ Loading open roles + {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ); + } + + if (state === "error") { + return ( +
+

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

+ +
+ ); + } + + if (roles.length === 0) { + return ( +

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

+ ); + } + + const hiddenFilterCount = Math.max(skillFilters.length - MAX_VISIBLE_FILTERS, 0); + const visibleFilters = + showAllFilters || hiddenFilterCount === 0 + ? skillFilters + : skillFilters.filter( + // Keep the active filter on screen even when it lives in the tail. + (filter, index) => index < MAX_VISIBLE_FILTERS || filter.name === activeSkill + ); + + return ( +
+ {skillFilters.length > 1 && ( +
+ + + {visibleFilters.map((filter) => { + const isActive = filter.name === activeSkill; + return ( + + ); + })} + + {hiddenFilterCount > 0 && ( + + )} +
+ )} + +

+ {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";
-
+ + +
+

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

+
+
@@ -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";

-