diff --git a/apps/web/package.json b/apps/web/package.json index 8c6fca7..4924d10 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5c38f31..662d6ac 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,22 @@ -import { createBrowserRouter, RouterProvider } from 'react-router'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'; import { TooltipProvider } from '@/components/ui/tooltip'; import { AppShell } from '@/components/AppShell'; import { NetworkErrorProvider } from '@/components/NetworkErrorBanner'; import { AuthProvider } from '@/hooks/useAuth'; -import { HomeStub } from '@/pages/HomeStub'; +import { ApiQueryClientProvider } from '@/lib/queryClient'; +import { Home } from '@/screens/Home'; +import { ProjectsIndex } from '@/screens/ProjectsIndex'; +import { ProjectDetail } from '@/screens/ProjectDetail'; +import { PeopleIndex } from '@/screens/PeopleIndex'; +import { PersonDetail } from '@/screens/PersonDetail'; +import { HelpWantedIndex } from '@/screens/HelpWantedIndex'; +import { ProjectUpdatesFeed } from '@/screens/ProjectUpdatesFeed'; +import { ProjectBuzzFeed } from '@/screens/ProjectBuzzFeed'; +import { TagsOverview } from '@/screens/TagsOverview'; +import { TagsNamespace } from '@/screens/TagsNamespace'; +import { TagDetail } from '@/screens/TagDetail'; +import { Volunteer } from '@/screens/Volunteer'; +import { Sponsor } from '@/screens/Sponsor'; import { ComingSoon } from '@/pages/ComingSoon'; import { NotFound } from '@/pages/NotFound'; import { LoginPlaceholder } from '@/pages/LoginPlaceholder'; @@ -12,35 +25,51 @@ const router = createBrowserRouter([ { element: , children: [ - { path: '/', element: }, - { path: '/projects', element: }, + { path: '/', element: }, + { path: '/projects', element: }, { path: '/projects/create', element: }, - { path: '/projects/:slug', element: }, + { path: '/projects/:slug', element: }, { path: '/projects/:slug/edit', element: }, - { path: '/help-wanted', element: }, - { path: '/members', element: }, - { path: '/members/:slug', element: }, - { path: '/volunteer', element: }, - { path: '/sponsor', element: }, + { path: '/projects/:slug/updates/:number', element: }, + { path: '/projects/:slug/buzz/:buzzSlug', element: }, + { path: '/projects/:slug/buzz/new', element: }, + { path: '/help-wanted', element: }, + { path: '/people', element: }, + { path: '/members', element: }, + { path: '/members/:slug', element: }, + { path: '/members/:slug/edit', element: }, + { path: '/project-updates', element: }, + { path: '/project-buzz', element: }, + { path: '/tags', element: }, + { path: '/tags/:namespace', element: }, + { path: '/tags/:namespace/:slug', element: }, + { path: '/volunteer', element: }, + { path: '/sponsor', element: }, { path: '/account', element: }, - { path: '/chat', element: }, - { path: '/search', element: }, + { path: '/search', element: }, { path: '/pages/:slug', element: }, { path: '/contact', element: }, - { path: '/tags/:namespace/:slug', element: }, { path: '/login', element: }, { path: '*', element: }, ], }, ]); +// /search?q=… isn't a separate page in v1 — redirect to /projects with the query preserved. +function SearchRedirect() { + const q = new URLSearchParams(window.location.search).get('q'); + return ; +} + export function App() { return ( - - - + + + + + ); diff --git a/apps/web/src/components/ActivityCard.tsx b/apps/web/src/components/ActivityCard.tsx new file mode 100644 index 0000000..409c45f --- /dev/null +++ b/apps/web/src/components/ActivityCard.tsx @@ -0,0 +1,139 @@ +import { Link } from 'react-router'; +import { MarkdownView } from '@/components/MarkdownView'; +import { PersonAvatar } from '@/components/PersonAvatar'; +import { formatRelativeTime, formatAbsoluteDate } from '@/lib/time'; +import type { ProjectUpdateResponse, ProjectBuzzResponse } from '@/lib/api'; + +export type ActivityItem = + | { kind: 'update'; data: ProjectUpdateResponse } + | { kind: 'buzz'; data: ProjectBuzzResponse }; + +interface ActivityCardProps { + item: ActivityItem; +} + +export function ActivityCard({ item }: ActivityCardProps) { + if (item.kind === 'update') return ; + return ; +} + +function UpdateCard({ update }: { update: ProjectUpdateResponse }) { + return ( +
+
+
+ + {update.project.title} + + · + + Update #{update.number} + +
+ + {formatRelativeTime(update.createdAt)} + +
+ + {update.author && ( +
+ + + {update.author.fullName} + +
+ )} + +
+ +
+
+ ); +} + +function BuzzCard({ buzz }: { buzz: ProjectBuzzResponse }) { + let hostname: string; + try { + hostname = new URL(buzz.url).hostname; + } catch { + hostname = buzz.url; + } + return ( +
+
+ + + {buzz.project.title} + + · Buzz · + + {formatAbsoluteDate(buzz.publishedAt, { month: 'short', day: 'numeric', year: 'numeric' })} + + +
+ +
+ {buzz.imageUrl && ( + + )} +
+

+ + {buzz.headline} + +

+

{hostname}

+ {buzz.summaryHtml && ( +
+ +
+ )} +
+
+ +
+ {buzz.postedBy && ( + + Logged by{' '} + + {buzz.postedBy.fullName} + + + )} + + View on site + +
+
+ ); +} + +/** Merge update + buzz arrays into a single reverse-chronological feed. */ +export function mergeActivity( + updates: ProjectUpdateResponse[], + buzz: ProjectBuzzResponse[], + limit?: number, +): ActivityItem[] { + const items: Array<{ item: ActivityItem; sortKey: string }> = [ + ...updates.map((u) => ({ + item: { kind: 'update' as const, data: u }, + sortKey: u.createdAt, + })), + ...buzz.map((b) => ({ + item: { kind: 'buzz' as const, data: b }, + sortKey: b.publishedAt, + })), + ]; + items.sort((a, b) => b.sortKey.localeCompare(a.sortKey)); + const merged = items.map((x) => x.item); + return limit ? merged.slice(0, limit) : merged; +} diff --git a/apps/web/src/components/FacetSidebar.tsx b/apps/web/src/components/FacetSidebar.tsx new file mode 100644 index 0000000..e5249b8 --- /dev/null +++ b/apps/web/src/components/FacetSidebar.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TagChip } from '@/components/TagChip'; +import { STAGES, type Stage } from '@/components/StageBadge'; +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import type { Facets, FacetEntry } from '@/lib/api'; + +interface FacetSidebarProps { + facets: Facets | undefined; + activeTags: string[]; + onToggleTag: (handle: string) => void; + activeStages?: string[]; + onToggleStage?: (stage: string) => void; + tabs?: Array<'topic' | 'tech' | 'event' | 'stage'>; + limit?: number; + className?: string; +} + +const NS_LABELS = { + topic: 'Topics', + tech: 'Tech', + event: 'Events', + stage: 'Stages', +} as const; + +const NS_SEE_ALL: Record = { + topic: '/tags/topic', + tech: '/tags/tech', + event: '/tags/event', +}; + +function facetsForNamespace(facets: Facets | undefined, ns: 'topic' | 'tech' | 'event'): FacetEntry[] { + if (!facets) return []; + if (ns === 'topic') return facets.byTopic ?? []; + if (ns === 'tech') return facets.byTech ?? []; + return facets.byEvent ?? []; +} + +export function FacetSidebar({ + facets, + activeTags, + onToggleTag, + activeStages, + onToggleStage, + tabs = ['topic', 'tech', 'event', 'stage'], + limit = 10, + className, +}: FacetSidebarProps) { + const [tab, setTab] = useState(tabs[0] ?? 'topic'); + const activeTagSet = new Set(activeTags); + const activeStageSet = new Set(activeStages ?? []); + + return ( + + ); +} diff --git a/apps/web/src/components/HelpWantedCard.tsx b/apps/web/src/components/HelpWantedCard.tsx new file mode 100644 index 0000000..570b1d1 --- /dev/null +++ b/apps/web/src/components/HelpWantedCard.tsx @@ -0,0 +1,87 @@ +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { TagChip } from '@/components/TagChip'; +import { PersonAvatar } from '@/components/PersonAvatar'; +import { MarkdownView } from '@/components/MarkdownView'; +import { useAuth } from '@/hooks/useAuth'; +import { formatRelativeTime } from '@/lib/time'; +import type { HelpWantedRoleResponse } from '@/lib/api'; + +interface HelpWantedCardProps { + role: HelpWantedRoleResponse; + showProjectLink?: boolean; +} + +function commitmentLabel(hours: number | null): string { + if (hours === null) return 'Flexible commitment'; + return `~${hours} hrs/week`; +} + +export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardProps) { + const { person } = useAuth(); + const isSignedIn = person !== null; + + return ( +
+ {showProjectLink && ( +
+ + {role.project.title} + + + Help Wanted + +
+ )} + +

{role.title}

+ +
+ +
+ +
+ + {commitmentLabel(role.commitmentHoursPerWeek)} + + {role.tags.tech.map((t) => ( + + ))} + {role.tags.topic.map((t) => ( + + ))} +
+ +
+
+ {role.postedBy && ( + <> + + {role.postedBy.fullName} + · + + )} + posted {formatRelativeTime(role.createdAt)} +
+ + {isSignedIn ? ( + role.permissions.alreadyExpressedInterest ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/MarkdownView.tsx b/apps/web/src/components/MarkdownView.tsx new file mode 100644 index 0000000..65f985c --- /dev/null +++ b/apps/web/src/components/MarkdownView.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/utils'; + +interface MarkdownViewProps { + /** Sanitized HTML from the API (e.g., overviewHtml, bioHtml). */ + html: string; + className?: string; +} + +// Pre-rendered, sanitized HTML from the server per +// specs/behaviors/markdown-rendering.md — no client-side markdown library. +export function MarkdownView({ html, className }: MarkdownViewProps) { + if (!html) return null; + return ( +
+ ); +} diff --git a/apps/web/src/components/Pagination.tsx b/apps/web/src/components/Pagination.tsx new file mode 100644 index 0000000..df54d9c --- /dev/null +++ b/apps/web/src/components/Pagination.tsx @@ -0,0 +1,82 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; + siblingCount?: number; + className?: string; +} + +function range(start: number, end: number): number[] { + const out: number[] = []; + for (let i = start; i <= end; i++) out.push(i); + return out; +} + +function buildPages(page: number, totalPages: number, siblingCount: number): Array { + const total = totalPages; + const totalShown = siblingCount * 2 + 5; + if (total <= totalShown) return range(1, total); + + const leftSibling = Math.max(page - siblingCount, 1); + const rightSibling = Math.min(page + siblingCount, total); + + const showLeftDots = leftSibling > 2; + const showRightDots = rightSibling < total - 1; + + const result: Array = [1]; + if (showLeftDots) result.push('ellipsis'); + for (const p of range(Math.max(2, leftSibling), Math.min(total - 1, rightSibling))) { + result.push(p); + } + if (showRightDots) result.push('ellipsis'); + result.push(total); + return result; +} + +export function Pagination({ page, totalPages, onPageChange, siblingCount = 1, className }: PaginationProps) { + if (totalPages <= 1) return null; + const pages = buildPages(page, totalPages, siblingCount); + + return ( + + ); +} diff --git a/apps/web/src/components/PersonAvatar.tsx b/apps/web/src/components/PersonAvatar.tsx new file mode 100644 index 0000000..2e7c31c --- /dev/null +++ b/apps/web/src/components/PersonAvatar.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import type { PersonAvatar as PersonAvatarType } from '@/lib/api'; + +interface PersonAvatarProps { + person: PersonAvatarType; + size?: number; + asLink?: boolean; + className?: string; + title?: string; +} + +export function PersonAvatar({ person, size = 32, asLink = true, className, title }: PersonAvatarProps) { + const letter = person.fullName.charAt(0).toUpperCase(); + const inner = person.avatarUrl ? ( + {person.fullName} + ) : ( + + {letter} + + ); + + if (!asLink) return inner; + + return ( + + {inner} + + ); +} diff --git a/apps/web/src/components/PersonCard.tsx b/apps/web/src/components/PersonCard.tsx new file mode 100644 index 0000000..29cb464 --- /dev/null +++ b/apps/web/src/components/PersonCard.tsx @@ -0,0 +1,34 @@ +import { Link } from 'react-router'; +import { TagChip } from '@/components/TagChip'; +import { PersonAvatar } from '@/components/PersonAvatar'; +import type { PersonListItem } from '@/lib/api'; + +interface PersonCardProps { + person: PersonListItem; +} + +export function PersonCard({ person }: PersonCardProps) { + return ( + +
+ +

{person.fullName}

+ {person.memberOfCount > 0 && ( +

+ Member of {person.memberOfCount} project{person.memberOfCount === 1 ? '' : 's'} +

+ )} + {person.tags.length > 0 && ( +
+ {person.tags.slice(0, 3).map((t) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/apps/web/src/components/ProjectCard.tsx b/apps/web/src/components/ProjectCard.tsx new file mode 100644 index 0000000..3fd9b55 --- /dev/null +++ b/apps/web/src/components/ProjectCard.tsx @@ -0,0 +1,90 @@ +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { StageBadge } from '@/components/StageBadge'; +import { TagChip } from '@/components/TagChip'; +import { PersonAvatar } from '@/components/PersonAvatar'; +import type { ProjectListItem } from '@/lib/api'; + +interface ProjectCardProps { + project: ProjectListItem; +} + +export function ProjectCard({ project }: ProjectCardProps) { + const summary = project.summary || project.overviewExcerpt; + const visibleTags = project.tags.slice(0, 5); + const extraTagCount = project.tags.length - visibleTags.length; + + return ( +
+
+

+ + {project.title} + +

+ +
+ + {summary && ( +

{summary}

+ )} + + {project.members.length > 0 && ( +
+ {project.members.slice(0, 8).map((m) => { + const isMaintainer = m.slug === project.maintainer?.slug; + return ( +
+ +
+ ); + })} + {project.memberCount > 8 && ( + +{project.memberCount - 8} more + )} +
+ )} + + {visibleTags.length > 0 && ( +
+ {visibleTags.map((t) => ( + + ))} + {extraTagCount > 0 && ( + +{extraTagCount} more + )} +
+ )} + +
+ {project.links.usersUrl && ( + + )} + {project.links.developersUrl && ( + + )} + {project.links.chatChannel && ( + + )} + {project.openHelpWantedCount > 0 && ( + + Help wanted ({project.openHelpWantedCount}) + + )} +
+
+ ); +} diff --git a/apps/web/src/components/SearchBox.tsx b/apps/web/src/components/SearchBox.tsx index 8ded3d7..42eca78 100644 --- a/apps/web/src/components/SearchBox.tsx +++ b/apps/web/src/components/SearchBox.tsx @@ -1,16 +1,30 @@ import { useCallback, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { Input } from '@/components/ui/input'; -import { useSearch } from '@/hooks/useSearch'; +import { useSearch, type SearchResult } from '@/hooks/useSearch'; interface SearchBoxProps { /** If true, renders compactly for embedding in the mobile sheet */ inline?: boolean; } +const GROUP_LABELS: Record = { + project: 'Projects', + member: 'Members', + tag: 'Tags', +}; + +function groupResults(results: SearchResult[]): Array<{ type: SearchResult['type']; items: SearchResult[] }> { + const groups: Record = { project: [], member: [], tag: [] }; + for (const r of results) groups[r.type].push(r); + return (['project', 'member', 'tag'] as const) + .filter((t) => groups[t].length > 0) + .map((t) => ({ type: t, items: groups[t] })); +} + export function SearchBox({ inline = false }: SearchBoxProps) { const navigate = useNavigate(); - const { query, results, setQuery, clear } = useSearch(); + const { query, results, loading, setQuery, clear } = useSearch(); const [open, setOpen] = useState(false); const inputRef = useRef(null); @@ -19,7 +33,6 @@ export function SearchBox({ inline = false }: SearchBoxProps) { }, []); const handleBlur = useCallback(() => { - // Delay so clicks on results fire first setTimeout(() => setOpen(false), 150); }, []); @@ -34,7 +47,7 @@ export function SearchBox({ inline = false }: SearchBoxProps) { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && query.trim()) { - void navigate(`/search?q=${encodeURIComponent(query.trim())}`); + void navigate(`/projects?q=${encodeURIComponent(query.trim())}`); clear(); setOpen(false); inputRef.current?.blur(); @@ -49,6 +62,7 @@ export function SearchBox({ inline = false }: SearchBoxProps) { ); const showDropdown = open && query.trim().length > 0; + const grouped = groupResults(results); return (
- {results.length === 0 ? ( + {loading && results.length === 0 && ( +

Searching…

+ )} + {!loading && results.length === 0 && (

No results for “{query}”

- ) : ( - results.map((r) => ( - { - clear(); - setOpen(false); - }} - > - - {r.type} - - {r.title} - - )) )} + {grouped.map((group) => ( +
+
+ {GROUP_LABELS[group.type]} +
+ {group.items.map((r) => ( + { + clear(); + setOpen(false); + }} + > + {r.title} + + ))} +
+ ))} + {query.trim() && ( { clear(); diff --git a/apps/web/src/components/StageBadge.tsx b/apps/web/src/components/StageBadge.tsx new file mode 100644 index 0000000..bb103db --- /dev/null +++ b/apps/web/src/components/StageBadge.tsx @@ -0,0 +1,133 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +export type Stage = + | 'commenting' + | 'bootstrapping' + | 'prototyping' + | 'testing' + | 'maintaining' + | 'drifting' + | 'hibernating'; + +export interface StageMeta { + rank: number; + label: string; + description: string; + progress: number; + className: string; + barClassName: string; +} + +export const STAGES: Record = { + commenting: { + rank: 0, + label: 'Commenting', + description: "Initial status — it's an idea people are commenting on", + progress: 10, + className: 'bg-yellow-100 text-yellow-900 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-200 dark:border-yellow-800', + barClassName: 'bg-yellow-400', + }, + bootstrapping: { + rank: 1, + label: 'Bootstrapping', + description: 'People and resources are being recruited to start', + progress: 30, + className: 'bg-yellow-100 text-yellow-900 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-200 dark:border-yellow-800', + barClassName: 'bg-yellow-500', + }, + prototyping: { + rank: 2, + label: 'Prototyping', + description: 'Something is being built', + progress: 60, + className: 'bg-blue-100 text-blue-900 border-blue-300 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-800', + barClassName: 'bg-blue-400', + }, + testing: { + rank: 3, + label: 'Testing', + description: 'Something has been built and some people are using it', + progress: 85, + className: 'bg-blue-100 text-blue-900 border-blue-300 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-800', + barClassName: 'bg-blue-500', + }, + maintaining: { + rank: 4, + label: 'Maintaining', + description: 'The project is publicly accessible, useable, and responding to ongoing feedback', + progress: 100, + className: 'bg-green-100 text-green-900 border-green-300 dark:bg-green-900/30 dark:text-green-200 dark:border-green-800', + barClassName: 'bg-green-500', + }, + drifting: { + rank: 5, + label: 'Drifting', + description: 'The project is still usable but not being actively maintained', + progress: 100, + className: 'bg-yellow-100 text-yellow-900 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-200 dark:border-yellow-800 opacity-80', + barClassName: 'bg-yellow-400 opacity-70', + }, + hibernating: { + rank: 6, + label: 'Hibernating', + description: 'The project is not currently usable or maintained', + progress: 100, + className: 'bg-red-100 text-red-900 border-red-300 dark:bg-red-900/30 dark:text-red-200 dark:border-red-800 opacity-80', + barClassName: 'bg-red-400 opacity-70', + }, +}; + +function asStage(value: string): Stage { + return (value in STAGES ? value : 'commenting') as Stage; +} + +interface StageBadgeProps { + stage: string; + className?: string; +} + +export function StageBadge({ stage, className }: StageBadgeProps) { + const meta = STAGES[asStage(stage)]; + return ( + + + + {meta.label} + + + {meta.description} + + ); +} + +interface StageProgressProps { + stage: string; + showLabel?: boolean; +} + +export function StageProgressBar({ stage, showLabel = true }: StageProgressProps) { + const meta = STAGES[asStage(stage)]; + return ( + + +
+
+
+
+ {showLabel && } +
+ + {meta.description} + + ); +} diff --git a/apps/web/src/components/TagChip.tsx b/apps/web/src/components/TagChip.tsx new file mode 100644 index 0000000..fcfa602 --- /dev/null +++ b/apps/web/src/components/TagChip.tsx @@ -0,0 +1,56 @@ +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import type { TagItem } from '@/lib/api'; + +const NAMESPACE_CLASSES: Record = { + topic: 'bg-purple-100 text-purple-900 border-purple-300 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-200 dark:border-purple-800', + tech: 'bg-cyan-100 text-cyan-900 border-cyan-300 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-200 dark:border-cyan-800', + event: 'bg-orange-100 text-orange-900 border-orange-300 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-200 dark:border-orange-800', +}; + +interface TagChipProps { + tag: Pick; + count?: number; + showNamespace?: boolean; + active?: boolean; + asLink?: boolean; + onClick?: () => void; + className?: string; +} + +export function TagChip({ tag, count, showNamespace = false, active = false, asLink = true, onClick, className }: TagChipProps) { + const nsClass = NAMESPACE_CLASSES[tag.namespace] ?? NAMESPACE_CLASSES['topic']!; + const display = showNamespace ? `${tag.namespace} · ${tag.title}` : tag.title; + const inner = ( + <> + {display} + {count !== undefined && ( + {count} + )} + + ); + const classes = cn( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors', + nsClass, + active && 'ring-2 ring-primary ring-offset-1', + className, + ); + + if (onClick) { + return ( + + ); + } + + if (asLink) { + return ( + + {inner} + + ); + } + + return {inner}; +} diff --git a/apps/web/src/hooks/useAuth.tsx b/apps/web/src/hooks/useAuth.tsx index 6d9c5c4..0a0327d 100644 --- a/apps/web/src/hooks/useAuth.tsx +++ b/apps/web/src/hooks/useAuth.tsx @@ -31,6 +31,13 @@ export interface AuthState { const AuthContext = createContext(null); +interface MeEnvelope { + data?: { + person?: AuthPerson | null; + accountLevel?: AccountLevel; + }; +} + async function fetchMe(): Promise { try { const res = await fetch('/api/auth/me', { credentials: 'include' }); @@ -38,8 +45,8 @@ async function fetchMe(): Promise { // 401 = anonymous, 404 = not yet implemented — both mean no session return null; } - const json = (await res.json()) as { data?: AuthPerson }; - return json.data ?? null; + const json = (await res.json()) as MeEnvelope; + return json.data?.person ?? null; } catch { // Network error — treat as anonymous, don't throw return null; diff --git a/apps/web/src/hooks/useSearch.ts b/apps/web/src/hooks/useSearch.ts index 3c9ac0b..cc16e9a 100644 --- a/apps/web/src/hooks/useSearch.ts +++ b/apps/web/src/hooks/useSearch.ts @@ -1,4 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, ApiError } from '@/lib/api'; +import { useNetworkError } from '@/components/NetworkErrorBanner'; export interface SearchResult { type: 'project' | 'member' | 'tag'; @@ -17,17 +19,24 @@ export interface SearchState { const DEBOUNCE_MS = 200; -// TODO(public-screens): replace with real API calls to /api/projects, /api/people, /api/tags -function mockSearch(q: string): SearchResult[] { - if (!q.trim()) return []; - return [ - { - type: 'project', - slug: 'example-project', - title: `Example project matching "${q}"`, - url: '/projects/example-project', - }, - ]; +async function performSearch(q: string): Promise { + const [projects, people, tags] = await Promise.all([ + api.projects.list({ q, perPage: 4 }), + api.people.list({ q, perPage: 4 }), + api.tags.list({ q, perPage: 4 }), + ]); + + const out: SearchResult[] = []; + for (const p of projects.data) { + out.push({ type: 'project', slug: p.slug, title: p.title, url: `/projects/${p.slug}` }); + } + for (const m of people.data) { + out.push({ type: 'member', slug: m.slug, title: m.fullName, url: `/members/${m.slug}` }); + } + for (const t of tags.data) { + out.push({ type: 'tag', slug: t.handle, title: t.title, url: `/tags/${t.namespace}/${t.slug}` }); + } + return out; } export function useSearch(): SearchState { @@ -35,28 +44,48 @@ export function useSearch(): SearchState { const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const timerRef = useRef | null>(null); + const reqIdRef = useRef(0); + const { showError } = useNetworkError(); - const setQuery = useCallback((q: string) => { - setQueryState(q); + const setQuery = useCallback( + (q: string) => { + setQueryState(q); - if (timerRef.current) { - clearTimeout(timerRef.current); - } + if (timerRef.current) { + clearTimeout(timerRef.current); + } - if (!q.trim()) { - setResults([]); - setLoading(false); - return; - } + if (!q.trim()) { + setResults([]); + setLoading(false); + return; + } - setLoading(true); - timerRef.current = setTimeout(() => { - // TODO(public-screens): replace with real fetch calls - const found = mockSearch(q); - setResults(found); - setLoading(false); - }, DEBOUNCE_MS); - }, []); + setLoading(true); + const reqId = ++reqIdRef.current; + timerRef.current = setTimeout(() => { + performSearch(q.trim()) + .then((found) => { + if (reqIdRef.current === reqId) { + setResults(found); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (reqIdRef.current === reqId) { + setResults([]); + setLoading(false); + } + if (err instanceof ApiError && err.isServerError) { + showError(); + } else if (!(err instanceof ApiError)) { + showError('Network error. Please check your connection and try again.'); + } + }); + }, DEBOUNCE_MS); + }, + [showError], + ); const clear = useCallback(() => { setQueryState(''); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..18d9aca --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,427 @@ +/** + * Typed API fetcher. + * + * Calls the read API and unwraps the standard response envelope per + * specs/api/conventions.md. Errors throw an ApiError carrying the status, + * code, message, and field-level error map. + */ + +export interface ResponseMeta { + readonly timestamp: string; +} + +export interface PaginationMeta extends ResponseMeta { + readonly page: number; + readonly perPage: number; + readonly totalItems: number; + readonly totalPages: number; + readonly facets?: Facets; +} + +export interface Facets { + readonly byTopic?: FacetEntry[]; + readonly byTech?: FacetEntry[]; + readonly byEvent?: FacetEntry[]; + readonly byStage?: FacetEntry[]; +} + +export interface FacetEntry { + readonly handle?: string; + readonly title?: string; + readonly slug?: string; + readonly namespace?: string; + readonly stage?: string; + readonly count: number; +} + +export interface SuccessEnvelope { + readonly success: true; + readonly data: T; + readonly metadata: ResponseMeta; +} + +export interface PaginatedEnvelope { + readonly success: true; + readonly data: T[]; + readonly metadata: PaginationMeta; +} + +export interface ErrorEnvelope { + readonly success: false; + readonly error: { + readonly code: string; + readonly message: string; + readonly traceId?: string; + readonly fields?: Record; + }; + readonly metadata: ResponseMeta; +} + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly fields?: Record; + readonly traceId?: string; + + constructor(status: number, code: string, message: string, fields?: Record, traceId?: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.fields = fields; + this.traceId = traceId; + } + + get isServerError(): boolean { + return this.status >= 500; + } +} + +export interface PersonAvatar { + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; +} + +export interface TagItem { + readonly namespace: string; + readonly slug: string; + readonly title: string; +} + +export interface ProjectListItem { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly stage: string; + readonly overviewExcerpt: string; + readonly maintainer: PersonAvatar | null; + readonly memberCount: number; + readonly members: PersonAvatar[]; + readonly links: { + readonly usersUrl: string | null; + readonly developersUrl: string | null; + readonly chatChannel: string | null; + }; + readonly openHelpWantedCount: number; + readonly tags: TagItem[]; + readonly featuredImageUrl?: string | null; + readonly updatedAt: string; +} + +export interface ProjectMembershipResponse { + readonly id: string; + readonly projectSlug: string; + readonly person: PersonAvatar; + readonly role: string | null; + readonly isMaintainer: boolean; + readonly joinedAt: string; +} + +export interface ProjectPermissions { + readonly canEdit: boolean; + readonly canManageMembers: boolean; + readonly canPostUpdate: boolean; + readonly canLogBuzz: boolean; + readonly canPostHelpWanted: boolean; + readonly canDelete: boolean; +} + +export interface HelpWantedRoleSummary { + readonly id: string; + readonly title: string; + readonly commitmentHoursPerWeek: number | null; + readonly status: string; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; +} + +export interface ProjectDetail { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly overview: string | null; + readonly overviewHtml: string; + readonly stage: string; + readonly stageProgress: number; + readonly maintainer: PersonAvatar | null; + readonly memberships: ProjectMembershipResponse[]; + readonly openHelpWantedRoles: HelpWantedRoleSummary[]; + readonly tags: { topic: TagItem[]; tech: TagItem[]; event: TagItem[] }; + readonly links: { + readonly usersUrl: string | null; + readonly developersUrl: string | null; + readonly chatChannel: string | null; + }; + readonly counts: { + readonly updates: number; + readonly buzz: number; + readonly members: number; + }; + readonly permissions: ProjectPermissions; + readonly featured: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface PersonListItem { + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly bioExcerpt: string; + readonly memberOfCount: number; + readonly tags: TagItem[]; + readonly createdAt: string; +} + +export interface PersonMembershipSummary { + readonly project: { readonly slug: string; readonly title: string; readonly stage: string }; + readonly role: string | null; + readonly isMaintainer: boolean; + readonly joinedAt: string; +} + +export interface ProjectUpdateSummary { + readonly id: string; + readonly number: number; + readonly project: { readonly slug: string; readonly title: string }; + readonly bodyHtml: string; + readonly createdAt: string; +} + +export interface PersonPermissions { + readonly canEdit: boolean; + readonly canChangeAccountLevel: boolean; +} + +export interface PersonDetail { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly firstName: string | null; + readonly lastName: string | null; + readonly avatarUrl: string | null; + readonly bio: string | null; + readonly bioHtml: string; + readonly accountLevel: string; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; + readonly memberships: PersonMembershipSummary[]; + readonly recentUpdates: ProjectUpdateSummary[]; + readonly permissions: PersonPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface TagResponse { + readonly id: string; + readonly handle: string; + readonly namespace: string; + readonly slug: string; + readonly title: string; + readonly projectCount: number; + readonly personCount: number; + readonly helpWantedCount: number; +} + +export interface HelpWantedPermissions { + readonly canEdit: boolean; + readonly canExpressInterest: boolean; + readonly alreadyExpressedInterest: boolean; + readonly canFill: boolean; + readonly canClose: boolean; +} + +export interface HelpWantedRoleResponse { + readonly id: string; + readonly project: { readonly slug: string; readonly title: string }; + readonly postedBy: PersonAvatar | null; + readonly title: string; + readonly description: string; + readonly descriptionHtml: string; + readonly commitmentHoursPerWeek: number | null; + readonly status: string; + readonly filledBy: PersonAvatar | null; + readonly filledAt: string | null; + readonly closedAt: string | null; + readonly tags: { topic: TagItem[]; tech: TagItem[] }; + readonly interestCount: number; + readonly permissions: HelpWantedPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface UpdatePermissions { + readonly canEdit: boolean; + readonly canDelete: boolean; +} + +export interface ProjectUpdateResponse { + readonly id: string; + readonly number: number; + readonly project: { readonly slug: string; readonly title: string }; + readonly author: PersonAvatar | null; + readonly body: string; + readonly bodyHtml: string; + readonly permissions: UpdatePermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface BuzzPermissions { + readonly canEdit: boolean; + readonly canDelete: boolean; +} + +export interface ProjectBuzzResponse { + readonly id: string; + readonly slug: string; + readonly project: { readonly slug: string; readonly title: string }; + readonly postedBy: PersonAvatar | null; + readonly headline: string; + readonly url: string; + readonly publishedAt: string; + readonly summary: string | null; + readonly summaryHtml: string; + readonly imageUrl: string | null; + readonly permissions: BuzzPermissions; + readonly createdAt: string; + readonly updatedAt: string; +} + +function buildQuery(params: object): string { + const usp = new URLSearchParams(); + for (const [key, val] of Object.entries(params as Record)) { + if (val === undefined || val === null || val === '') continue; + if (Array.isArray(val)) { + for (const v of val) { + if (v !== undefined && v !== null && v !== '') usp.append(key, String(v)); + } + } else { + usp.append(key, String(val)); + } + } + const s = usp.toString(); + return s ? `?${s}` : ''; +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'include', + ...init, + headers: { + Accept: 'application/json', + ...(init?.headers ?? {}), + }, + }); + + // 204 No Content + if (res.status === 204) { + return undefined as T; + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new ApiError(res.status, 'invalid_response', `Non-JSON response (${res.status})`); + } + + if (!res.ok) { + const err = body as ErrorEnvelope; + const code = err?.error?.code ?? 'unknown_error'; + const message = err?.error?.message ?? `Request failed with status ${res.status}`; + throw new ApiError(res.status, code, message, err?.error?.fields, err?.error?.traceId); + } + + return body as T; +} + +export interface ProjectListParams { + q?: string; + stage?: string; + stageIn?: string; + tag?: string[]; + maintainer?: string; + memberSlug?: string; + helpWanted?: boolean; + featured?: boolean; + includeDeleted?: boolean; + sort?: string; + page?: number; + perPage?: number; +} + +export interface PeopleListParams { + q?: string; + tag?: string[]; + accountLevel?: string; + sort?: string; + page?: number; + perPage?: number; +} + +export interface TagListParams { + namespace?: string; + q?: string; + taggableType?: string; + sort?: string; + page?: number; + perPage?: number; +} + +export interface HelpWantedListParams { + status?: string; + tag?: string[]; + commitmentMax?: number; + q?: string; + sort?: string; + page?: number; + perPage?: number; +} + +export interface FeedParams { + page?: number; + perPage?: number; + since?: string; + tag?: string[]; +} + +export const api = { + projects: { + list: (params: ProjectListParams = {}): Promise> => + request(`/api/projects${buildQuery(params)}`), + get: (slug: string): Promise> => + request(`/api/projects/${encodeURIComponent(slug)}`), + updates: (slug: string, params: { page?: number; perPage?: number } = {}): Promise> => + request(`/api/projects/${encodeURIComponent(slug)}/updates${buildQuery(params)}`), + buzz: (slug: string, params: { page?: number; perPage?: number } = {}): Promise> => + request(`/api/projects/${encodeURIComponent(slug)}/buzz${buildQuery(params)}`), + helpWanted: (slug: string, params: { status?: string; page?: number; perPage?: number } = {}): Promise> => + request(`/api/projects/${encodeURIComponent(slug)}/help-wanted${buildQuery(params)}`), + }, + people: { + list: (params: PeopleListParams = {}): Promise> => + request(`/api/people${buildQuery(params)}`), + get: (slug: string): Promise> => + request(`/api/people/${encodeURIComponent(slug)}`), + }, + tags: { + list: (params: TagListParams = {}): Promise> => + request(`/api/tags${buildQuery(params)}`), + get: (handle: string): Promise> => + request(`/api/tags/${encodeURIComponent(handle)}`), + }, + helpWanted: { + list: (params: HelpWantedListParams = {}): Promise> => + request(`/api/help-wanted${buildQuery(params)}`), + }, + projectUpdates: { + feed: (params: FeedParams = {}): Promise> => + request(`/api/project-updates${buildQuery(params)}`), + }, + projectBuzz: { + feed: (params: FeedParams = {}): Promise> => + request(`/api/project-buzz${buildQuery(params)}`), + }, +}; diff --git a/apps/web/src/lib/queryClient.tsx b/apps/web/src/lib/queryClient.tsx new file mode 100644 index 0000000..47d45e3 --- /dev/null +++ b/apps/web/src/lib/queryClient.tsx @@ -0,0 +1,46 @@ +import { useEffect, useMemo, type ReactNode } from 'react'; +import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useNetworkError } from '@/components/NetworkErrorBanner'; +import { ApiError } from '@/lib/api'; + +export function ApiQueryClientProvider({ children }: { children: ReactNode }) { + const { showError } = useNetworkError(); + + const client = useMemo( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false; + } + return failureCount < 2; + }, + refetchOnWindowFocus: false, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiError && error.isServerError) { + showError('Something went wrong. We are looking at it.'); + } else if (!(error instanceof ApiError)) { + // Network-level error (fetch threw): treat as server error + showError('Network error. Please check your connection and try again.'); + } + }, + }), + }), + [showError], + ); + + // Clean up on unmount + useEffect(() => { + return () => { + client.clear(); + }; + }, [client]); + + return {children}; +} diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts new file mode 100644 index 0000000..50012a1 --- /dev/null +++ b/apps/web/src/lib/time.ts @@ -0,0 +1,50 @@ +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; + +export function formatRelativeTime(iso: string, now: Date = new Date()): string { + const then = new Date(iso).getTime(); + const diff = now.getTime() - then; + const abs = Math.abs(diff); + const future = diff < 0; + + let value: number; + let unit: string; + + if (abs < MINUTE) { + return future ? 'in a moment' : 'just now'; + } else if (abs < HOUR) { + value = Math.round(abs / MINUTE); + unit = 'minute'; + } else if (abs < DAY) { + value = Math.round(abs / HOUR); + unit = 'hour'; + } else if (abs < WEEK) { + value = Math.round(abs / DAY); + unit = 'day'; + } else if (abs < MONTH) { + value = Math.round(abs / WEEK); + unit = 'week'; + } else if (abs < YEAR) { + value = Math.round(abs / MONTH); + unit = 'month'; + } else { + value = Math.round(abs / YEAR); + unit = 'year'; + } + + const plural = value === 1 ? '' : 's'; + return future ? `in ${value} ${unit}${plural}` : `${value} ${unit}${plural} ago`; +} + +export function formatAbsoluteDate(iso: string, opts: Intl.DateTimeFormatOptions = { dateStyle: 'long' }): string { + return new Date(iso).toLocaleDateString(undefined, opts); +} + +export function formatMonthYear(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short' }); +} diff --git a/apps/web/src/screens/HelpWantedIndex.tsx b/apps/web/src/screens/HelpWantedIndex.tsx new file mode 100644 index 0000000..83d7275 --- /dev/null +++ b/apps/web/src/screens/HelpWantedIndex.tsx @@ -0,0 +1,205 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { HelpWantedCard } from '@/components/HelpWantedCard'; +import { FacetSidebar } from '@/components/FacetSidebar'; +import { Pagination } from '@/components/Pagination'; +import { TagChip } from '@/components/TagChip'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; + +const COMMITMENT_OPTIONS = [ + { value: '', label: 'Any' }, + { value: '2', label: '≤ 2 hrs/week' }, + { value: '5', label: '≤ 5 hrs/week' }, + { value: '10', label: '≤ 10 hrs/week' }, +]; + +export function HelpWantedIndex() { + const [params, setParams] = useSearchParams(); + const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1); + const tags = params.getAll('tag'); + const commitmentMax = params.get('commitmentMax') ?? ''; + + const helpWantedQ = useQuery({ + queryKey: ['help-wanted-index', { tags, commitmentMax, page }], + queryFn: () => + api.helpWanted.list({ + status: 'open', + tag: tags.length ? tags : undefined, + commitmentMax: commitmentMax ? parseInt(commitmentMax, 10) : undefined, + page, + perPage: 20, + }), + }); + + 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 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('commitmentMax'); + }); + }, [updateParams]); + + const handlePageChange = useCallback( + (newPage: number) => { + updateParams((p) => p.set('page', String(newPage)), false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [updateParams], + ); + + const data = helpWantedQ.data?.data ?? []; + const meta = helpWantedQ.data?.metadata; + const totalItems = meta?.totalItems ?? 0; + const totalPages = meta?.totalPages ?? 1; + const facets = meta?.facets; + const hasActiveFilters = tags.length > 0 || commitmentMax !== ''; + + return ( +
+
+

+ Help Wanted + + {totalItems} + +

+

+ Concrete, time-boxed ways to contribute to Code for Philly projects. +

+
+ +
+ + +
+
+ {hasActiveFilters && ( + <> + Filters: + {tags.map((handle) => { + const [ns, ...slugParts] = handle.split('.'); + const slug = slugParts.join('.'); + return ( + handleToggleTag(handle)} + showNamespace + /> + ); + })} + {commitmentMax && ( + + )} + + + )} +
+ + {helpWantedQ.isLoading ? ( +

Loading roles…

+ ) : helpWantedQ.isError ? ( +

Failed to load roles.

+ ) : data.length === 0 ? ( +
+

+ No open roles match your filters.{' '} + {hasActiveFilters && ( + + )} +

+
+ ) : ( +
+ {data.map((role) => ( + + ))} +
+ )} + + +
+
+
+ ); +} diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx new file mode 100644 index 0000000..0687628 --- /dev/null +++ b/apps/web/src/screens/Home.tsx @@ -0,0 +1,222 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard'; +import { HelpWantedCard } from '@/components/HelpWantedCard'; +import { useAuth } from '@/hooks/useAuth'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +function FeaturedTile({ title, summary, slug, imageUrl }: { title: string; summary: string | null; slug: string; imageUrl?: string | null }) { + return ( + + {imageUrl ? ( + + ) : ( +
+ )} +
+

+ {title} +

+ {summary && ( +

{summary}

+ )} +
+ + ); +} + +const ACTIVITY_FILTERS = ['all', 'updates', 'buzz'] as const; +type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]; + +export function Home() { + const { person } = useAuth(); + const [activityFilter, setActivityFilter] = useState('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.fullName}

+

+ Member since {formatMonthYear(person.createdAt)} +

+ {allTags.length > 0 && ( +
+ {allTags.map((t) => ( + + ))} +
+ )} +
+ {person.permissions.canEdit && ( + + )} +
+ + {person.bioHtml && ( +
+

About

+ +
+ )} + +
+

+ Projects ({memberships.length}) +

+ {memberships.length === 0 ? ( +

+ Not a member of any projects yet.{' '} + + Browse projects → + +

+ ) : ( +
    + {memberships.map((m, idx) => ( +
  • +
    + + {m.project.title} + + + {m.role && ( + + {m.role} + + )} + {m.isMaintainer && ( + + Maintainer + + )} +
    + + joined {formatRelativeTime(m.joinedAt)} + +
  • + ))} +
+ )} +
+ + {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 && ( +
+

Overview

+ +
+ )} + + {(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.