diff --git a/CLAUDE.md b/CLAUDE.md index 3cedca5..5d80de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,6 +347,7 @@ Algorithm: | Medication status labels, badge classes | `lib/config/medications.ts` | Component | | Locales, RTL flag | `lib/config/locales.ts` | next-intl config | | App name, email, URL | `lib/config/app.ts` | Component | +| Portal paths (static + `petPath`/`petHealthLogPath`) | `lib/config/routes.ts` | Component, nav config (inline `"/portal/..."`) | | Auth guards | `lib/auth/guards.ts` | Page component | | Signal computation logic | `lib/domain/pet-signal.ts` | API route, cron | | Email templates | `lib/email/templates.ts` | API route (inline) | @@ -509,3 +510,4 @@ All `/api/cron/*` routes require `Authorization: Bearer CRON_SECRET`. - Order placement logic written in an API route → `placeOrder()` in `lib/domain/orders.ts` - A message key added to `messages/en.json` only → all 9 locales must carry it (enforced by `lib/i18n/message-keys.test.ts`) - A "Sign up to buy" CTA on a public product → the storefront sells without an account now +- A `"/portal/..."` literal in a component → `lib/config/routes.ts` (enforced by `lib/config/routes.test.ts`) diff --git a/components/portal/DigitalTwinCard.tsx b/components/portal/DigitalTwinCard.tsx index 37aa957..65f7705 100644 --- a/components/portal/DigitalTwinCard.tsx +++ b/components/portal/DigitalTwinCard.tsx @@ -15,6 +15,7 @@ import { import { TWIN_STATE_CONFIG, TWIN_TREND_CONFIG } from "@/lib/config/digital-twin"; import type { TwinState, TwinTrend } from "@/lib/domain/digital-twin"; import { useTranslations } from "next-intl"; +import { petHealthLogPath } from "@/lib/config/routes"; const METRIC_ICONS: Record> = { mood: Smile, @@ -111,7 +112,7 @@ export function DigitalTwinCard({ twin, petId, petName }: Props) {

{t("twinNotActiveDesc", { name: petName })}

- + {t("healthLogFirst")} @@ -188,7 +189,7 @@ export function DigitalTwinCard({ twin, petId, petName }: Props) { {twin.daysAgo !== null && twin.daysAgo > 0 && (
diff --git a/components/portal/HealthLogForm.tsx b/components/portal/HealthLogForm.tsx index 48b53f6..7026e2a 100644 --- a/components/portal/HealthLogForm.tsx +++ b/components/portal/HealthLogForm.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { EMOTIONAL_METRICS, HEALTH_METRIC_CONFIG } from "@/lib/config/health-metrics"; +import { petPath } from "@/lib/config/routes"; type PhysicalHint = { min: number; max: number; unit: string } | null; type InitialValues = { @@ -83,7 +84,7 @@ export function HealthLogForm({ if (!data.success) { setError(data.error ?? t("saveFailed")); } else { - router.push(`/portal/pets/${petId}`); + router.push(petPath(petId)); } } diff --git a/components/portal/QuickActions.tsx b/components/portal/QuickActions.tsx index 2a9eab7..a6ec58b 100644 --- a/components/portal/QuickActions.tsx +++ b/components/portal/QuickActions.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { Search, ShoppingBag, Heart, Store, Briefcase, CalendarCheck } from "lucide-react"; import { getTranslations } from "next-intl/server"; +import { PORTAL_ROUTES } from "@/lib/config/routes"; /** * The dashboard used to answer one question — "how are my pets?" — and left @@ -20,21 +21,21 @@ export default async function QuickActions({ const t = await getTranslations({ locale, namespace: "portal" }); const actions: { href: string; icon: React.ElementType; title: string; desc: string }[] = [ - { href: "/portal/find", icon: Search, title: t("qaFindTitle"), desc: t("qaFindDesc") }, - { href: "/portal/shop", icon: ShoppingBag, title: t("qaShopTitle"), desc: t("qaShopDesc") }, - { href: "/portal/adopt", icon: Heart, title: t("qaAdoptTitle"), desc: t("qaAdoptDesc") }, + { href: PORTAL_ROUTES.find, icon: Search, title: t("qaFindTitle"), desc: t("qaFindDesc") }, + { href: PORTAL_ROUTES.shop, icon: ShoppingBag, title: t("qaShopTitle"), desc: t("qaShopDesc") }, + { href: PORTAL_ROUTES.adopt, icon: Heart, title: t("qaAdoptTitle"), desc: t("qaAdoptDesc") }, ]; if (isProfessional) { actions.push({ - href: "/portal/bookings", + href: PORTAL_ROUTES.bookings, icon: CalendarCheck, title: t("qaBookingsTitle"), desc: t("qaBookingsDesc"), }); } else { actions.push({ - href: "/portal/become-a-pro", + href: PORTAL_ROUTES.becomeAPro, icon: Briefcase, title: t("qaOfferTitle"), desc: t("qaOfferDesc"), @@ -44,13 +45,13 @@ export default async function QuickActions({ actions.push( isSeller ? { - href: "/portal/my-products", + href: PORTAL_ROUTES.myProducts, icon: Store, title: t("qaMyStoreTitle"), desc: t("qaMyStoreDesc"), } : { - href: "/portal/seller-profile", + href: PORTAL_ROUTES.sellerProfile, icon: Store, title: t("qaSellTitle"), desc: t("qaSellDesc"), diff --git a/components/portal/SidebarNav.tsx b/components/portal/SidebarNav.tsx index 94bca68..be9c3d1 100644 --- a/components/portal/SidebarNav.tsx +++ b/components/portal/SidebarNav.tsx @@ -17,9 +17,10 @@ import { Store, } from "lucide-react"; import { APP } from "@/lib/config/app"; +import { PORTAL_ROUTES } from "@/lib/config/routes"; function isActive(pathname: string, href: string, match?: string[]): boolean { - if (href === "/portal/dashboard") return pathname === href; + if (href === PORTAL_ROUTES.dashboard) return pathname === href; const prefixes = match ?? [href]; return prefixes.some((p) => pathname === p || pathname.startsWith(p + "/")); } @@ -47,39 +48,39 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l // services) live in Settings, not in daily navigation. type NavItem = { href: string; icon: React.ElementType; label: string; match?: string[] }; const NAV_ITEMS: NavItem[] = [ - { href: "/portal/dashboard", icon: Home, label: t("dashboard") }, - { href: "/portal/pets", icon: PawPrint, label: t("myPets") }, - { href: "/portal/checkin", icon: CalendarDays, label: t("checkin") }, + { href: PORTAL_ROUTES.dashboard, icon: Home, label: t("dashboard") }, + { href: PORTAL_ROUTES.pets, icon: PawPrint, label: t("myPets") }, + { href: PORTAL_ROUTES.checkin, icon: CalendarDays, label: t("checkin") }, { - href: "/portal/find", + href: PORTAL_ROUTES.find, icon: Search, label: t("navSectionCare"), - match: ["/portal/find", "/portal/bookings"], + match: [PORTAL_ROUTES.find, PORTAL_ROUTES.bookings], }, { - href: "/portal/shop", + href: PORTAL_ROUTES.shop, icon: ShoppingCart, label: t("shop"), - match: ["/portal/shop", "/portal/orders"], + match: [PORTAL_ROUTES.shop, PORTAL_ROUTES.orders], }, { - href: "/portal/adopt", + href: PORTAL_ROUTES.adopt, icon: Heart, label: t("adopt"), - match: ["/portal/adopt", "/portal/adoptions"], + match: [PORTAL_ROUTES.adopt, PORTAL_ROUTES.adoptions], }, ...(hasSeller ? [ { - href: "/portal/my-products", + href: PORTAL_ROUTES.myProducts, icon: Store, label: t("navMyStore"), - match: ["/portal/my-products", "/portal/seller-profile"], + match: [PORTAL_ROUTES.myProducts, PORTAL_ROUTES.sellerProfile], }, ] : []), ...(isProfessional - ? [{ href: "/portal/professional-profile", icon: Stethoscope, label: t("myProfile") }] + ? [{ href: PORTAL_ROUTES.professionalProfile, icon: Stethoscope, label: t("myProfile") }] : []), ]; @@ -88,33 +89,33 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l // (the natural follow-up destination after using Find a Pro). // Professionals swap "Find" for their own profile since they are the pro. const MOBILE_NAV_ITEMS_DEFAULT: NavItem[] = [ - { href: "/portal/dashboard", icon: Home, label: t("dashboard") }, - { href: "/portal/pets", icon: PawPrint, label: t("myPets") }, - { href: "/portal/checkin", icon: CalendarDays, label: t("checkin") }, + { href: PORTAL_ROUTES.dashboard, icon: Home, label: t("dashboard") }, + { href: PORTAL_ROUTES.pets, icon: PawPrint, label: t("myPets") }, + { href: PORTAL_ROUTES.checkin, icon: CalendarDays, label: t("checkin") }, { - href: "/portal/find", + href: PORTAL_ROUTES.find, icon: Search, label: t("navSectionCare"), - match: ["/portal/find", "/portal/bookings"], + match: [PORTAL_ROUTES.find, PORTAL_ROUTES.bookings], }, { - href: "/portal/shop", + href: PORTAL_ROUTES.shop, icon: ShoppingCart, label: t("shop"), - match: ["/portal/shop", "/portal/orders"], + match: [PORTAL_ROUTES.shop, PORTAL_ROUTES.orders], }, ]; const MOBILE_NAV_ITEMS_PRO: NavItem[] = [ - { href: "/portal/dashboard", icon: Home, label: t("dashboard") }, - { href: "/portal/pets", icon: PawPrint, label: t("myPets") }, - { href: "/portal/checkin", icon: CalendarDays, label: t("checkin") }, - { href: "/portal/professional-profile", icon: Stethoscope, label: t("myProfile") }, + { href: PORTAL_ROUTES.dashboard, icon: Home, label: t("dashboard") }, + { href: PORTAL_ROUTES.pets, icon: PawPrint, label: t("myPets") }, + { href: PORTAL_ROUTES.checkin, icon: CalendarDays, label: t("checkin") }, + { href: PORTAL_ROUTES.professionalProfile, icon: Stethoscope, label: t("myProfile") }, { - href: "/portal/find", + href: PORTAL_ROUTES.find, icon: Search, label: t("navSectionCare"), - match: ["/portal/find", "/portal/bookings"], + match: [PORTAL_ROUTES.find, PORTAL_ROUTES.bookings], }, ]; @@ -125,7 +126,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l {/* Logo */}
@@ -153,8 +154,8 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l
{t("settings")} @@ -187,7 +188,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l {/* ── Mobile top bar ───────────────────────────────────────────────────── */}
@@ -196,7 +197,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l {APP.name} {initials} diff --git a/lib/config/routes.test.ts b/lib/config/routes.test.ts new file mode 100644 index 0000000..c76740a --- /dev/null +++ b/lib/config/routes.test.ts @@ -0,0 +1,65 @@ +/** + * Portal paths live in exactly one place. + * + * They were literals scattered through the nav: 38 occurrences of thirteen + * paths in `SidebarNav.tsx` alone, with `/portal/dashboard` and `/portal/find` + * hand-typed six times each. Renaming a route meant finding every copy, and a + * missed one did not fail to compile — it became a dead link only a visitor + * would discover. + * + * This test is the reason it stays fixed. A literal creeping back into a nav + * component fails here rather than in production. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { PORTAL_ROUTES } from "./routes"; + +const ROOT = join(__dirname, "..", ".."); +const NAV_DIRS = [join(ROOT, "components", "portal")]; + +function tsxFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...tsxFiles(full)); + } else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) { + out.push(full); + } + } + return out; +} + +describe("PORTAL_ROUTES", () => { + it("has no duplicate paths", () => { + const values = Object.values(PORTAL_ROUTES); + expect(new Set(values).size).toBe(values.length); + }); + + it("uses the /portal prefix consistently", () => { + for (const path of Object.values(PORTAL_ROUTES)) { + expect(path.startsWith("/portal/")).toBe(true); + expect(path.endsWith("/")).toBe(false); + } + }); + + it("is the only place a portal path is written down", () => { + const offenders: string[] = []; + for (const dir of NAV_DIRS) { + for (const file of tsxFiles(dir)) { + const source = readFileSync(file, "utf8"); + for (const [line, text] of source.split("\n").entries()) { + // Match a quoted /portal/... literal. The constants file itself is + // not scanned — it is the one place these are allowed to exist. + if (/["'`]\/portal\/[a-z-]+/.test(text)) { + offenders.push(`${file.replace(ROOT + "/", "")}:${line + 1} ${text.trim()}`); + } + } + } + } + expect(offenders, `import from @/lib/config/routes instead:\n${offenders.join("\n")}`).toEqual( + [], + ); + }); +}); diff --git a/lib/config/routes.ts b/lib/config/routes.ts new file mode 100644 index 0000000..e8ecf18 --- /dev/null +++ b/lib/config/routes.ts @@ -0,0 +1,38 @@ +/** + * Every portal path, written down once. + * + * These were string literals scattered through the nav components — thirteen + * distinct paths across 38 occurrences in `SidebarNav.tsx` alone, with + * `/portal/dashboard` and `/portal/find` hand-typed six times each. Renaming a + * route meant finding every copy, and the ones you missed did not fail to + * compile: they became dead links that only a visitor discovers. + * + * Adding a route means adding it here. Nothing else should contain a + * `"/portal/..."` literal. + */ +export const PORTAL_ROUTES = { + dashboard: "/portal/dashboard", + pets: "/portal/pets", + checkin: "/portal/checkin", + find: "/portal/find", + bookings: "/portal/bookings", + shop: "/portal/shop", + orders: "/portal/orders", + adopt: "/portal/adopt", + adoptions: "/portal/adoptions", + myProducts: "/portal/my-products", + sellerProfile: "/portal/seller-profile", + professionalProfile: "/portal/professional-profile", + becomeAPro: "/portal/become-a-pro", + settings: "/portal/settings", +} as const; + +export type PortalRoute = (typeof PORTAL_ROUTES)[keyof typeof PORTAL_ROUTES]; + +/** + * Paths that need an id. Functions rather than constants for the same reason + * the constants exist: a template literal in a component is a copy, and the + * copies drift. `/portal/pets/${petId}/health/log` was written in two files. + */ +export const petPath = (petId: string) => `${PORTAL_ROUTES.pets}/${petId}` as const; +export const petHealthLogPath = (petId: string) => `${petPath(petId)}/health/log` as const;