diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2102113..b9b91a5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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'; @@ -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'; @@ -45,7 +45,7 @@ const router = createBrowserRouter([ { path: '/projects/:slug/edit', element: }, { path: '/projects/:slug/updates/:number', element: }, { path: '/projects/:slug/buzz/:buzzSlug', element: }, - { path: '/projects/:slug/buzz/new', element: }, + { path: '/projects/:slug/buzz/new', element: }, { path: '/help-wanted', element: }, { path: '/people', element: }, { path: '/members', element: }, diff --git a/apps/web/src/screens/ProjectBuzzNew.tsx b/apps/web/src/screens/ProjectBuzzNew.tsx new file mode 100644 index 0000000..cb4c3b5 --- /dev/null +++ b/apps/web/src/screens/ProjectBuzzNew.tsx @@ -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({ + headline: '', + url: '', + publishedAt: todayIso(), + summary: '', + }); + const [submitting, setSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + + if (!slug) return ; + if (authLoading) { + return ( +
Loading…
+ ); + } + if (!person) { + return ( + + ); + } + + async function handleSubmit(e: FormEvent): Promise { + 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 ( +
+
+

Log Buzz

+

+ Add a press mention, blog post, or external link about{' '} + + this project + + . +

+
+ +
+
+ + setForm((f) => ({ ...f, headline: e.target.value }))} + maxLength={200} + required + placeholder="The Inquirer praises Project X" + aria-invalid={fieldErrors['headline'] ? 'true' : 'false'} + /> + {fieldErrors['headline'] && ( +

{fieldErrors['headline']}

+ )} +
+ +
+ + setForm((f) => ({ ...f, url: e.target.value }))} + required + placeholder="https://www.inquirer.com/…" + aria-invalid={fieldErrors['url'] ? 'true' : 'false'} + /> + {fieldErrors['url'] && ( +

{fieldErrors['url']}

+ )} +

+ Must be HTTPS. Each URL can only be logged once per project. +

+
+ +
+ + setForm((f) => ({ ...f, publishedAt: e.target.value }))} + required + max={todayIso()} + aria-invalid={fieldErrors['publishedAt'] ? 'true' : 'false'} + /> + {fieldErrors['publishedAt'] && ( +

{fieldErrors['publishedAt']}

+ )} +
+ +
+ +