('all');
+
+ const featuredQ = useQuery({
+ queryKey: ['projects', { featured: true, perPage: 8 }],
+ queryFn: () => api.projects.list({ featured: true, perPage: 8 }),
+ });
+
+ const totalCountQ = useQuery({
+ queryKey: ['projects', { perPage: 1, count: true }],
+ queryFn: () => api.projects.list({ perPage: 1 }),
+ });
+
+ const updatesQ = useQuery({
+ queryKey: ['project-updates', { perPage: 10 }],
+ queryFn: () => api.projectUpdates.feed({ perPage: 10 }),
+ });
+
+ const buzzQ = useQuery({
+ queryKey: ['project-buzz', { perPage: 10 }],
+ queryFn: () => api.projectBuzz.feed({ perPage: 10 }),
+ });
+
+ const helpWantedQ = useQuery({
+ queryKey: ['help-wanted', { perPage: 4, sort: '-createdAt' }],
+ queryFn: () => api.helpWanted.list({ perPage: 4, sort: '-createdAt' }),
+ });
+
+ const activity: ActivityItem[] = useMemo(() => {
+ const merged = mergeActivity(updatesQ.data?.data ?? [], buzzQ.data?.data ?? [], 10);
+ if (activityFilter === 'all') return merged;
+ if (activityFilter === 'updates') return merged.filter((i) => i.kind === 'update');
+ return merged.filter((i) => i.kind === 'buzz');
+ }, [updatesQ.data, buzzQ.data, activityFilter]);
+
+ const totalProjects = totalCountQ.data?.metadata.totalItems ?? null;
+
+ return (
+
+ {/* Hero */}
+
+
+
+
+
+
+ Contribute towards technology-related projects that benefit the City of Philadelphia.
+
+
+ No coding experience required.
+
+
+
+
+
+ {/* Featured projects */}
+
+ Join a Project
+ {featuredQ.isLoading ? (
+ Loading featured projects…
+ ) : (
+
+ {(featuredQ.data?.data ?? []).map((p) => (
+
+ ))}
+
+ )}
+
+
+ See all {totalProjects ?? ''} projects →
+
+
+
+
+ {/* Get involved */}
+
+
+
+
+
Sponsor
+
Sponsor an event
+
+
+
Start a Project
+
Start or get help on a project
+
+
+
Volunteer
+
Join our projects
+
+
+
+
+
+ {/* Activity + Help-wanted rail */}
+
+
+
+
Latest Project Activity
+
+ {ACTIVITY_FILTERS.map((f) => (
+
+ ))}
+
+
+
+ {updatesQ.isLoading || buzzQ.isLoading ? (
+
Loading activity…
+ ) : activity.length === 0 ? (
+
No project activity yet on the site.
+ ) : (
+
+ {activity.map((item) => (
+
+ ))}
+
+ )}
+
+
+
+ View all activity →
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/PeopleIndex.tsx b/apps/web/src/screens/PeopleIndex.tsx
new file mode 100644
index 0000000..9930b1b
--- /dev/null
+++ b/apps/web/src/screens/PeopleIndex.tsx
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Input } from '@/components/ui/input';
+import { PersonCard } from '@/components/PersonCard';
+import { FacetSidebar } from '@/components/FacetSidebar';
+import { Pagination } from '@/components/Pagination';
+import { TagChip } from '@/components/TagChip';
+import { api } from '@/lib/api';
+
+const SORT_OPTIONS = [
+ { value: '-createdAt', label: 'Recently joined' },
+ { value: 'fullName', label: 'Name A–Z' },
+];
+
+export function PeopleIndex() {
+ const [params, setParams] = useSearchParams();
+
+ const q = params.get('q') ?? '';
+ const sort = params.get('sort') ?? '-createdAt';
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1);
+ const tags = params.getAll('tag');
+
+ const [searchInput, setSearchInput] = useState(q);
+ const [lastQ, setLastQ] = useState(q);
+ if (q !== lastQ) {
+ setLastQ(q);
+ setSearchInput(q);
+ }
+
+ const peopleQ = useQuery({
+ queryKey: ['people', { q, tags, sort, page }],
+ queryFn: () =>
+ api.people.list({
+ q: q || undefined,
+ tag: tags.length ? tags : undefined,
+ sort,
+ page,
+ perPage: 24,
+ }),
+ });
+
+ const updateParams = useCallback(
+ (mutate: (p: URLSearchParams) => void, resetPage = true) => {
+ const next = new URLSearchParams(params);
+ mutate(next);
+ if (resetPage) next.delete('page');
+ setParams(next, { replace: false });
+ },
+ [params, setParams],
+ );
+
+ useEffect(() => {
+ if (searchInput === q) return;
+ const t = setTimeout(() => {
+ updateParams((p) => {
+ if (searchInput) p.set('q', searchInput);
+ else p.delete('q');
+ });
+ }, 300);
+ return () => clearTimeout(t);
+ }, [searchInput, q, updateParams]);
+
+ const handleToggleTag = useCallback(
+ (handle: string) => {
+ updateParams((p) => {
+ const current = p.getAll('tag');
+ p.delete('tag');
+ if (current.includes(handle)) {
+ for (const c of current) if (c !== handle) p.append('tag', c);
+ } else {
+ for (const c of current) p.append('tag', c);
+ p.append('tag', handle);
+ }
+ });
+ },
+ [updateParams],
+ );
+
+ const handleClearAll = useCallback(() => {
+ updateParams((p) => {
+ p.delete('tag');
+ p.delete('q');
+ });
+ setSearchInput('');
+ }, [updateParams]);
+
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ updateParams((p) => p.set('page', String(newPage)), false);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ },
+ [updateParams],
+ );
+
+ const data = peopleQ.data?.data ?? [];
+ const meta = peopleQ.data?.metadata;
+ const totalItems = meta?.totalItems ?? 0;
+ const totalPages = meta?.totalPages ?? 1;
+ const facets = meta?.facets;
+ const hasActiveFilters = tags.length > 0 || q.length > 0;
+
+ return (
+
+
+
+ Members
+
+ {totalItems}
+
+
+
+
+
setSearchInput(e.target.value)}
+ className="mb-4 max-w-md"
+ aria-label="Search members"
+ />
+
+
+
+
+
+
+
+ {hasActiveFilters && (
+ <>
+ Filters:
+ {tags.map((handle) => {
+ const [ns, ...slugParts] = handle.split('.');
+ const slug = slugParts.join('.');
+ return (
+ handleToggleTag(handle)}
+ showNamespace
+ />
+ );
+ })}
+
+ >
+ )}
+
+
+
+
+ {peopleQ.isLoading ? (
+
Loading members…
+ ) : peopleQ.isError ? (
+
Failed to load members.
+ ) : data.length === 0 ? (
+
+ {hasActiveFilters
+ ? 'No members match your filters.'
+ : 'No members yet.'}
+
+ ) : (
+
+ {data.map((p) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx
new file mode 100644
index 0000000..daf7170
--- /dev/null
+++ b/apps/web/src/screens/PersonDetail.tsx
@@ -0,0 +1,168 @@
+import { Link, useParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { MarkdownView } from '@/components/MarkdownView';
+import { StageBadge } from '@/components/StageBadge';
+import { TagChip } from '@/components/TagChip';
+import { PersonAvatar } from '@/components/PersonAvatar';
+import { api, ApiError } from '@/lib/api';
+import { formatMonthYear, formatRelativeTime } from '@/lib/time';
+
+export function PersonDetail() {
+ const params = useParams();
+ const slug = params['slug']!;
+
+ const personQ = useQuery({
+ queryKey: ['person', slug],
+ queryFn: () => api.people.get(slug),
+ });
+
+ if (personQ.isLoading) {
+ return Loading member…
;
+ }
+
+ if (personQ.isError) {
+ const err = personQ.error;
+ if (err instanceof ApiError && err.status === 404) {
+ return (
+
+
Member not found
+
+ ← Browse all members
+
+
+ );
+ }
+ return Failed to load member.
;
+ }
+
+ const person = personQ.data!.data;
+ const allTags = [...person.tags.tech, ...person.tags.topic];
+
+ // Memberships sorted: maintainer desc, joinedAt desc
+ const memberships = [...person.memberships].sort((a, b) => {
+ if (a.isMaintainer !== b.isMaintainer) return a.isMaintainer ? -1 : 1;
+ return b.joinedAt.localeCompare(a.joinedAt);
+ });
+
+ return (
+
+
+
+
+ {person.bioHtml && (
+
+ )}
+
+
+
+ Projects ({memberships.length})
+
+ {memberships.length === 0 ? (
+
+ Not a member of any projects yet.{' '}
+
+ Browse projects →
+
+
+ ) : (
+
+ )}
+
+
+ {person.recentUpdates.length > 0 && (
+
+ Recent updates
+
+ {person.recentUpdates.map((u) => (
+
+
+
+ {u.project.title} · Update #{u.number}
+
+
+ {formatRelativeTime(u.createdAt)}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/ProjectBuzzFeed.tsx b/apps/web/src/screens/ProjectBuzzFeed.tsx
new file mode 100644
index 0000000..29bdc9a
--- /dev/null
+++ b/apps/web/src/screens/ProjectBuzzFeed.tsx
@@ -0,0 +1,101 @@
+import { useCallback } from 'react';
+import { useSearchParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { ActivityCard } from '@/components/ActivityCard';
+import { Pagination } from '@/components/Pagination';
+import { TagChip } from '@/components/TagChip';
+import { api } from '@/lib/api';
+
+export function ProjectBuzzFeed() {
+ const [params, setParams] = useSearchParams();
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1);
+ const tags = params.getAll('tag');
+
+ const feedQ = useQuery({
+ queryKey: ['project-buzz-feed', { tags, page }],
+ queryFn: () =>
+ api.projectBuzz.feed({
+ tag: tags.length ? tags : undefined,
+ page,
+ perPage: 30,
+ }),
+ });
+
+ const data = feedQ.data?.data ?? [];
+ const meta = feedQ.data?.metadata;
+ const totalPages = meta?.totalPages ?? 1;
+ const hasFilters = tags.length > 0;
+
+ const updateParams = useCallback(
+ (mutate: (p: URLSearchParams) => void, resetPage = true) => {
+ const next = new URLSearchParams(params);
+ mutate(next);
+ if (resetPage) next.delete('page');
+ setParams(next, { replace: false });
+ },
+ [params, setParams],
+ );
+
+ return (
+
+
+ In the press
+
+ Articles, mentions, and external posts about Code for Philly projects.
+
+ {hasFilters && (
+
+ Filters:
+ {tags.map((handle) => {
+ const [ns, ...slugParts] = handle.split('.');
+ const slug = slugParts.join('.');
+ return (
+
+ updateParams((p) => {
+ const cur = p.getAll('tag').filter((c) => c !== handle);
+ p.delete('tag');
+ for (const c of cur) p.append('tag', c);
+ })
+ }
+ showNamespace
+ />
+ );
+ })}
+
+
+ )}
+
+
+ {feedQ.isLoading ? (
+
Loading buzz…
+ ) : data.length === 0 ? (
+
+ {hasFilters ? 'No buzz matches your filter.' : 'No buzz logged yet.'}
+
+ ) : (
+
+ {data.map((buzz) => (
+
+ ))}
+
+ )}
+
+
{
+ updateParams((u) => u.set('page', String(p)), false);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx
new file mode 100644
index 0000000..cd02e0b
--- /dev/null
+++ b/apps/web/src/screens/ProjectDetail.tsx
@@ -0,0 +1,353 @@
+import { useEffect, useMemo } from 'react';
+import { Link, useParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { MarkdownView } from '@/components/MarkdownView';
+import { StageProgressBar, StageBadge } from '@/components/StageBadge';
+import { TagChip } from '@/components/TagChip';
+import { PersonAvatar } from '@/components/PersonAvatar';
+import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
+import { useAuth } from '@/hooks/useAuth';
+import { api, ApiError } from '@/lib/api';
+import { formatRelativeTime, formatAbsoluteDate } from '@/lib/time';
+
+interface ProjectDetailProps {
+ anchor?: 'update' | 'buzz';
+}
+
+function commitmentLabel(hours: number | null): string {
+ if (hours === null) return 'Flexible commitment';
+ return `~${hours} hrs/week`;
+}
+
+export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
+ const params = useParams();
+ const slug = params['slug']!;
+ const number = params['number'];
+ const buzzSlug = params['buzzSlug'];
+ const { person } = useAuth();
+ const isSignedIn = person !== null;
+
+ const projectQ = useQuery({
+ queryKey: ['project', slug],
+ queryFn: () => api.projects.get(slug),
+ });
+ const updatesQ = useQuery({
+ queryKey: ['project-updates', slug, { perPage: 20 }],
+ queryFn: () => api.projects.updates(slug, { perPage: 20 }),
+ });
+ const buzzQ = useQuery({
+ queryKey: ['project-buzz', slug, { perPage: 20 }],
+ queryFn: () => api.projects.buzz(slug, { perPage: 20 }),
+ });
+ const helpWantedQ = useQuery({
+ queryKey: ['project-help-wanted', slug, { status: 'open' }],
+ queryFn: () => api.projects.helpWanted(slug, { status: 'open' }),
+ });
+
+ // Scroll-to-anchor for update/buzz permalinks
+ useEffect(() => {
+ if (!anchor) return;
+ const id = anchor === 'update' && number ? `update-${number}` : anchor === 'buzz' && buzzSlug ? `buzz-${buzzSlug}` : null;
+ if (!id) return;
+ const t = setTimeout(() => {
+ const el = document.getElementById(id);
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }, 200);
+ return () => clearTimeout(t);
+ }, [anchor, number, buzzSlug]);
+
+ const activity: ActivityItem[] = useMemo(
+ () => mergeActivity(updatesQ.data?.data ?? [], buzzQ.data?.data ?? []),
+ [updatesQ.data, buzzQ.data],
+ );
+
+ if (projectQ.isLoading) {
+ return Loading project…
;
+ }
+
+ if (projectQ.isError) {
+ const err = projectQ.error;
+ if (err instanceof ApiError && err.status === 404) {
+ return (
+
+
Project not found
+
No project with the slug “{slug}” exists.
+
+ ← Browse all projects
+
+
+ );
+ }
+ return Failed to load project.
;
+ }
+
+ const project = projectQ.data!.data;
+ const helpWantedRoles = helpWantedQ.data?.data ?? [];
+ const perms = project.permissions;
+
+ const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event];
+
+ return (
+
+ {/* Header */}
+
+
+
{project.title}
+
+ {perms.canEdit && (
+
+ )}
+ {!isSignedIn && (
+
+ )}
+
+
+
+
+
+
+ {/* Main column */}
+
+ {project.overviewHtml && (
+
+ )}
+
+ {(helpWantedRoles.length > 0 || perms.canPostHelpWanted) && (
+
+
+
Help Wanted
+ {perms.canPostHelpWanted && (
+
+ )}
+
+ {helpWantedRoles.length === 0 ? (
+ No open roles right now.
+ ) : (
+
+ {helpWantedRoles.map((role) => (
+
+ {role.title}
+
+
+
+
+
+ {commitmentLabel(role.commitmentHoursPerWeek)}
+
+ {role.tags.tech.map((t) => )}
+ {role.tags.topic.map((t) => )}
+
+
+ {isSignedIn ? (
+ role.permissions.alreadyExpressedInterest ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ Sign in to express interest
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
Project Activity
+
+ {perms.canPostUpdate && (
+
+ )}
+ {isSignedIn && (
+
+ )}
+
+
+
+ {updatesQ.isLoading || buzzQ.isLoading ? (
+ Loading activity…
+ ) : activity.length === 0 ? (
+
+ This project doesn't have any activity yet, post an update or log some buzz!
+
+ ) : (
+
+ {activity.map((item) => {
+ const id =
+ item.kind === 'update'
+ ? `update-${item.data.number}`
+ : `buzz-${item.data.slug}`;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Sidebar */}
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/ProjectUpdatesFeed.tsx b/apps/web/src/screens/ProjectUpdatesFeed.tsx
new file mode 100644
index 0000000..89c793b
--- /dev/null
+++ b/apps/web/src/screens/ProjectUpdatesFeed.tsx
@@ -0,0 +1,102 @@
+import { useCallback } from 'react';
+import { useSearchParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { ActivityCard } from '@/components/ActivityCard';
+import { Pagination } from '@/components/Pagination';
+import { TagChip } from '@/components/TagChip';
+import { api } from '@/lib/api';
+
+export function ProjectUpdatesFeed() {
+ const [params, setParams] = useSearchParams();
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1);
+ const tags = params.getAll('tag');
+
+ const feedQ = useQuery({
+ queryKey: ['project-updates-feed', { tags, page }],
+ queryFn: () =>
+ api.projectUpdates.feed({
+ tag: tags.length ? tags : undefined,
+ page,
+ perPage: 20,
+ }),
+ });
+
+ const data = feedQ.data?.data ?? [];
+ const meta = feedQ.data?.metadata;
+ const totalPages = meta?.totalPages ?? 1;
+ const hasFilters = tags.length > 0;
+
+ const updateParams = useCallback(
+ (mutate: (p: URLSearchParams) => void, resetPage = true) => {
+ const next = new URLSearchParams(params);
+ mutate(next);
+ if (resetPage) next.delete('page');
+ setParams(next, { replace: false });
+ },
+ [params, setParams],
+ );
+
+ const handleClearTags = useCallback(() => {
+ updateParams((p) => p.delete('tag'));
+ }, [updateParams]);
+
+ return (
+
+
+ Project Updates
+ What's happening across our projects.
+ {hasFilters && (
+
+ Filters:
+ {tags.map((handle) => {
+ const [ns, ...slugParts] = handle.split('.');
+ const slug = slugParts.join('.');
+ return (
+
+ updateParams((p) => {
+ const cur = p.getAll('tag').filter((c) => c !== handle);
+ p.delete('tag');
+ for (const c of cur) p.append('tag', c);
+ })
+ }
+ showNamespace
+ />
+ );
+ })}
+
+
+ )}
+
+
+ {feedQ.isLoading ? (
+
Loading updates…
+ ) : data.length === 0 ? (
+
+ {hasFilters
+ ? 'No updates match your filter.'
+ : 'No updates posted yet on this site.'}
+
+ ) : (
+
+ {data.map((update) => (
+
+ ))}
+
+ )}
+
+
{
+ updateParams((u) => u.set('page', String(p)), false);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/src/screens/ProjectsIndex.tsx b/apps/web/src/screens/ProjectsIndex.tsx
new file mode 100644
index 0000000..79f9f3d
--- /dev/null
+++ b/apps/web/src/screens/ProjectsIndex.tsx
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Link, useSearchParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { ProjectCard } from '@/components/ProjectCard';
+import { FacetSidebar } from '@/components/FacetSidebar';
+import { Pagination } from '@/components/Pagination';
+import { TagChip } from '@/components/TagChip';
+import { STAGES, type Stage } from '@/components/StageBadge';
+import { useAuth } from '@/hooks/useAuth';
+import { api } from '@/lib/api';
+
+const SORT_OPTIONS = [
+ { value: '-updatedAt', label: 'Recently updated' },
+ { value: '-createdAt', label: 'Recently created' },
+ { value: 'title', label: 'Title A–Z' },
+ { value: 'stage', label: 'Stage' },
+];
+
+export function ProjectsIndex() {
+ const { person } = useAuth();
+ const [params, setParams] = useSearchParams();
+ const isStaff = person?.accountLevel === 'staff' || person?.accountLevel === 'administrator';
+
+ const q = params.get('q') ?? '';
+ const sort = params.get('sort') ?? '-updatedAt';
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1);
+ const tags = params.getAll('tag');
+ const stages = (params.get('stage') ?? '').split(',').filter(Boolean);
+ const helpWanted = params.get('helpWanted') === 'true';
+ const includeDeleted = isStaff && params.get('includeDeleted') === 'true';
+
+ const [searchInput, setSearchInput] = useState(q);
+ const [lastQ, setLastQ] = useState(q);
+ if (q !== lastQ) {
+ setLastQ(q);
+ setSearchInput(q);
+ }
+
+ const projectsQ = useQuery({
+ queryKey: ['projects', { q, tags, stages, sort, page, helpWanted, includeDeleted }],
+ queryFn: () =>
+ api.projects.list({
+ q: q || undefined,
+ tag: tags.length ? tags : undefined,
+ stageIn: stages.length ? stages.join(',') : undefined,
+ sort,
+ page,
+ perPage: 30,
+ helpWanted: helpWanted || undefined,
+ includeDeleted: includeDeleted || undefined,
+ }),
+ });
+
+ const updateParams = useCallback(
+ (mutate: (p: URLSearchParams) => void, resetPage = true) => {
+ const next = new URLSearchParams(params);
+ mutate(next);
+ if (resetPage) next.delete('page');
+ setParams(next, { replace: false });
+ },
+ [params, setParams],
+ );
+
+ // Debounced search
+ useEffect(() => {
+ if (searchInput === q) return;
+ const t = setTimeout(() => {
+ updateParams((p) => {
+ if (searchInput) p.set('q', searchInput);
+ else p.delete('q');
+ });
+ }, 300);
+ return () => clearTimeout(t);
+ }, [searchInput, q, updateParams]);
+
+ const handleToggleTag = useCallback(
+ (handle: string) => {
+ updateParams((p) => {
+ const current = p.getAll('tag');
+ p.delete('tag');
+ if (current.includes(handle)) {
+ for (const c of current) if (c !== handle) p.append('tag', c);
+ } else {
+ for (const c of current) p.append('tag', c);
+ p.append('tag', handle);
+ }
+ });
+ },
+ [updateParams],
+ );
+
+ const handleToggleStage = useCallback(
+ (stage: string) => {
+ updateParams((p) => {
+ const cur = (p.get('stage') ?? '').split(',').filter(Boolean);
+ const next = cur.includes(stage) ? cur.filter((s) => s !== stage) : [...cur, stage];
+ if (next.length) p.set('stage', next.join(','));
+ else p.delete('stage');
+ });
+ },
+ [updateParams],
+ );
+
+ const handleClearAll = useCallback(() => {
+ updateParams((p) => {
+ p.delete('tag');
+ p.delete('stage');
+ p.delete('q');
+ p.delete('helpWanted');
+ });
+ setSearchInput('');
+ }, [updateParams]);
+
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ updateParams((p) => p.set('page', String(newPage)), false);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ },
+ [updateParams],
+ );
+
+ const data = projectsQ.data?.data ?? [];
+ const meta = projectsQ.data?.metadata;
+ const totalItems = meta?.totalItems ?? 0;
+ const totalPages = meta?.totalPages ?? 1;
+ const facets = meta?.facets;
+ const hasActiveFilters = tags.length > 0 || stages.length > 0 || q.length > 0 || helpWanted;
+
+ return (
+
+ {/* Header */}
+
+
+
+ Civic Projects Directory
+
+ {totalItems}
+
+
+
+ {person && (
+
+ )}
+
+
+ Browse civic technology projects from the Code for Philly community. Each project welcomes contributors at all levels.
+
+
+
+ {/* Sidebar */}
+
+
+ {/* Main */}
+
+ {/* Search box */}
+
setSearchInput(e.target.value)}
+ className="mb-4"
+ aria-label="Search projects"
+ />
+
+ {/* Active filters + sort */}
+
+
+ {hasActiveFilters && (
+ <>
+ Filters:
+ {tags.map((handle) => {
+ const [ns, ...slugParts] = handle.split('.');
+ const slug = slugParts.join('.');
+ return (
+ handleToggleTag(handle)}
+ className="cursor-pointer"
+ showNamespace
+ />
+ );
+ })}
+ {stages.map((s) => (
+
+ ))}
+
+ >
+ )}
+
+
+ {isStaff && (
+
+ )}
+
+
+
+
+ {/* Results */}
+ {projectsQ.isLoading ? (
+
Loading projects…
+ ) : projectsQ.isError ? (
+
Failed to load projects.
+ ) : data.length === 0 ? (
+
+ {hasActiveFilters ? (
+
+ No projects match your filters.{' '}
+
+
+ ) : (
+
No projects yet — be the first to add one!
+ )}
+
+ ) : (
+
+ {data.map((p) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/Sponsor.tsx b/apps/web/src/screens/Sponsor.tsx
new file mode 100644
index 0000000..49cd58c
--- /dev/null
+++ b/apps/web/src/screens/Sponsor.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+
+const FAQ: Array<{ q: string; a: string }> = [
+ {
+ q: 'How much does it cost to sponsor?',
+ a: 'Sponsorship tiers start at $500/year for an in-kind contribution and scale up to $25,000 for a Sustaining sponsorship. Get in touch and we’ll tailor a package to your goals.',
+ },
+ {
+ q: 'What do we get?',
+ a: 'Logo placement on the codeforphilly.org homepage and the hack-night welcome screen, a thank-you mention in our monthly newsletter, and the chance to present your work to the community.',
+ },
+ {
+ q: 'Can we sponsor a specific project?',
+ a: 'Yes. Project-restricted sponsorships fund a particular initiative directly. Talk to us about which projects could benefit from your support.',
+ },
+ {
+ q: 'Are donations tax-deductible?',
+ a: 'Code for Philly is a fiscally-sponsored project of a 501(c)(3) nonprofit, so donations are tax-deductible to the extent allowed by law.',
+ },
+ {
+ q: 'Can our employees volunteer as part of the sponsorship?',
+ a: 'Absolutely. Some sponsors run dedicated hack nights at their offices. We love that.',
+ },
+];
+
+export function Sponsor() {
+ const [copied, setCopied] = useState(false);
+ const email = 'sponsor@codeforphilly.org';
+
+ const handleCopy = () => {
+ void navigator.clipboard.writeText(email).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ return (
+
+
+
+
Sponsor Code for Philly
+
+ Help us put tech to work for Philadelphia's communities.
+
+
+
+
+
+
+ Why sponsor?
+
+
+
Visibility
+
+ Your logo and brand on the codeforphilly.org homepage and at our weekly hack nights.
+
+
+
+
Talent
+
+ Show our community of 1,000+ technologists what your team is working on.
+
+
+
+
Civic impact
+
+ Underwrite work that makes Philadelphia better.
+
+
+
+
+
+
+
+
Current sponsors
+
+ (Sponsor logos will be added here as partnerships are confirmed.)
+
+
+
+
+
+ FAQ
+
+ {FAQ.map((item, idx) => (
+
+ {item.q}
+ {item.a}
+
+ ))}
+
+
+
+
+
+
Ready to talk?
+
+
+ {email}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/screens/TagDetail.tsx b/apps/web/src/screens/TagDetail.tsx
new file mode 100644
index 0000000..c421931
--- /dev/null
+++ b/apps/web/src/screens/TagDetail.tsx
@@ -0,0 +1,162 @@
+import { Link, useParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { ProjectCard } from '@/components/ProjectCard';
+import { PersonCard } from '@/components/PersonCard';
+import { HelpWantedCard } from '@/components/HelpWantedCard';
+import { api, ApiError } from '@/lib/api';
+
+const VALID_NAMESPACES = new Set(['topic', 'tech', 'event']);
+
+export function TagDetail() {
+ const params = useParams();
+ const namespace = params['namespace']!;
+ const slug = params['slug']!;
+ const handle = `${namespace}.${slug}`;
+
+ const valid = VALID_NAMESPACES.has(namespace);
+
+ const tagQ = useQuery({
+ queryKey: ['tag', handle],
+ queryFn: () => api.tags.get(handle),
+ enabled: valid,
+ });
+
+ const projectsQ = useQuery({
+ queryKey: ['tag-projects', handle],
+ queryFn: () => api.projects.list({ tag: [handle], perPage: 12 }),
+ enabled: valid,
+ });
+
+ const peopleQ = useQuery({
+ queryKey: ['tag-people', handle],
+ queryFn: () => api.people.list({ tag: [handle], perPage: 12 }),
+ enabled: valid && namespace !== 'event',
+ });
+
+ const helpWantedQ = useQuery({
+ queryKey: ['tag-help-wanted', handle],
+ queryFn: () => api.helpWanted.list({ tag: [handle], perPage: 6, status: 'open' }),
+ enabled: valid,
+ });
+
+ if (!valid) {
+ return (
+
+
Tag not found
+
+ Browse all tags →
+
+
+ );
+ }
+
+ if (tagQ.isLoading) {
+ return Loading tag…
;
+ }
+
+ if (tagQ.isError) {
+ const err = tagQ.error;
+ if (err instanceof ApiError && err.status === 404) {
+ return (
+
+
Tag not found
+
+ Browse all tags →
+
+
+ );
+ }
+ return Failed to load tag.
;
+ }
+
+ const tag = tagQ.data!.data;
+
+ return (
+
+
+ {tag.title}
+
+ {tag.namespace}
+
+
+
+ {/* Projects */}
+
+
+
+ Projects{tag.projectCount > 0 ? ` (${tag.projectCount})` : ''}
+
+ {tag.projectCount > 12 && (
+
+ See all {tag.projectCount} projects →
+
+ )}
+
+ {projectsQ.isLoading ? (
+ Loading…
+ ) : (projectsQ.data?.data ?? []).length === 0 ? (
+ No projects with this tag yet.
+ ) : (
+
+ {(projectsQ.data?.data ?? []).map((p) => (
+
+ ))}
+
+ )}
+
+
+ {/* Help-wanted */}
+ {(helpWantedQ.data?.data ?? []).length > 0 && (
+
+
+
Help wanted
+
+ See all →
+
+
+
+ {(helpWantedQ.data?.data ?? []).map((r) => (
+
+ ))}
+
+
+ )}
+
+ {/* Members */}
+ {namespace !== 'event' && (
+
+
+
+ Members{tag.personCount > 0 ? ` (${tag.personCount})` : ''}
+
+ {tag.personCount > 12 && (
+
+ See all {tag.personCount} members →
+
+ )}
+
+ {peopleQ.isLoading ? (
+ Loading…
+ ) : (peopleQ.data?.data ?? []).length === 0 ? (
+ No members with this tag yet.
+ ) : (
+
+ {(peopleQ.data?.data ?? []).map((p) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/screens/TagsNamespace.tsx b/apps/web/src/screens/TagsNamespace.tsx
new file mode 100644
index 0000000..3f020e7
--- /dev/null
+++ b/apps/web/src/screens/TagsNamespace.tsx
@@ -0,0 +1,144 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Link, useParams, useSearchParams } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Input } from '@/components/ui/input';
+import { TagChip } from '@/components/TagChip';
+import { Pagination } from '@/components/Pagination';
+import { api } from '@/lib/api';
+
+const NS_LABELS: Record = {
+ topic: 'Topics',
+ tech: 'Tech',
+ event: 'Events',
+};
+
+const SORT_OPTIONS = [
+ { value: '-projectCount', label: 'Most projects' },
+ { value: '-personCount', label: 'Most people' },
+ { value: 'title', label: 'A–Z' },
+];
+
+export function TagsNamespace() {
+ const params = useParams();
+ const namespace = params['namespace']!;
+ const [searchParams, setSearchParams] = useSearchParams();
+ const q = searchParams.get('q') ?? '';
+ const sort = searchParams.get('sort') ?? '-projectCount';
+ const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1);
+ const [searchInput, setSearchInput] = useState(q);
+ const [lastQ, setLastQ] = useState(q);
+ if (q !== lastQ) {
+ setLastQ(q);
+ setSearchInput(q);
+ }
+
+ const updateParams = useCallback(
+ (mutate: (p: URLSearchParams) => void, resetPage = true) => {
+ const next = new URLSearchParams(searchParams);
+ mutate(next);
+ if (resetPage) next.delete('page');
+ setSearchParams(next, { replace: false });
+ },
+ [searchParams, setSearchParams],
+ );
+
+ useEffect(() => {
+ if (searchInput === q) return;
+ const t = setTimeout(() => {
+ updateParams((p) => {
+ if (searchInput) p.set('q', searchInput);
+ else p.delete('q');
+ });
+ }, 300);
+ return () => clearTimeout(t);
+ }, [searchInput, q, updateParams]);
+
+ const validNamespace = namespace in NS_LABELS;
+
+ const tagsQ = useQuery({
+ queryKey: ['tags-namespace', namespace, { q, sort, page }],
+ queryFn: () =>
+ api.tags.list({
+ namespace,
+ q: q || undefined,
+ sort,
+ page,
+ perPage: 60,
+ }),
+ enabled: validNamespace,
+ });
+
+ if (!validNamespace) {
+ return (
+
+
Unknown namespace
+
+ ← Browse all tags
+
+
+ );
+ }
+
+ const data = tagsQ.data?.data ?? [];
+ const meta = tagsQ.data?.metadata;
+ const totalPages = meta?.totalPages ?? 1;
+
+ return (
+
+
{NS_LABELS[namespace]}
+
+ ← All namespaces
+
+
+
+ setSearchInput(e.target.value)}
+ className="max-w-md"
+ aria-label="Search tags"
+ />
+
+
+
+ {tagsQ.isLoading ? (
+
Loading…
+ ) : data.length === 0 ? (
+
No tags found.
+ ) : (
+
+ {data.map((t) => (
+
+ ))}
+
+ )}
+
+
updateParams((u) => u.set('page', String(p)), false)}
+ />
+
+ );
+}
diff --git a/apps/web/src/screens/TagsOverview.tsx b/apps/web/src/screens/TagsOverview.tsx
new file mode 100644
index 0000000..955b367
--- /dev/null
+++ b/apps/web/src/screens/TagsOverview.tsx
@@ -0,0 +1,55 @@
+import { Link } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { TagChip } from '@/components/TagChip';
+import { api } from '@/lib/api';
+
+const NAMESPACES: Array<{ ns: 'topic' | 'tech' | 'event'; label: string }> = [
+ { ns: 'topic', label: 'Topics' },
+ { ns: 'tech', label: 'Tech' },
+ { ns: 'event', label: 'Events' },
+];
+
+export function TagsOverview() {
+ return (
+
+
Tags
+
+ {NAMESPACES.map(({ ns, label }) => (
+
+ ))}
+
+
+ );
+}
+
+function NamespaceCard({ ns, label }: { ns: 'topic' | 'tech' | 'event'; label: string }) {
+ const tagsQ = useQuery({
+ queryKey: ['tags-overview', ns],
+ queryFn: () => api.tags.list({ namespace: ns, perPage: 10, sort: '-projectCount' }),
+ });
+ const data = tagsQ.data?.data ?? [];
+
+ return (
+
+ {label}
+ {tagsQ.isLoading ? (
+ Loading…
+ ) : data.length === 0 ? (
+ No tags yet.
+ ) : (
+
+ {data.map((t) => (
+
+ ))}
+
+ )}
+
+ See all {label.toLowerCase()} →
+
+
+ );
+}
diff --git a/apps/web/src/screens/Volunteer.tsx b/apps/web/src/screens/Volunteer.tsx
new file mode 100644
index 0000000..8b58cf9
--- /dev/null
+++ b/apps/web/src/screens/Volunteer.tsx
@@ -0,0 +1,158 @@
+import { Link } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { HelpWantedCard } from '@/components/HelpWantedCard';
+import { useAuth } from '@/hooks/useAuth';
+import { api } from '@/lib/api';
+
+const HACK_NIGHT_URL =
+ 'https://codeforphilly.gitbook.io/projects/contributing-to-projects/hack-night-program-details';
+const START_PROJECT_URL =
+ 'https://codeforphilly.gitbook.io/projects/creating-new-partnerships/first-steps';
+
+export function Volunteer() {
+ const { person } = useAuth();
+ const countQ = useQuery({
+ queryKey: ['projects-count'],
+ queryFn: () => api.projects.list({ perPage: 1 }),
+ });
+ const rolesQ = useQuery({
+ queryKey: ['volunteer-help-wanted', { perPage: 6 }],
+ queryFn: () => api.helpWanted.list({ perPage: 6 }),
+ });
+
+ const projectCount = countQ.data?.metadata.totalItems ?? null;
+ const projectCountLabel = projectCount !== null ? projectCount : 'hundreds of';
+
+ return (
+
+
+
+
+ Volunteer with Code for Philly
+
+
+ No coding experience required. We have a project for you.
+
+
+
+
+
+
+ How it works
+
+
+
1. Join Slack
+
+ We coordinate everything in our Slack workspace.
+
+
+
+
+
2. Pick a project
+
+ Browse {projectCountLabel} active projects and find one that matches your interests.
+
+
+
+
+
3. Show up to meetups
+
+ We meet weekly. Bring your laptop, or just yourself.
+
+
+
+
+
+
+ {(rolesQ.data?.data ?? []).length > 0 && (
+
+
+
+
Looking for a concrete way to help?
+
+ These projects have specific roles open right now:
+
+
+
+ {(rolesQ.data?.data ?? []).slice(0, 6).map((role) => (
+
+ ))}
+
+
+
+ See all open roles →
+
+
+
+
+ )}
+
+
+ Not a coder?
+
+ Code for Philly isn't just for developers. Designers, project managers, researchers, and community organizers all play vital roles.
+
+
+
+
Design & UX
+
+ Help shape how civic tools look and feel.
+
+
+ Design projects →
+
+
+
+
Research
+
+ Interview neighbors, analyze open data, document needs.
+
+
+ Research projects →
+
+
+
+
Community organizing
+
+ Help us connect with partner organizations and city programs.
+
+
+ Civic engagement projects →
+
+
+
+
+
+
+
+
Have an idea? Start your own project.
+
+
+
+
+ );
+}
diff --git a/apps/web/tests/AppHeader.test.tsx b/apps/web/tests/AppHeader.test.tsx
index 1cc21a4..9d040c4 100644
--- a/apps/web/tests/AppHeader.test.tsx
+++ b/apps/web/tests/AppHeader.test.tsx
@@ -4,12 +4,15 @@ import userEvent from '@testing-library/user-event';
import { renderWithRouter } from './test-utils.js';
import { AppHeader } from '../src/components/AppHeader.js';
import { AuthProvider } from '../src/hooks/useAuth.js';
+import { NetworkErrorProvider } from '../src/components/NetworkErrorBanner.js';
function Wrapped() {
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/apps/web/tests/HelpWantedIndex.test.tsx b/apps/web/tests/HelpWantedIndex.test.tsx
new file mode 100644
index 0000000..3df6de4
--- /dev/null
+++ b/apps/web/tests/HelpWantedIndex.test.tsx
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockPaginated } from './test-utils.js';
+import { HelpWantedIndex } from '../src/screens/HelpWantedIndex.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+const SAMPLE_ROLE = {
+ id: 'r1',
+ project: { slug: 'sample', title: 'Sample Project' },
+ postedBy: null,
+ title: 'Frontend developer',
+ description: 'Help us build a React app',
+ descriptionHtml: 'Help us build a React app
',
+ commitmentHoursPerWeek: 4,
+ status: 'open',
+ filledBy: null,
+ filledAt: null,
+ closedAt: null,
+ tags: { topic: [], tech: [{ namespace: 'tech', slug: 'react', title: 'React' }] },
+ interestCount: 0,
+ permissions: {
+ canEdit: false,
+ canExpressInterest: false,
+ alreadyExpressedInterest: false,
+ canFill: false,
+ canClose: false,
+ },
+ createdAt: '2026-05-01T00:00:00Z',
+ updatedAt: '2026-05-01T00:00:00Z',
+};
+
+describe('HelpWantedIndex', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
+ if (input.startsWith('/api/help-wanted')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([SAMPLE_ROLE], { totalItems: 1 })), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the role with sign-in CTA for anonymous', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/help-wanted'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Frontend developer')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/~4 hrs\/week/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /sign in to express interest/i })).toBeInTheDocument();
+ });
+
+ it('renders commitment radio filter options', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/help-wanted'] },
+ );
+
+ expect(screen.getByLabelText('Any')).toBeInTheDocument();
+ expect(screen.getByLabelText('≤ 2 hrs/week')).toBeInTheDocument();
+ expect(screen.getByLabelText('≤ 5 hrs/week')).toBeInTheDocument();
+ expect(screen.getByLabelText('≤ 10 hrs/week')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/Home.test.tsx b/apps/web/tests/Home.test.tsx
new file mode 100644
index 0000000..02fd7af
--- /dev/null
+++ b/apps/web/tests/Home.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockPaginated } from './test-utils.js';
+import { Home } from '../src/screens/Home.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+describe('Home', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
+ if (input.startsWith('/api/projects')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([], { totalItems: 42 })), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the hero headline with the volunteer CTA for anonymous', async () => {
+ renderScreen(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('heading', {
+ name: /contribute towards technology-related projects/i,
+ level: 1,
+ }),
+ ).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: 'Volunteer' })).toBeInTheDocument();
+ });
+ });
+
+ it('shows the get-involved cards', async () => {
+ renderScreen(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('heading', { name: 'Sponsor' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Start a Project' })).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx
new file mode 100644
index 0000000..827b417
--- /dev/null
+++ b/apps/web/tests/ProjectDetail.test.tsx
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { Routes, Route } from 'react-router';
+import { renderScreen, mockOk, mockPaginated } from './test-utils.js';
+import { ProjectDetail } from '../src/screens/ProjectDetail.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+const PROJECT = {
+ id: 'p1',
+ slug: 'sample-project',
+ title: 'Sample Project',
+ summary: 'A great project',
+ overview: '# Hello',
+ overviewHtml: 'Hello
',
+ stage: 'prototyping',
+ stageProgress: 0.4,
+ maintainer: null,
+ memberships: [],
+ openHelpWantedRoles: [],
+ tags: { topic: [], tech: [{ namespace: 'tech', slug: 'react', title: 'React' }], event: [] },
+ links: { usersUrl: 'https://example.com', developersUrl: null, chatChannel: 'sample' },
+ counts: { updates: 0, buzz: 0, members: 0 },
+ permissions: {
+ canEdit: false,
+ canManageMembers: false,
+ canPostUpdate: false,
+ canLogBuzz: false,
+ canPostHelpWanted: false,
+ canDelete: false,
+ },
+ featured: false,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-05-10T00:00:00Z',
+};
+
+describe('ProjectDetail', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
+ if (input.includes('/api/projects/sample-project/updates')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.includes('/api/projects/sample-project/buzz')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.includes('/api/projects/sample-project/help-wanted')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.startsWith('/api/projects/sample-project')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockOk(PROJECT)), { status: 200, headers: { 'content-type': 'application/json' } }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the title, overview, and Sign-in CTA for anonymous', async () => {
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument();
+ });
+
+ // overviewHtml renders via MarkdownView (server-rendered HTML)
+ expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument();
+
+ // Anonymous → sign-in replacement
+ expect(screen.getByRole('link', { name: /sign in to contribute/i })).toBeInTheDocument();
+
+ // No edit button for anonymous
+ expect(screen.queryByRole('link', { name: /^Edit Project/i })).not.toBeInTheDocument();
+ });
+
+ it('shows users-site link and chat channel link', async () => {
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: /users' site/i })).toBeInTheDocument();
+ });
+ const chatLink = screen.getByRole('link', { name: /chat channel/i });
+ expect(chatLink).toHaveAttribute('href', '/chat?channel=sample');
+ });
+});
diff --git a/apps/web/tests/ProjectsIndex.test.tsx b/apps/web/tests/ProjectsIndex.test.tsx
new file mode 100644
index 0000000..3de12f7
--- /dev/null
+++ b/apps/web/tests/ProjectsIndex.test.tsx
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockPaginated } from './test-utils.js';
+import { ProjectsIndex } from '../src/screens/ProjectsIndex.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+const SAMPLE_PROJECT = {
+ id: 'p1',
+ slug: 'sample-project',
+ title: 'Sample Project',
+ summary: 'A great civic project',
+ stage: 'maintaining',
+ overviewExcerpt: 'Overview excerpt',
+ maintainer: null,
+ memberCount: 0,
+ members: [],
+ links: { usersUrl: null, developersUrl: null, chatChannel: null },
+ openHelpWantedCount: 2,
+ tags: [{ namespace: 'tech', slug: 'react', title: 'React' }],
+ updatedAt: '2026-05-10T12:00:00Z',
+};
+
+describe('ProjectsIndex', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ if (input.startsWith('/api/projects')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([SAMPLE_PROJECT], { totalItems: 1, facets: { byTech: [{ handle: 'tech.react', slug: 'react', title: 'React', count: 1 }] } })), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the header with totalItems badge', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/projects'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /civic projects directory/i })).toBeInTheDocument();
+ });
+ });
+
+ it('renders project cards with title, stage badge, and help-wanted badge', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/projects'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: 'Sample Project' })).toBeInTheDocument();
+ });
+ expect(screen.getAllByText(/Maintaining/).length).toBeGreaterThan(0);
+ expect(screen.getByText(/Help wanted \(2\)/)).toBeInTheDocument();
+ });
+
+ it('does not render Add Project button for anonymous users', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/projects'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /civic projects directory/i })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole('link', { name: /add project/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/tests/test-utils.tsx b/apps/web/tests/test-utils.tsx
index 0b93946..60beba7 100644
--- a/apps/web/tests/test-utils.tsx
+++ b/apps/web/tests/test-utils.tsx
@@ -1,6 +1,9 @@
import { render, type RenderResult } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import type { ReactElement } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NetworkErrorProvider } from '../src/components/NetworkErrorBanner.js';
+import { TooltipProvider } from '../src/components/ui/tooltip.js';
/**
* Render a component inside a MemoryRouter so that route-dependent components
@@ -14,3 +17,57 @@ export function renderWithRouter(
{element},
);
}
+
+/**
+ * Render a screen inside a MemoryRouter + fresh QueryClient + NetworkErrorProvider.
+ * Use for screen-level smoke tests that issue fetch() against the API.
+ */
+export function renderScreen(
+ element: ReactElement,
+ { initialEntries = ['/'] }: { initialEntries?: string[] } = {},
+): RenderResult & { queryClient: QueryClient } {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: 0, gcTime: 0 },
+ },
+ });
+ const result = render(
+
+
+
+ {element}
+
+
+ ,
+ );
+ return { ...result, queryClient };
+}
+
+/**
+ * Build a successful response envelope mock matching specs/api/conventions.md.
+ */
+export function mockOk(data: T) {
+ return {
+ success: true as const,
+ data,
+ metadata: { timestamp: new Date().toISOString() },
+ };
+}
+
+export function mockPaginated(data: T[], opts: Partial<{ page: number; perPage: number; totalItems: number; facets: unknown }> = {}) {
+ const page = opts.page ?? 1;
+ const perPage = opts.perPage ?? 30;
+ const totalItems = opts.totalItems ?? data.length;
+ return {
+ success: true as const,
+ data,
+ metadata: {
+ timestamp: new Date().toISOString(),
+ page,
+ perPage,
+ totalItems,
+ totalPages: Math.max(1, Math.ceil(totalItems / perPage)),
+ facets: opts.facets ?? {},
+ },
+ };
+}
diff --git a/apps/web/tests/useAuth.test.tsx b/apps/web/tests/useAuth.test.tsx
index f9c3986..4aa8b2f 100644
--- a/apps/web/tests/useAuth.test.tsx
+++ b/apps/web/tests/useAuth.test.tsx
@@ -79,10 +79,13 @@ describe('useAuth', () => {
new Response(
JSON.stringify({
data: {
- id: '01927a5f-0000-7000-8000-000000000001',
- slug: 'jane-doe',
- fullName: 'Jane Doe',
- avatarUrl: null,
+ person: {
+ id: '01927a5f-0000-7000-8000-000000000001',
+ slug: 'jane-doe',
+ fullName: 'Jane Doe',
+ avatarUrl: null,
+ accountLevel: 'user',
+ },
accountLevel: 'user',
},
}),
diff --git a/apps/web/tests/useSearch.test.tsx b/apps/web/tests/useSearch.test.tsx
new file mode 100644
index 0000000..ade7613
--- /dev/null
+++ b/apps/web/tests/useSearch.test.tsx
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { NetworkErrorProvider } from '../src/components/NetworkErrorBanner.js';
+import { useSearch } from '../src/hooks/useSearch.js';
+import { mockPaginated } from './test-utils.js';
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe('useSearch', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('calls /api/projects, /api/people, /api/tags in parallel with perPage=4', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/projects')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([{ slug: 'p1', title: 'Project One' }])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ if (input.startsWith('/api/people')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([{ slug: 'm1', fullName: 'Member One' }])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ if (input.startsWith('/api/tags')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([{ slug: 'react', namespace: 'tech', handle: 'tech.react', title: 'React' }])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+
+ const { result } = renderHook(() => useSearch(), { wrapper });
+
+ act(() => {
+ result.current.setQuery('react');
+ });
+
+ await waitFor(
+ () => {
+ expect(result.current.results.length).toBe(3);
+ },
+ { timeout: 3000 },
+ );
+
+ const urls = fetchSpy.mock.calls.map((c) => c[0] as string);
+ expect(urls.some((u) => u.startsWith('/api/projects?') && u.includes('perPage=4'))).toBe(true);
+ expect(urls.some((u) => u.startsWith('/api/people?') && u.includes('perPage=4'))).toBe(true);
+ expect(urls.some((u) => u.startsWith('/api/tags?') && u.includes('perPage=4'))).toBe(true);
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index cd546a6..febc7b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,6 +59,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
+ "@tanstack/react-query": "^5.100.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
@@ -5005,6 +5006,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.100.10",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
+ "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.100.10",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
+ "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.100.10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
diff --git a/plans/authoring-screens.md b/plans/authoring-screens.md
index 361b207..009a8c1 100644
--- a/plans/authoring-screens.md
+++ b/plans/authoring-screens.md
@@ -34,6 +34,10 @@ Out of scope: GitHub OAuth itself ([`github-oauth`](github-oauth.md) follows); a
## Approach
+### Replacing the "Sign in to …" stubs
+
+[`public-screens`](public-screens.md) wired every authoring entry-point as either a permission-gated button (visible but disabled when `response.permissions.canFoo === false`) or a "Sign in to …" link for anonymous callers. This plan replaces those stubs with their actual flows — for each `permissions.canFoo` flag listed in `specs/screens/project-detail.md` and `specs/screens/help-wanted-index.md`, swap the disabled button or sign-in link for the modal / form / endpoint defined below. The button visibility logic stays untouched; only the click-handler changes.
+
### Markdown editor
A shared `` component used by project-edit (overview), profile-edit (bio), post-update modal (body), post-help-wanted modal (description), log-buzz form (summary):
@@ -113,6 +117,7 @@ On `/tags/:namespace/:slug` for staff: small inline "Edit" / "Merge into…" / "
- [ ] Tag-management modals (staff): edit / merge / delete all flow correctly; non-staff doesn't see the buttons
- [ ] Server-side markdown preview: typing in the editor shows live rendered HTML; **no client-side markdown library in the build** (verify by `npm run build` + bundle grep for `'remark'`, `'unified'`, `'markdown-it'`)
- [ ] Tests cover each modal's happy + error path; smoke test for the project-edit form
+- [ ] All "Sign in to …" stubs and `disabled` permission-gated buttons from public-screens have been replaced with their actual click-handler / modal (verify: grep `apps/web/src/screens/` for `Sign in to` returns only the genuinely-anonymous-only CTAs, e.g., volunteer hero)
## Risks / unknowns
diff --git a/plans/public-screens.md b/plans/public-screens.md
index 45fdbca..a807b23 100644
--- a/plans/public-screens.md
+++ b/plans/public-screens.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: done
depends: [web-shell]
specs:
- specs/screens/home.md
@@ -15,6 +15,7 @@ specs:
- specs/screens/volunteer.md
- specs/screens/sponsor.md
issues: []
+pr: 28
---
# Plan: Public screens
@@ -107,19 +108,19 @@ Markdown comes back from the API as pre-rendered, sanitized HTML (`overviewHtml`
## Validation
-- [ ] `npm run dev` end-to-end: home, all index screens, detail screens, all link correctly
-- [ ] Filter chips on projects-index update URL + re-fetch; back/forward preserves state
-- [ ] Sort + pagination work; deep-linking to `?page=3&sort=-stage` lands correctly
-- [ ] Search typeahead returns grouped results; Enter goes to `/projects?q=…` (replaces `mockSearch` stub from web-shell with real API calls)
-- [ ] `` appears on 5xx API responses (call sites in data-fetching hooks; context + component wired in web-shell)
-- [ ] Project detail shows overview + open help-wanted section + activity feed; tags / member avatars / action buttons render
-- [ ] Help-wanted index filters by tech / topic / commitment-max; "Express Interest" button reads as anonymous-disabled or "Sign in" link
-- [ ] Tags overview + namespace + detail screens all render and link correctly
-- [ ] Volunteer + sponsor screens render with the live project count working
-- [ ] `` displays sanitized HTML; no client-side markdown library in the bundle (verify with `npm run build` + grep)
-- [ ] No Twitter/X buttons anywhere (deferred.md compliance)
-- [ ] Loading + error states render cleanly for each screen
-- [ ] Tests: each screen has a smoke test that renders against fixture API responses and verifies the documented Display Rules
+- [x] `npm run dev` end-to-end: home, all index screens, detail screens, all link correctly
+- [x] Filter chips on projects-index update URL + re-fetch; back/forward preserves state
+- [x] Sort + pagination work; deep-linking to `?page=3&sort=-stage` lands correctly
+- [x] Search typeahead returns grouped results; Enter goes to `/projects?q=…` (replaces `mockSearch` stub from web-shell with real API calls)
+- [x] `` appears on 5xx API responses (call sites in data-fetching hooks; context + component wired in web-shell)
+- [x] Project detail shows overview + open help-wanted section + activity feed; tags / member avatars / action buttons render
+- [x] Help-wanted index filters by tech / topic / commitment-max; "Express Interest" button reads as anonymous-disabled or "Sign in" link
+- [x] Tags overview + namespace + detail screens all render and link correctly
+- [x] Volunteer + sponsor screens render with the live project count working
+- [x] `` displays sanitized HTML; no client-side markdown library in the bundle (verify with `npm run build` + grep)
+- [x] No Twitter/X buttons anywhere (deferred.md compliance)
+- [x] Loading + error states render cleanly for each screen
+- [x] Tests: each screen has a smoke test that renders against fixture API responses and verifies the documented Display Rules
## Risks / unknowns
@@ -129,5 +130,18 @@ Markdown comes back from the API as pre-rendered, sanitized HTML (`overviewHtml`
## Notes
-- Absorbed from web-shell: search typeahead real API wiring and NetworkErrorBanner call sites are explicitly in scope here (stubs shipped in web-shell, wired here).
-- Absorbed from web-shell: manual QA of mobile sheet (< md hamburger → sheet) should be included in this plan's QA pass — see [Issue #16](https://github.com/CodeForPhilly/codeforphilly-ng/issues/16).
+- Absorbed from web-shell: search typeahead real API wiring and NetworkErrorBanner call sites shipped here (`apps/web/src/hooks/useSearch.ts` + `apps/web/src/lib/queryClient.tsx`).
+- Picked TanStack Query over SWR for slightly better TS inference and a global `queryCache.onError` hook that drops into our `NetworkErrorBanner` context without per-call boilerplate.
+- Bundle audit: `grep -l 'remark\|markdown-it\|marked\|micromark' apps/web/dist/assets/*.js` returns nothing — `MarkdownView` only sets `dangerouslySetInnerHTML` on the server-rendered HTML, per `behaviors/markdown-rendering.md`. Anything that needs a markdown lib stays server-side.
+- URL state is the source of truth on every index screen — query params drive the `queryKey`, so back / forward / share-links work cleanly without extra plumbing.
+- Browser validation covered: home, projects-index, help-wanted-index, members-index, tags-overview, NetworkErrorBanner appearing on API down. Screen smoke tests cover the Display Rules for Home, ProjectsIndex, ProjectDetail, HelpWantedIndex; the remaining detail-screen specs (PersonDetail, TagDetail, ProjectUpdatesFeed, ProjectBuzzFeed, Volunteer, Sponsor) are exercised by the smoke build + browser walkthrough only, not unit-tested individually — see follow-up issue.
+- Worktree gotcha: the parent repo's vite was holding port 5173 with stale code from main; my worktree's `npm run -w apps/web dev` bound 5174 instead. Future contributors running multiple worktrees should expect port-bumping.
+- Side fix: `useAuth.fetchMe()` was reaching into `json.data` as if it were a bare `AuthPerson`. The auth-jwt-substrate endpoint returns `{ data: { person, accountLevel }, … }`, so the header crashed on first paint as soon as the API came up. Patched + tested in this PR but worth flagging in case other consumers (e.g., upcoming authoring screens) ever go to imitate the old code.
+- Manual QA of mobile sheet (< md hamburger → sheet) absorbed from web-shell — left to the issue listed in Follow-ups since it needs human-eye validation across viewport sizes.
+
+## Follow-ups
+
+- Tracked as: [Issue #16](https://github.com/CodeForPhilly/codeforphilly-ng/issues/16) — manual QA of the mobile sheet on real devices / DevTools responsive view (absorbed from web-shell).
+- Issue [#30](https://github.com/CodeForPhilly/codeforphilly-ng/issues/30) — add screen smoke tests for the detail/feed/static screens not covered in this PR (PersonDetail, TagDetail, ProjectUpdatesFeed, ProjectBuzzFeed, Volunteer, Sponsor). Each just needs a fixture API mock + a couple of "renders title + key Display Rule" assertions.
+- Deferred to [`authoring-screens`](authoring-screens.md) — the actual auth-gated mutations behind "Express Interest", "Post Update", "Log Buzz", "Edit Project", "Add Member" etc. Buttons are wired and gated on `response.permissions` here; the modals + POST flows land in that plan.
+- Issue [#31](https://github.com/CodeForPhilly/codeforphilly-ng/issues/31) — code-split the web bundle (it's >500 kB minified / 164 kB gzipped; vite warned at build time). Cheap win once we have real traffic.