diff --git a/apps/web/src/community/forms.css b/apps/web/src/community/forms.css new file mode 100644 index 00000000000..87ac8279d97 --- /dev/null +++ b/apps/web/src/community/forms.css @@ -0,0 +1,244 @@ +.community-page { + background: var(--bg); + color: var(--ink); +} +.community-wrap { + max-width: 760px; + padding: 0 28px; + margin: 0 auto; +} +.community-intro { + padding: 64px 0 8px; +} +.community-page h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.12; + letter-spacing: -0.045em; + text-align: left; + margin: 0 0 24px; + color: var(--heading); +} +.community-heading-mark { + display: inline-block; + width: 0.95em; + height: 0.8em; + background-size: 177.78% 210.99%; + background-position: 50% 50.5%; + vertical-align: -0.025em; +} +.community-nowrap { + white-space: nowrap; +} +.community-intro > p:not(.community-help), +.community-thanks > p { + font-size: 1.0625rem; + line-height: 1.7; + margin: 0 0 16px; +} +.community-help { + color: var(--dim); + font-size: 0.875rem; + line-height: 1.6; + margin: 12px 0; +} +.community-page fieldset { + padding: 32px 0; + margin: 0; + border: 0; + border-bottom: 1px solid var(--border-soft); + min-width: 0; +} +.community-page legend { + padding: 0; + float: left; + width: 100%; + font-size: 1rem; + line-height: 1.55; + font-weight: 600; + color: var(--heading); + margin-bottom: 16px; +} +.community-page legend + * { + clear: both; +} +.community-page form { + counter-reset: community-question; +} +.community-page fieldset[data-question] { + counter-increment: community-question; +} +.community-number::before { + content: counter(community-question) ". "; +} +.community-number { + color: var(--dim); + font-weight: 400; +} +.community-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; +} +.community-option { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + line-height: 1.45; + font-size: 0.875rem; +} +.community-option:hover { + background: var(--state-hover); +} +.community-option:has(input:checked) { + background: var(--surface-recessed); + border-color: var(--dim); +} +.community-page input[data-other] { + margin-top: 12px; +} +.community-note { + margin: 10px 0 0; + color: var(--dim); + font-size: 0.8125rem; + line-height: 1.5; +} +.community-page input[type="radio"], +.community-page input[type="checkbox"] { + width: 16px; + height: 16px; + flex-shrink: 0; + margin: 2px 0 0; + accent-color: var(--ink); +} +.community-page input[type="text"], +.community-page input[type="email"], +.community-page textarea { + display: block; + width: 100%; + padding: 13px 15px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--ink); + font: inherit; + font-size: 1rem; + line-height: 1.5; + box-sizing: border-box; +} +.community-page textarea { + resize: vertical; +} +.community-page :is(input, textarea, button, a):focus-visible { + outline: 2px solid var(--ink); + outline-offset: 3px; +} +.community-required { + color: var(--del); + font-weight: 400; +} +.community-error { + color: var(--del); + font-size: 0.875rem; + line-height: 1.5; +} +.community-notice { + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font-size: 0.875rem; + line-height: 1.6; +} +.community-notice a { + color: inherit; +} +.community-thanks:focus { + outline: none; +} +.community-thanks { + padding: 80px 0; + min-height: 50vh; + text-align: center; +} +@media (max-width: 600px) { + .community-wrap { + padding: 0; + } + .community-intro { + padding-top: 40px; + } + .community-options { + grid-template-columns: 1fr; + } + .community-page fieldset { + padding: 28px 0; + } + .community-email-row { + flex-direction: column; + } +} + +.community-last-step { + display: block; + margin: 32px 0 0; + line-height: 1.7; +} +.community-email { + margin: 16px 0 0; + padding-bottom: 96px; +} +.community-email-row { + display: flex; + gap: 10px; +} +.community-email-row input[type="email"] { + flex: 1; + min-width: 0; +} +.community-email-row .btn { + flex-shrink: 0; + font: inherit; + font-weight: 500; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.community-spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: community-spin 0.8s linear infinite; +} +@keyframes community-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .community-spinner { + animation-duration: 2.4s; + } +} + +.community-section { + margin: 56px 0 0; + font-size: 1.5rem; + line-height: 1.25; + letter-spacing: -0.02em; + font-weight: 650; + color: var(--heading); +} +.community-section:first-of-type { + margin-top: 16px; +} +.community-page .community-section + fieldset { + padding-top: 16px; +} diff --git a/apps/web/src/community/survey-page.test.tsx b/apps/web/src/community/survey-page.test.tsx new file mode 100644 index 00000000000..f4b2ef36fa8 --- /dev/null +++ b/apps/web/src/community/survey-page.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { surveyOrder } from "./survey"; +import { SurveyPage } from "./survey-page"; + +const key = "bb-community-survey"; +const answers = { + discovery: ["GitHub"], + role: ["Student"], + work: ["Just me"], + love: ["Plugins"], + improve: ["Search"], + community: ["Get help when I'm stuck"], + city: ["Austin"], + meetup: ["Maybe"], + research_call: ["No"], + email: ["ada@example.test"], +}; +let root: Root | undefined; + +async function render() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + const container = document.createElement("div"); + document.body.replaceChildren(container); + root = createRoot(container); + await act(async () => + root!.render(), + ); +} +const field = (selector: string) => + document.querySelector(selector); +const click = (selector: string) => act(async () => field(selector)!.click()); +const submit = () => + act(async () => field("form")!.requestSubmit()); +const shown = (name: string) => field(`[data-question="${name}"]`) !== null; + +describe("survey page", () => { + afterEach(() => { + act(() => root?.unmount()); + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("restores saved progress and keeps saving changes", async () => { + localStorage.setItem( + key, + JSON.stringify({ role: ["Other"], role_other: ["Researcher"] }), + ); + await render(); + expect(field('[name="role_other"]')!.value).toBe("Researcher"); + await click('[name="work"][value="Just me"]'); + expect(JSON.parse(localStorage.getItem(key)!)).toMatchObject({ + role_other: ["Researcher"], + work: ["Just me"], + }); + }); + + it("ignores unreadable saved progress", async () => { + localStorage.setItem(key, "{not json"); + await render(); + expect(field('[name="city"]')!.value).toBe(""); + }); + + it("shows only the questions that apply", async () => { + await render(); + await click('[name="role"][value="Founder or executive"]'); + await click('[name="work"][value="2–5 people"]'); + expect( + ["work", "company_size", "team_improve", "team_adopt"].map(shown), + ).toEqual([true, true, true, false]); + await click('[name="work"][value="Just me"]'); + expect(["team_improve", "team_adopt"].map(shown)).toEqual([false, true]); + await click('[name="role"][value="Student"]'); + expect(["work", "company_size", "team_adopt"].map(shown)).toEqual([ + false, + false, + false, + ]); + }); + + it("asks for Other text while Other is picked", async () => { + await render(); + await click('[name="community"][value="Other"]'); + expect(document.activeElement).toBe(field('[name="community_other"]')); + await click('[name="community"][value="Other"]'); + expect(field('[name="community_other"]')).toBeNull(); + }); + + it("sends visible answers once, then focuses the thanks and clears progress", async () => { + const fetch = vi.fn(async (_url: string, _init: RequestInit) => + Response.json({ result: { duplicate: false } }), + ); + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("scrollTo", () => {}); + localStorage.setItem(key, JSON.stringify(answers)); + await render(); + expect(field("form")!.getAttribute("method")).toBe("post"); + await submit(); + expect(fetch).toHaveBeenCalledOnce(); + const body = JSON.parse(fetch.mock.calls[0]![1].body as string); + expect(body.answers.work).toBeUndefined(); + expect(field("main")!.textContent).toContain("We got your answers."); + expect(document.activeElement).toBe(field(".community-thanks")); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it("points to an unanswered checkbox question without sending", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + localStorage.setItem(key, JSON.stringify({ ...answers, community: [] })); + await render(); + await submit(); + expect(fetch).not.toHaveBeenCalled(); + expect(field('[role="alert"]')!.textContent).toContain( + "What would you most want from the bb community? Please answer this question.", + ); + }); + + it("shows a sending state, then keeps the answers when saving fails", async () => { + let finish: (response: Response) => void = () => {}; + vi.stubGlobal( + "fetch", + () => new Promise((resolve) => (finish = resolve)), + ); + localStorage.setItem(key, JSON.stringify(answers)); + await render(); + await submit(); + const button = field('[type="submit"]')!; + expect([button.disabled, button.textContent]).toEqual([true, "Sending…"]); + await act(async () => + finish( + Response.json( + { error: "We couldn't save your response. Please try again." }, + { status: 503 }, + ), + ), + ); + expect(field('[role="alert"]')!.textContent).toContain( + "We couldn't save your response.", + ); + expect(field('[name="city"]')!.value).toBe("Austin"); + expect([button.disabled, button.textContent]).toEqual([ + false, + "Send my answers", + ]); + }); +}); diff --git a/apps/web/src/community/survey-page.tsx b/apps/web/src/community/survey-page.tsx new file mode 100644 index 00000000000..58f4179d495 --- /dev/null +++ b/apps/web/src/community/survey-page.tsx @@ -0,0 +1,376 @@ +import { Fragment, useEffect, useRef, useState, type FormEvent } from "react"; +import { flushSync } from "react-dom"; +import { SiteFooter, SiteNav } from "../landing/site-chrome"; +import { + formValues, + questionVisible, + SECTIONS, + surveyAnswers, + surveyQuestions, + surveyReply, + validateSurvey, + type FormErrors, + type FormValues, + type SurveyOrder, + type SurveyReply, +} from "./survey"; + +const SURVEY_API_PATH = "/api/community/survey"; +const SURVEY_DESCRIPTION = + "We're figuring out what's next for bb, from product changes to meetups, and we'd love your input."; +const SURVEY_PURPOSE = + "This should take ~3 min. As a small thank you, we'll send you bb stickers free of charge (US-only for now). Thanks for building with us!"; +const CITY_HELP = "So we know where to start meetups."; +const QUESTION_NOTES: Record = { + meetup: "If you help organize, we'll help with budget, merch and more.", + research_call: + "We'll email you to find a time. We'd love to hear your feedback and share tips.", +}; +const EMAIL_NOTE = "We won't send you any marketing emails."; +const PROGRESS_KEY = "bb-community-survey"; + +function readProgress(): FormValues { + try { + const saved = formValues.safeParse( + JSON.parse(localStorage.getItem(PROGRESS_KEY) ?? "null"), + ); + return saved.success ? saved.data : {}; + } catch { + return {}; + } +} + +function writeProgress(values: FormValues | null) { + try { + if (values) localStorage.setItem(PROGRESS_KEY, JSON.stringify(values)); + else localStorage.removeItem(PROGRESS_KEY); + } catch {} +} + +async function send(id: string, answers: FormValues): Promise { + try { + const response = await fetch(SURVEY_API_PATH, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ submission_id: id, answers }), + }); + const body = surveyReply.safeParse(await response.json()); + if (body.success) return body.data; + } catch {} + return { error: "We couldn't send your answers. Please try again." }; +} + +function Question({ + question, + values, + errors, + order, + onChange, +}: { + question: (typeof surveyQuestions)[number]; + values: FormValues; + errors: FormErrors; + order: SurveyOrder; + onChange: (name: string, value: string[]) => void; +}) { + const { name } = question; + const selected = values[name] ?? []; + const city = values.city?.[0]?.trim(); + const otherName = `${name}_other`; + const other = useRef(null); + const pick = (option: string, checked: boolean) => { + const next = + question.type === "radio" + ? [option] + : checked + ? selected.filter((value) => value !== option) + : [...selected, option]; + if (option !== "Other" || checked) return onChange(name, next); + flushSync(() => onChange(name, next)); + other.current?.focus(); + }; + return ( +
+ + + + {name === "meetup" && city + ? `Would you come to a bb meetup in ${city}?` + : question.label} + + {question.type === "checkbox" && " (pick any)"} + + {" *"} + + + {name === "city" &&

{CITY_HELP}

} + {question.options.length ? ( +
+ {(order[name] ?? question.options).map((option) => { + const checked = selected.includes(option); + return ( + + ); + })} +
+ ) : question.type === "textarea" ? ( +