Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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`)
5 changes: 3 additions & 2 deletions components/portal/DigitalTwinCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, React.ComponentType<{ className?: string }>> = {
mood: Smile,
Expand Down Expand Up @@ -111,7 +112,7 @@ export function DigitalTwinCard({ twin, petId, petName }: Props) {
<p className="text-xs text-[var(--muted)] mb-4 max-w-xs mx-auto">
{t("twinNotActiveDesc", { name: petName })}
</p>
<Link href={`/portal/pets/${petId}/health/log`} className="btn-primary text-sm">
<Link href={petHealthLogPath(petId)} className="btn-primary text-sm">
<CalendarDays className="w-4 h-4" />
{t("healthLogFirst")}
</Link>
Expand Down Expand Up @@ -188,7 +189,7 @@ export function DigitalTwinCard({ twin, petId, petName }: Props) {
{twin.daysAgo !== null && twin.daysAgo > 0 && (
<div className="pt-1 border-t border-[var(--border)]">
<Link
href={`/portal/pets/${petId}/health/log`}
href={petHealthLogPath(petId)}
className="text-xs text-[var(--teal)] hover:underline inline-flex items-center gap-1"
>
<CalendarDays className="w-3 h-3" />
Expand Down
3 changes: 2 additions & 1 deletion components/portal/HealthLogForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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));
}
}

Expand Down
15 changes: 8 additions & 7 deletions components/portal/QuickActions.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"),
Expand All @@ -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"),
Expand Down
63 changes: 32 additions & 31 deletions components/portal/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "/"));
}
Expand Down Expand Up @@ -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") }]
: []),
];

Expand All @@ -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],
},
];

Expand All @@ -125,7 +126,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l
{/* Logo */}
<div className="h-16 flex items-center px-5 border-b border-[var(--border)] flex-shrink-0">
<Link
href="/portal/dashboard"
href={PORTAL_ROUTES.dashboard}
className="font-bold text-[var(--ink)] text-lg no-underline flex items-center gap-2.5"
>
<div className="w-8 h-8 rounded-lg bg-[var(--teal)] flex items-center justify-center flex-shrink-0">
Expand Down Expand Up @@ -153,8 +154,8 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l
<div className="px-3 pb-3 border-t border-[var(--border)] pt-3 space-y-0.5">
<LocaleSwitcher current={locale} />
<Link
href="/portal/settings"
className={`nav-link ${pathname === "/portal/settings" ? "nav-link-active" : "nav-link-inactive"}`}
href={PORTAL_ROUTES.settings}
className={`nav-link ${pathname === PORTAL_ROUTES.settings ? "nav-link-active" : "nav-link-inactive"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{t("settings")}
Expand Down Expand Up @@ -187,7 +188,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l
{/* ── Mobile top bar ───────────────────────────────────────────────────── */}
<header className="lg:hidden fixed top-0 inset-x-0 h-14 bg-white border-b border-[var(--border)] flex items-center justify-between px-4 z-20">
<Link
href="/portal/dashboard"
href={PORTAL_ROUTES.dashboard}
className="font-bold text-[var(--ink)] text-lg no-underline flex items-center gap-2.5"
>
<div className="w-7 h-7 rounded-lg bg-[var(--teal)] flex items-center justify-center flex-shrink-0">
Expand All @@ -196,7 +197,7 @@ export default function SidebarNav({ userName, userEmail, userRole, hasSeller, l
{APP.name}
</Link>
<Link
href="/portal/settings"
href={PORTAL_ROUTES.settings}
className="w-9 h-9 rounded-full bg-[var(--teal-light)] flex items-center justify-center text-[var(--teal)] font-bold text-sm"
>
{initials}
Expand Down
65 changes: 65 additions & 0 deletions lib/config/routes.test.ts
Original file line number Diff line number Diff line change
@@ -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(
[],
);
});
});
38 changes: 38 additions & 0 deletions lib/config/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading