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
130 changes: 130 additions & 0 deletions src/components/volunteer/OpenRoleCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}

function Chevron({ expanded }: { expanded: boolean }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`transition-transform duration-150 ${expanded ? "rotate-180" : ""}`}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
}

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 (
<article className={tokens.card}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
{(role.orgName || isTeamRole) && (
<p className="mb-1.5 flex flex-wrap items-center gap-2">
{role.orgName && <span className={tokens.org}>{role.orgName}</span>}
{isTeamRole && <span className={tokens.orgMarker}>T4P team</span>}
</p>
)}
<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>
</div>

{(role.skillCategories.length > 0 || role.timeCommitment) && (
<ul className="mt-4 flex flex-wrap gap-2">
{role.skillCategories.map((skill) => (
<li key={skill} className={tokens.pill}>
{skill}
</li>
))}
{role.timeCommitment && (
<li className={tokens.pill}>
<ClockIcon />
{role.timeCommitment}
</li>
)}
</ul>
)}

{role.description && (
<>
<p
id={descriptionId}
className={`mt-4 max-w-[75ch] ${tokens.body} ${
isCollapsed ? "line-clamp-3" : "whitespace-pre-line"
}`}
>
{/* 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}
</p>

{canCollapse && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={descriptionId}
className={`mt-1 ${tokens.disclosure} ${tokens.focus}`}
>
{expanded ? "Show less" : "Read the full role"}
<Chevron expanded={expanded} />
</button>
)}
</>
)}

{expanded && role.areasOfInterest.length > 0 && (
<p className="mt-3">
<span className={tokens.detailLabel}>Areas of interest: </span>
<span className={tokens.body}>{role.areasOfInterest.join(", ")}</span>
</p>
)}
</article>
);
}
174 changes: 174 additions & 0 deletions src/components/volunteer/OpenRoles.tsx
Original file line number Diff line number Diff line change
@@ -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<OpenRole[]>([]);
const [state, setState] = useState<LoadState>("loading");
const [activeSkill, setActiveSkill] = useState<string>(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 (
<div className="space-y-4" aria-busy="true" aria-live="polite">
<span className="sr-only">Loading open roles</span>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={`h-40 animate-pulse motion-reduce:animate-none ${tokens.skeleton}`}
/>
))}
</div>
);
}

if (state === "error") {
return (
<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>{" "}
and we&rsquo;ll match you to a role.
</p>
<button
type="button"
onClick={load}
className={`mt-4 ${tokens.applyProject} ${tokens.focus}`}
>
Try again
</button>
</div>
);
}

if (roles.length === 0) {
return (
<p className={tokens.notice}>
No roles are open right now.{" "}
<a href={VOLUNTEER_FORM_URL} className={`${tokens.link} ${tokens.focus}`}>
Apply anyway
</a>{" "}
and we&rsquo;ll get in touch when one opens up.
</p>
);
}

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 (
<div>
{skillFilters.length > 1 && (
<div className="mb-6 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveSkill(ALL_SKILLS)}
aria-pressed={activeSkill === ALL_SKILLS}
className={`${tokens.chip} ${
activeSkill === ALL_SKILLS ? tokens.chipActive : tokens.chipIdle
} ${tokens.focus}`}
>
All roles
<span className="ml-1.5 opacity-70">{roles.length}</span>
</button>

{visibleFilters.map((filter) => {
const isActive = filter.name === activeSkill;
return (
<button
key={filter.name}
type="button"
onClick={() => setActiveSkill(filter.name)}
aria-pressed={isActive}
className={`${tokens.chip} ${isActive ? tokens.chipActive : tokens.chipIdle} ${
tokens.focus
}`}
>
{filter.name}
<span className="ml-1.5 opacity-70">{filter.count}</span>
</button>
);
})}

{hiddenFilterCount > 0 && (
<button
type="button"
onClick={() => setShowAllFilters((value) => !value)}
className={`${tokens.chip} ${tokens.chipIdle} ${tokens.focus}`}
>
{showAllFilters ? "Show fewer skills" : `${hiddenFilterCount} more skills`}
</button>
)}
</div>
)}

<p className={`mb-6 ${tokens.count}`} aria-live="polite">
{visibleRoles.length === 1 ? "1 open role" : `${visibleRoles.length} open roles`}
{activeSkill !== ALL_SKILLS && ` in ${activeSkill}`}
</p>

<div className="space-y-4">
{visibleRoles.map((role) => (
<OpenRoleCard key={role.id} role={role} tokens={tokens} />
))}
</div>
</div>
);
}
Loading
Loading