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
4 changes: 2 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Home } from '@/screens/Home';
import { ProjectsIndex } from '@/screens/ProjectsIndex';
import { ProjectDetail } from '@/screens/ProjectDetail';
import { ProjectEdit } from '@/screens/ProjectEdit';
import { ProjectBuzzNew } from '@/screens/ProjectBuzzNew';
import { PeopleIndex } from '@/screens/PeopleIndex';
import { PersonDetail } from '@/screens/PersonDetail';
import { ProfileEdit } from '@/screens/ProfileEdit';
Expand All @@ -23,7 +24,6 @@ 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 { Contact } from '@/pages/Contact';
import { StaticPage } from '@/pages/StaticPage';
import { NotFound } from '@/pages/NotFound';
Expand All @@ -45,7 +45,7 @@ const router = createBrowserRouter([
{ path: '/projects/:slug/edit', element: <ProjectEdit mode="edit" /> },
{ path: '/projects/:slug/updates/:number', element: <ProjectDetail anchor="update" /> },
{ path: '/projects/:slug/buzz/:buzzSlug', element: <ProjectDetail anchor="buzz" /> },
{ path: '/projects/:slug/buzz/new', element: <ComingSoon /> },
{ path: '/projects/:slug/buzz/new', element: <ProjectBuzzNew /> },
{ path: '/help-wanted', element: <HelpWantedIndex /> },
{ path: '/people', element: <Navigate to="/members" replace /> },
{ path: '/members', element: <PeopleIndex /> },
Expand Down
197 changes: 197 additions & 0 deletions apps/web/src/screens/ProjectBuzzNew.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* /projects/:slug/buzz/new — log a buzz item.
*
* Per specs/api/projects-buzz.md → POST /api/projects/:slug/buzz.
* Any signed-in user can log buzz on any project (laddr precedent).
* Anonymous callers are redirected to /login with a return-to back here.
*/
import { useState, type FormEvent } from 'react';
import { Link, Navigate, useNavigate, useParams } from 'react-router';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useAuth } from '@/hooks/useAuth';
import { api, ApiError, type CreateBuzzInput } from '@/lib/api';

interface FormState {
headline: string;
url: string;
publishedAt: string;
summary: string;
}

function todayIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

export function ProjectBuzzNew() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { person, loading: authLoading } = useAuth();

const [form, setForm] = useState<FormState>({
headline: '',
url: '',
publishedAt: todayIso(),
summary: '',
});
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});

if (!slug) return <Navigate to="/projects" replace />;
if (authLoading) {
return (
<div className="container mx-auto px-4 py-8 text-muted-foreground">Loading…</div>
);
}
if (!person) {
return (
<Navigate
to={`/login?return=${encodeURIComponent(`/projects/${slug}/buzz/new`)}`}
replace
/>
);
}

async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
if (!slug) return;
setSubmitting(true);
setFieldErrors({});
try {
const input: CreateBuzzInput = {
headline: form.headline.trim(),
url: form.url.trim(),
publishedAt: form.publishedAt,
summary: form.summary.trim() ? form.summary.trim() : null,
};
await api.projects.postBuzz(slug, input);
toast.success('Buzz logged');
navigate(`/projects/${slug}#activity`);
} catch (err) {
if (err instanceof ApiError) {
// Spec carves out `duplicate_url` at 409; surface inline on the URL field.
if (err.code === 'duplicate_url') {
setFieldErrors({ url: 'This URL is already logged for this project.' });
} else if (err.fields) {
setFieldErrors(err.fields);
}
toast.error(err.message || 'Failed to log buzz');
} else {
toast.error('Failed to log buzz');
}
} finally {
setSubmitting(false);
}
}

return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<header className="mb-6">
<h1 className="text-2xl font-bold">Log Buzz</h1>
<p className="text-sm text-muted-foreground mt-1">
Add a press mention, blog post, or external link about{' '}
<Link to={`/projects/${slug}`} className="text-primary underline hover:no-underline">
this project
</Link>
.
</p>
</header>

<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="headline">
Headline <span className="text-destructive">*</span>
</Label>
<Input
id="headline"
value={form.headline}
onChange={(e) => setForm((f) => ({ ...f, headline: e.target.value }))}
maxLength={200}
required
placeholder="The Inquirer praises Project X"
aria-invalid={fieldErrors['headline'] ? 'true' : 'false'}
/>
{fieldErrors['headline'] && (
<p className="text-xs text-destructive">{fieldErrors['headline']}</p>
)}
</div>

<div className="space-y-1.5">
<Label htmlFor="url">
URL <span className="text-destructive">*</span>
</Label>
<Input
id="url"
type="url"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
required
placeholder="https://www.inquirer.com/…"
aria-invalid={fieldErrors['url'] ? 'true' : 'false'}
/>
{fieldErrors['url'] && (
<p className="text-xs text-destructive">{fieldErrors['url']}</p>
)}
<p className="text-xs text-muted-foreground">
Must be HTTPS. Each URL can only be logged once per project.
</p>
</div>

<div className="space-y-1.5">
<Label htmlFor="publishedAt">
Published <span className="text-destructive">*</span>
</Label>
<Input
id="publishedAt"
type="date"
value={form.publishedAt}
onChange={(e) => setForm((f) => ({ ...f, publishedAt: e.target.value }))}
required
max={todayIso()}
aria-invalid={fieldErrors['publishedAt'] ? 'true' : 'false'}
/>
{fieldErrors['publishedAt'] && (
<p className="text-xs text-destructive">{fieldErrors['publishedAt']}</p>
)}
</div>

<div className="space-y-1.5">
<Label htmlFor="summary">Summary</Label>
<Textarea
id="summary"
value={form.summary}
onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
maxLength={2000}
rows={4}
placeholder="Optional excerpt or quote. Markdown supported."
/>
<p className="text-xs text-muted-foreground text-right">
{form.summary.length} / 2000
</p>
{fieldErrors['summary'] && (
<p className="text-xs text-destructive">{fieldErrors['summary']}</p>
)}
</div>

<div className="flex justify-end gap-2 pt-2">
<Button asChild variant="outline" type="button">
<Link to={`/projects/${slug}`}>Cancel</Link>
</Button>
<Button
type="submit"
disabled={submitting || !form.headline.trim() || !form.url.trim()}
>
{submitting ? 'Logging…' : 'Log Buzz'}
</Button>
</div>
</form>
</div>
);
}
142 changes: 142 additions & 0 deletions apps/web/tests/ProjectBuzzNew.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router';
import { renderScreen, mockOk } from './test-utils.js';
import { ProjectBuzzNew } from '../src/screens/ProjectBuzzNew.js';
import { AuthProvider } from '../src/hooks/useAuth.js';

const SIGNED_IN_PERSON = {
data: {
person: {
id: '01951a3c-0000-7000-8000-000000000001',
slug: 'jane-doe',
fullName: 'Jane Doe',
avatarUrl: null,
accountLevel: 'user',
},
accountLevel: 'user',
},
};

function renderForm() {
return renderScreen(
<AuthProvider>
<Routes>
<Route path="/projects/:slug/buzz/new" element={<ProjectBuzzNew />} />
<Route path="/projects/:slug" element={<div>Project page</div>} />
<Route path="/login" element={<div>Login page</div>} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/projects/transit-app/buzz/new'] },
);
}

describe('ProjectBuzzNew', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('redirects anonymous callers to /login with return-to', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
if (input.startsWith('/api/auth/me')) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch);

renderForm();
await waitFor(() => {
expect(screen.getByText(/login page/i)).toBeInTheDocument();
});
});

describe('signed-in', () => {
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => {
if (input.startsWith('/api/auth/me')) {
return Promise.resolve(
new Response(JSON.stringify(SIGNED_IN_PERSON), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
if (
input === '/api/projects/transit-app/buzz' &&
init?.method === 'POST'
) {
return Promise.resolve(
new Response(
JSON.stringify(
mockOk({
id: 'b1',
slug: 'fake-buzz',
project: { slug: 'transit-app', title: 'Transit' },
postedBy: null,
headline: 'Hello',
url: 'https://example.com',
publishedAt: '2026-05-01T00:00:00Z',
summary: null,
summaryHtml: '',
imageUrl: null,
permissions: { canEdit: false, canDelete: false },
createdAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
}),
),
{ status: 201, headers: { 'content-type': 'application/json' } },
),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch);
});

it('renders the form with required fields', async () => {
renderForm();
await waitFor(() => {
expect(screen.getByRole('heading', { name: /log buzz/i })).toBeInTheDocument();
});
expect(screen.getByLabelText(/headline/i)).toBeInTheDocument();
expect(screen.getByLabelText(/url/i)).toBeInTheDocument();
expect(screen.getByLabelText(/published/i)).toBeInTheDocument();
expect(screen.getByLabelText(/summary/i)).toBeInTheDocument();
});

it('disables submit while required fields are empty', async () => {
renderForm();
await waitFor(() => {
expect(screen.getByRole('button', { name: /log buzz/i })).toBeInTheDocument();
});
const submit = screen.getByRole('button', { name: /log buzz/i });
expect(submit).toBeDisabled();
});

it('enables submit once headline + url are filled', async () => {
renderForm();
await waitFor(() => {
expect(screen.getByLabelText(/headline/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/headline/i), { target: { value: 'Hello' } });
fireEvent.change(screen.getByLabelText(/url/i), {
target: { value: 'https://example.com' },
});
expect(screen.getByRole('button', { name: /log buzz/i })).not.toBeDisabled();
});

it('navigates to the project page on successful submit', async () => {
renderForm();
await waitFor(() => {
expect(screen.getByLabelText(/headline/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/headline/i), { target: { value: 'Hello' } });
fireEvent.change(screen.getByLabelText(/url/i), {
target: { value: 'https://example.com' },
});
fireEvent.click(screen.getByRole('button', { name: /log buzz/i }));
await waitFor(() => {
expect(screen.getByText(/project page/i)).toBeInTheDocument();
});
});
});
});
Loading