diff --git a/README.md b/README.md index efd41fdc..c78c6b24 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,28 @@ signed ID tokens, checks private-key assertions, PKCE, nonce, signature failure, refresh and revocation. It requires no production credentials. The optional compiled HTTP tests are documented in [`docs/idp-phase4c-handoff.md`](docs/idp-phase4c-handoff.md). +## Freshman content + +Web › Freshman contains Resources, Telegram Hub, FAQs and Guide tabs. Resources and Telegram Hub read the +fixed website slots through `web.freshman.getContent`; FAQs select five questions from the existing FAQ catalog. +Edits and adjacent moves use authenticated server functions with `web:content:write`, forwarding the signed-in +user's token through the existing BFF. After mutations the dashboard reloads its loaders before closing dialogs. +A reload failure after a successful write reports that the change was saved, without offering to repeat it. +There is no browser storage, demo catalog or local restore action. + +The Guide tab keeps the existing PDF edition management and loads editions only when selected. The former +`/dashboard/web/guides` URL redirects to `/dashboard/web/freshman?tab=guide`. The selected tab stays in the URL; +the full FAQ catalog loads only on the FAQs tab. Page titles, introductory text and slot counts remain fixed. + +### Pending backend package release + +The source integration targets the backend Freshman contract on `bia/freshmans` (migration +`0026_freshman_content`). The currently pinned `@polinetwork/backend@1.4.0` does not yet export `web.freshman`. +Once a version containing these procedures is published, update this dependency and `pnpm-lock.yaml`, then +rerun `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Until that update, the normal typecheck is +expected to report the missing Freshman contract. No declaration patches or type-error suppression directives are used. +The running backend must also include the Freshman router and its migration before using this page. + ## Production and cutover `pnpm build` writes `.output/`; `pnpm start` runs it. Main-branch CI publishes `ghcr.io/polinetworkorg/admin:latest`. diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 747d07a8..4205f18f 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -76,7 +76,7 @@ export type PageMatch = | { kind: "m365-members" } | { kind: "web-projects" } | { kind: "web-associations" } - | { kind: "web-guides" } + | { kind: "web-freshman" } | { kind: "web-faqs" } | { kind: "web-labels" } | { kind: "web-categories" } @@ -209,13 +209,7 @@ const web: Service = { path: "/dashboard/web/associations", searchPlaceholder: "Search associations…", }, - { - id: "guides", - title: "Freshman guide", - icon: BookOpen, - path: "/dashboard/web/guides", - searchPlaceholder: "Search by version…", - }, + { id: "freshman", title: "Freshman", icon: BookOpen, path: "/dashboard/web/freshman" }, { id: "faqs", title: "FAQs", @@ -352,7 +346,8 @@ export function matchPath(pathname: string): PageMatch { if (third === undefined) { if (second === "projects") return { kind: "web-projects" } if (second === "associations") return { kind: "web-associations" } - if (second === "guides") return { kind: "web-guides" } + if (second === "freshman") return { kind: "web-freshman" } + if (second === "guides") return { kind: "web-freshman" } if (second === "faqs") return { kind: "web-faqs" } if (second === "group-labels") return { kind: "web-labels" } if (second === "groups-by-label") return { kind: "web-categories" } @@ -401,7 +396,7 @@ export function serviceFor(match: PageMatch): Service { return m365 case "web-projects": case "web-associations": - case "web-guides": + case "web-freshman": case "web-faqs": case "web-labels": case "web-categories": @@ -446,8 +441,8 @@ export function sectionFor(match: PageMatch): Section | null { return section(web, "projects") case "web-associations": return section(web, "associations") - case "web-guides": - return section(web, "guides") + case "web-freshman": + return section(web, "freshman") case "web-faqs": return section(web, "faqs") case "web-labels": diff --git a/src/features/faqs/faqs-page.tsx b/src/features/faqs/faqs-page.tsx index d86c706f..cb580d3b 100644 --- a/src/features/faqs/faqs-page.tsx +++ b/src/features/faqs/faqs-page.tsx @@ -17,6 +17,7 @@ import { appToast, Count, PageBar, PageContent, SearchField, useCan } from "@/co import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import type { FAQItem, FAQs } from "@/lib/api/types" +import { errorHasCode } from "@/lib/errors" import { formatNumber, pluralize } from "@/lib/format" import { cn } from "@/lib/utils" @@ -301,7 +302,11 @@ export function FAQsPage({ categories }: { categories: FAQs }) { await deleteCategoryFn({ data: { id: category.categoryId } }) } catch (error) { console.error(error) - throw new Error("Couldn't delete the category.") + throw new Error( + errorHasCode(error, "FEATURED_FAQ") + ? "A question in this category is featured on the freshman page. Replace it in Web › Freshman first." + : "Couldn't delete the category." + ) } edit.stop() editDirty.current = false @@ -324,7 +329,11 @@ export function FAQsPage({ categories }: { categories: FAQs }) { await deleteFAQFn({ data: { id: deletingFaq.faqId } }) } catch (error) { console.error(error) - throw new Error("Couldn't delete the FAQ.") + throw new Error( + errorHasCode(error, "FEATURED_FAQ") + ? "This question is featured on the freshman page. Replace it in Web › Freshman first." + : "Couldn't delete the FAQ." + ) } await router.invalidate({ sync: true }) setOpenIds((current) => current.filter((id) => id !== deletingFaq.faqId)) diff --git a/src/features/freshman/freshman-card-dialog.tsx b/src/features/freshman/freshman-card-dialog.tsx new file mode 100644 index 00000000..26c7504d --- /dev/null +++ b/src/features/freshman/freshman-card-dialog.tsx @@ -0,0 +1,119 @@ +import { useServerFn } from "@tanstack/react-start" +import { useId, useState } from "react" + +import { fieldControl, fieldHintId, FormDialog, FormField } from "@/components/primitives" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +import { FreshmanIconPicker } from "./freshman-icon" +import { updateFreshmanCard } from "./freshman.functions" +import { updateFreshmanCardInput } from "./freshman.validation" +import type { FreshmanCard, FreshmanCardInput, FreshmanCardErrors } from "./types" + +export function FreshmanCardDialog({ + card, + section, + open, + onClose, + onSaved, +}: { + card: FreshmanCard + open: boolean + section: "resources" | "telegram" + onClose: () => void + onSaved: () => Promise +}) { + const updateFreshmanCardFn = useServerFn(updateFreshmanCard) + const fieldPrefix = useId() + const initial: FreshmanCardInput = { + title: card.title, + description: card.description, + href: card.href, + icon: card.icon, + } + const [values, setValues] = useState(initial) + const [errors, setErrors] = useState({}) + const dirty = + values.title !== initial.title || + values.description !== initial.description || + values.href !== initial.href || + values.icon !== initial.icon + + async function submit() { + const parsed = updateFreshmanCardInput.safeParse({ id: card.id, ...values }) + const nextErrors: FreshmanCardErrors = {} + if (!parsed.success) + for (const issue of parsed.error.issues) { + const field = issue.path[0] + if (field === "title" || field === "description" || field === "href" || field === "icon") + nextErrors[field] = issue.message + } + setErrors(nextErrors) + if (!parsed.success) { + const firstInvalid = Object.keys(nextErrors)[0] + if (firstInvalid) document.getElementById(`${fieldPrefix}-${firstInvalid}`)?.focus() + return + } + await updateFreshmanCardFn({ data: parsed.data }) + await onSaved() + } + + return ( + { + if (!open) onClose() + }} + title={`Edit ${card.title}`} + description="Update this fixed position on the freshman page." + noun="card" + dirty={dirty} + submitLabel="Save changes" + canSubmit={dirty} + onSubmit={submit} + > + + setValues({ ...values, title: event.target.value })} + /> + + {section === "resources" && ( + +