Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { TagDetail } from '@/screens/TagDetail';
import { Volunteer } from '@/screens/Volunteer';
import { Sponsor } from '@/screens/Sponsor';
import { ComingSoon } from '@/pages/ComingSoon';
import { Contact } from '@/pages/Contact';
import { NotFound } from '@/pages/NotFound';
import { LoginPlaceholder } from '@/pages/LoginPlaceholder';
import { AccountClaim } from '@/pages/AccountClaim';
Expand Down Expand Up @@ -61,7 +62,7 @@ const router = createBrowserRouter([
{ path: '/account', element: <Account /> },
{ path: '/search', element: <SearchRedirect /> },
{ path: '/pages/:slug', element: <ComingSoon /> },
{ path: '/contact', element: <ComingSoon /> },
{ path: '/contact', element: <Contact /> },
{ path: '/login', element: <LoginPlaceholder /> },
{ path: '/account-claim', element: <AccountClaim /> },
{ path: '/account-claim/by-password', element: <AccountClaimByPassword /> },
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/components/StageInfoDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { STAGES, type Stage } from '@/components/StageBadge';
import { cn } from '@/lib/utils';

interface StageInfoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The project's current stage; highlighted in the list. */
currentStage?: string;
}

// Stage order matches the rank from specs/behaviors/project-stages.md.
const ORDERED_STAGES: readonly Stage[] = [
'commenting',
'bootstrapping',
'prototyping',
'testing',
'maintaining',
'drifting',
'hibernating',
];

export function StageInfoDialog({ open, onOpenChange, currentStage }: StageInfoDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>What does each stage mean?</DialogTitle>
<DialogDescription>
Every project carries one of seven lifecycle stages. See{' '}
<span className="italic">specs/behaviors/project-stages.md</span> for the canonical
source.
</DialogDescription>
</DialogHeader>

<ul className="space-y-3 mt-2">
{ORDERED_STAGES.map((s) => {
const meta = STAGES[s];
const isCurrent = s === currentStage;
return (
<li
key={s}
className={cn(
'rounded-md border px-3 py-2',
isCurrent
? 'border-primary bg-primary/5'
: 'border-border bg-card',
)}
>
<div className="flex items-baseline gap-2">
<span className="font-semibold">{meta.label}</span>
{isCurrent && (
<span className="text-xs text-primary">(this project)</span>
)}
</div>
<p className="text-sm text-muted-foreground">{meta.description}</p>
</li>
);
})}
</ul>
</DialogContent>
</Dialog>
);
}
24 changes: 24 additions & 0 deletions apps/web/src/pages/Contact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function Contact() {
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<h1 className="text-3xl font-bold mb-4">Contact</h1>
<p className="text-muted-foreground leading-relaxed">
Email us at{' '}
<a
href="mailto:hello@codeforphilly.org"
className="text-primary underline hover:no-underline"
>
hello@codeforphilly.org
</a>
.
</p>
<p className="text-muted-foreground mt-4">
For real-time chat, join our{' '}
<a href="/chat" className="text-primary underline hover:no-underline">
Slack workspace
</a>
.
</p>
</div>
);
}
53 changes: 53 additions & 0 deletions apps/web/src/screens/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { MarkdownView } from '@/components/MarkdownView';
import { StageProgressBar, StageBadge } from '@/components/StageBadge';
import { StageInfoDialog } from '@/components/StageInfoDialog';
import { TagChip } from '@/components/TagChip';
import { PersonAvatar } from '@/components/PersonAvatar';
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
Expand All @@ -26,6 +27,15 @@ function commitmentLabel(hours: number | null): string {
return `~${hours} hrs/week`;
}

function isGithubUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname === 'github.com' || u.hostname.endsWith('.github.com');
} catch {
return false;
}
}

export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
const params = useParams();
const slug = params['slug']!;
Expand All @@ -40,6 +50,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
const [manageMembersOpen, setManageMembersOpen] = useState(false);
const [interestRole, setInterestRole] = useState<HelpWantedRoleResponse | null>(null);
const [fillRole, setFillRole] = useState<HelpWantedRoleResponse | null>(null);
const [stageInfoOpen, setStageInfoOpen] = useState(false);

// Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker).
// Use the state-sync pattern so we don't trigger a cascading re-render.
Expand Down Expand Up @@ -403,6 +414,19 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
>
Copy link
</Button>
<Button
variant="outline"
onClick={() => {
// Copy a pre-formatted Slack message. Spec calls this
// out as either system-share or copy; copy works in every
// browser context without a Web Share API gate.
void navigator.clipboard.writeText(
`Check out ${project.title} on Code for Philly: https://codeforphilly.org/projects/${slug}`,
);
}}
>
Share to Slack
</Button>
</div>
</section>

Expand All @@ -424,10 +448,39 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
<span className="font-medium text-foreground">Stage:</span>
<StageBadge stage={project.stage} />
</p>
<p>
<button
type="button"
onClick={() => setStageInfoOpen(true)}
className="text-primary underline hover:no-underline"
>
What does this stage mean?
</button>
</p>
</section>

{/* Footer link — Edit on GitHub when developersUrl is a github.com URL */}
{project.links.developersUrl && isGithubUrl(project.links.developersUrl) && (
<section className="text-xs text-muted-foreground pt-2 border-t border-border">
<a
href={project.links.developersUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground"
>
Edit on GitHub →
</a>
</section>
)}
</aside>
</div>

<StageInfoDialog
open={stageInfoOpen}
onOpenChange={setStageInfoOpen}
currentStage={project.stage}
/>

<PostUpdateModal
open={updateModalOpen}
onOpenChange={setUpdateModalOpen}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/tests/Contact.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderScreen } from './test-utils.js';
import { Contact } from '../src/pages/Contact.js';

describe('Contact', () => {
it('renders a mailto link to hello@codeforphilly.org', () => {
renderScreen(<Contact />, { initialEntries: ['/contact'] });
expect(screen.getByRole('heading', { name: /^contact$/i })).toBeInTheDocument();
const mailtoLink = screen.getByRole('link', { name: /hello@codeforphilly\.org/i });
expect(mailtoLink).toHaveAttribute('href', 'mailto:hello@codeforphilly.org');
});

it('links to /chat for real-time chat', () => {
renderScreen(<Contact />, { initialEntries: ['/contact'] });
const slackLink = screen.getByRole('link', { name: /slack workspace/i });
expect(slackLink).toHaveAttribute('href', '/chat');
});
});
92 changes: 92 additions & 0 deletions apps/web/tests/ProjectDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,96 @@ describe('ProjectDetail', () => {
const chatLink = screen.getByRole('link', { name: /chat channel/i });
expect(chatLink).toHaveAttribute('href', '/chat?channel=sample');
});

it('renders the Share to Slack button alongside Copy link', async () => {
renderScreen(
<AuthProvider>
<Routes>
<Route path="/projects/:slug" element={<ProjectDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/projects/sample-project'] },
);

await waitFor(() => {
expect(screen.getByRole('button', { name: /^copy link$/i })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /share to slack/i })).toBeInTheDocument();
});

it('renders "What does this stage mean?" link', async () => {
renderScreen(
<AuthProvider>
<Routes>
<Route path="/projects/:slug" element={<ProjectDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/projects/sample-project'] },
);

await waitFor(() => {
expect(
screen.getByRole('button', { name: /what does this stage mean\?/i }),
).toBeInTheDocument();
});
});

it('does not render Edit on GitHub when developersUrl is absent', async () => {
renderScreen(
<AuthProvider>
<Routes>
<Route path="/projects/:slug" element={<ProjectDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/projects/sample-project'] },
);

await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument();
});
expect(screen.queryByRole('link', { name: /edit on github/i })).not.toBeInTheDocument();
});

it('renders Edit on GitHub when developersUrl is a github.com URL', async () => {
// Override the fetch mock for this case to add a github developersUrl.
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')) {
const projectWithGithub = {
...PROJECT,
links: { ...PROJECT.links, developersUrl: 'https://github.com/codeforphilly/sample-project' },
};
return Promise.resolve(
new Response(JSON.stringify(mockOk(projectWithGithub)), { status: 200, headers: { 'content-type': 'application/json' } }),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch);

renderScreen(
<AuthProvider>
<Routes>
<Route path="/projects/:slug" element={<ProjectDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/projects/sample-project'] },
);

await waitFor(() => {
expect(screen.getByRole('link', { name: /edit on github/i })).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /edit on github/i })).toHaveAttribute(
'href',
'https://github.com/codeforphilly/sample-project',
);
});
});
Loading