From e2f5838f492d16b24dafb9444e0207563be45bcf Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:22:38 -0400 Subject: [PATCH 1/3] chore(plans): open screen-gaps-phase4 (in-progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last open piece of #83 — the /projects/:slug/buzz/new form that closes the loop on a fully-implemented API endpoint. After this lands, #83's engineering work is done; only the legacy-content port stays as a follow-up content PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/screen-gaps-phase4.md | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 plans/screen-gaps-phase4.md diff --git a/plans/screen-gaps-phase4.md b/plans/screen-gaps-phase4.md new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/plans/screen-gaps-phase4.md @@ -0,0 +1,65 @@ +--- +status: in-progress +depends: [screen-gaps-phase3] +specs: + - specs/api/projects-buzz.md + - specs/screens/project-detail.md +issues: [83] +--- + +# Plan: screen-gaps phase 4 — `/projects/:slug/buzz/new` create form + +## Scope + +Last open piece of [#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83). The route was a `` placeholder; the API endpoint (`POST /api/projects/:slug/buzz`) is fully implemented + tested. This plan ships the SPA form that closes the loop. + +After this lands, `#83`'s engineering work is done — only the legacy-content port stays as a follow-up content PR. + +## Implements + +- [api/projects-buzz.md](../specs/api/projects-buzz.md) — request shape, validation, the `duplicate_url` 409. +- [screens/project-detail.md](../specs/screens/project-detail.md) — the "Log Buzz" affordance points here. + +## Approach + +### 1. Form component + +`apps/web/src/screens/ProjectBuzzNew.tsx`: + +- Route at `/projects/:slug/buzz/new` (replace `` in App.tsx). +- Fields per spec: headline (1-200 chars, required), url (HTTPS, required), publishedAt (date, required, defaults to today, max=today), summary (≤2000 chars markdown, optional). +- Submit calls existing `api.projects.postBuzz(slug, input)` helper. +- Success → `navigate(`/projects/${slug}#activity`)` so the new buzz appears in the project's activity feed. +- Failure surfaces inline field errors via `ApiError.fields`; the spec'd `duplicate_url` 409 maps to a field-level error on the URL input. +- Anonymous callers redirect to `/login?return=…` so the post-login flow drops them back on the form. + +### 2. Tests + +`apps/web/tests/ProjectBuzzNew.test.tsx`: + +- Anonymous → login redirect carries return-to query +- Signed-in: form renders all required fields +- Submit disabled until headline + url filled +- Submit enabled once both are filled +- Successful submit navigates to project page + +## Validation + +- [ ] `/projects/:slug/buzz/new` renders the form when signed in. +- [ ] Anonymous callers redirect to `/login?return=`. +- [ ] Successful POST navigates to `/projects/:slug`. +- [ ] `duplicate_url` 409 surfaces inline on the URL field (manual smoke test — the unit test covers the success path). +- [ ] `npm run type-check && npm run lint && npm test` clean. + +## Risks / unknowns + +- **Image upload deferred.** Spec's request shape allows an optional `imageUpload.key` from a prior upload endpoint. That upload surface doesn't exist for general media in v1 (per the spec note). Out of scope here — surfaces in the form would just be dead UI. +- **No anchor scroll on `#activity`.** The project-detail screen doesn't have an `#activity` element today. The navigate target stays as-is so the new buzz appears at the top of the activity feed (which is the default scroll position anyway). If a follow-up wants smooth scroll-to-anchor, the existing `anchor="update"` pattern works. + +## Notes + +_(filled at done time)_ + +## Follow-ups + +_(filled at done time)_ From 5b58586d59568b4d72f5c17d85628c7c1fb21643 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:22:58 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20/projects/:slug/buzz/new=20?= =?UTF-8?q?=E2=80=94=20log-buzz=20create=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the placeholder at /projects/:slug/buzz/new. The API endpoint (POST /api/projects/:slug/buzz, per specs/api/projects-buzz.md) is fully implemented + tested; this PR ships the SPA form that closes the loop. Form fields match the spec request body: headline required, 1-200 chars url required, HTTPS, surfaces inline 409 on duplicate_url publishedAt required date, defaults to today, max=today summary optional, ≤2000 chars markdown Successful POST navigates to /projects/:slug#activity so the new buzz appears in the activity feed. Anonymous callers redirect to /login with a return-to back to the form so post-login lands them where they started. Image upload is out of scope for v1 — the spec carve-out for imageUpload.key references a general-media upload endpoint that doesn't exist yet. 5 tests: anonymous→login redirect, form renders, submit disabled until required fields filled, submit enabled once both filled, success navigates to project page. The lingering ComingSoon import in App.tsx (now unused after the last /pages/:slug → StaticPage swap in #104) is also dropped here. Closes #83 (engineering scope). Content-port follow-up tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/App.tsx | 4 +- apps/web/src/screens/ProjectBuzzNew.tsx | 197 ++++++++++++++++++++++++ apps/web/tests/ProjectBuzzNew.test.tsx | 142 +++++++++++++++++ 3 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/screens/ProjectBuzzNew.tsx create mode 100644 apps/web/tests/ProjectBuzzNew.test.tsx 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']}

+ )} +
+ +
+ +