feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)

* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account

Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).

Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): route every Codex OAuth callback redirect to /settings/integrations

Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.

After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.

All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-08 14:15:15 +02:00
1 parent ddab92e42b
commit 3221f86eea
18 files changed
+3879 -3253

No files matched your search

+40
View File
@@ -873,6 +873,46 @@
"settings": {
"title": "Einstellungen",
"subtitle": "Profil, Sicherheit & Verbindungen",
"shell": {
"sectionsNav": "Einstellungs-Bereiche"
},
"sections": {
"account": {
"title": "Konto",
"description": "Profil, Anmeldemethoden und Konto-Löschung."
},
"integrations": {
"title": "Integrationen",
"description": "Withings, moodLog und weitere verbundene Dienste."
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Telegram, ntfy, Web Push."
},
"dashboard": {
"title": "Übersicht",
"description": "Kachel-Layout, Schwellwerte."
},
"ai": {
"title": "KI-Auswertungen",
"description": "Provider, Modell, API-Key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer-Tokens für Drittanbieter-Clients."
},
"advanced": {
"title": "Erweitert",
"description": "Export, Import, Daten löschen."
},
"about": {
"title": "Über",
"description": "Version, Lizenz, Links."
},
"placeholder": {
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
}
},
"sectionsTitle": "Settings",
"categoryGeneral": "Allgemein",
"categoryNotifications": "Benachrichtigungen",
+40
View File
@@ -873,6 +873,46 @@
"settings": {
"title": "Settings",
"subtitle": "Profile, Security & Connections",
"shell": {
"sectionsNav": "Settings sections"
},
"sections": {
"account": {
"title": "Account",
"description": "Profile, sign-in methods, and account deletion."
},
"integrations": {
"title": "Integrations",
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Notifications",
"description": "Telegram, ntfy, Web Push."
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout, thresholds."
},
"ai": {
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer tokens for headless clients."
},
"advanced": {
"title": "Advanced",
"description": "Export, import, danger zone."
},
"about": {
"title": "About",
"description": "Version, license, links."
},
"placeholder": {
"coming_soon": "Moving here with the next release update."
}
},
"sectionsTitle": "Settings",
"categoryGeneral": "General",
"categoryNotifications": "Notifications",
+9 -9
View File
@@ -14,7 +14,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
}
const url = new URL(request.url);
@@ -29,13 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (error) {
annotate({ meta: { oauth_error: error } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
);
}
if (!code || !state) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=missing_params`,
`${appUrl}/settings/integrations?codex_error=missing_params`,
);
}
@@ -48,7 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!storedState || !storedVerifier || state !== storedState) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=invalid_state`,
`${appUrl}/settings/integrations?codex_error=invalid_state`,
);
}
@@ -70,9 +70,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + tokens.expires_in * 1000,
),
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
@@ -86,12 +84,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
annotate({ action: { name: "codex.oauth.callback.success" } });
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_connected=true`,
);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_token_error: message } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=token_exchange_failed`,
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
);
}
});
+12 -7
View File
@@ -30,7 +30,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
) {
return NextResponse.redirect(
new URL(
"/settings?withings=error&reason=state",
"/settings/integrations?withings=error&reason=state",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
@@ -41,7 +41,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (stateUserId !== user.id) {
return NextResponse.redirect(
new URL(
"/settings?withings=error&reason=user",
"/settings/integrations?withings=error&reason=user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
@@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) {
return NextResponse.redirect(
new URL(
"/settings?withings=error&reason=nocode",
"/settings/integrations?withings=error&reason=nocode",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
@@ -61,7 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!creds) {
return NextResponse.redirect(
new URL(
"/settings?withings=error&reason=nocreds",
"/settings/integrations?withings=error&reason=nocreds",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
@@ -90,7 +90,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
// Subscribe to webhooks in background
setupWebhook(user.id).catch((err) => getEvent()?.addWarning("Webhook setup failed: " + err));
setupWebhook(user.id).catch((err) =>
getEvent()?.addWarning("Webhook setup failed: " + err),
);
await auditLog("withings.connect", {
userId: user.id,
@@ -98,7 +100,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const response = NextResponse.redirect(
new URL("/settings?withings=connected", process.env.NEXT_PUBLIC_APP_URL!),
new URL(
"/settings/integrations?withings=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete("withings_state");
return response;
@@ -106,7 +111,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
getEvent()?.setError(err);
return NextResponse.redirect(
new URL(
"/settings?withings=error&reason=token",
"/settings/integrations?withings=error&reason=token",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
+1 -1
View File
@@ -163,7 +163,7 @@ export default function NotificationsPage() {
{t("notifications.noChannels")}
</p>
<Button asChild variant="outline">
<Link href="/settings">
<Link href="/settings/notifications">
<Settings className="mr-2 h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
+7 -2
View File
@@ -32,7 +32,12 @@ const CHANNEL_OPTIONS: ReadonlyArray<{
Icon: typeof MessageCircle;
titleKey: string;
hintKey: string;
/** Hash on /settings to scroll to. NONE = skip, no hash. */
/**
* Hash anchor on the notifications settings page to scroll to. NONE = skip,
* no hash. The wizard pushes the user to `/settings/notifications#<hash>`
* after onboarding finishes — see the v1.4 settings split (PR A2-shell)
* for the new route shape.
*/
hash: string | null;
}> = [
{
@@ -117,7 +122,7 @@ export default function OnboardingPage() {
toast.success(t("onboarding.v2.doneToast"));
if (opts?.gotoSettingsHash) {
router.replace(`/settings#${opts.gotoSettingsHash}`);
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
} else {
router.replace("/");
}
+1 -1
View File
@@ -861,7 +861,7 @@ export default function DashboardPage() {
* exactly the chart-width below. When the active tile count
* exceeds what fits the viewport, the strip horizontal-scrolls
* instead of wrapping; the user trims the set in
* Settings → Dashboard Layout (`/settings#dashboard-layout`).
* Settings → Dashboard (`/settings/dashboard`).
* Each tile keeps a `min-w-[10rem]` so a single tile still
* looks substantial on a wide screen, and `snap-x snap-mandatory`
* makes the scroll feel deliberate rather than arbitrary on
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
/**
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
* is pre-rendered at build via `generateStaticParams()` so the URLs are
* statically known to Next.js, while the `dynamicParams = false` flag below
* tells the router to 404 (instead of attempting on-demand rendering) for any
* slug not in the list — which is exactly what `notFound()` would do at
* request time, just earlier and without rendering.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
}
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ section: string }>;
}
export default async function SettingsSectionPage({ params }: PageProps) {
const { section } = await params;
// Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at
// routing time, but we re-check here so a hand-rolled override of the route
// config can never silently fall through to a typo'd slug.
if (!isSettingsSectionSlug(section)) {
notFound();
}
return (
<SettingsShell active={section}>
<SectionPlaceholder slug={section} />
</SettingsShell>
);
}
File diff suppressed because it is too large. Load diff
+18 -3216
View File
File diff suppressed because it is too large. Load diff
+21 -9
View File
@@ -158,7 +158,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<Avatar className="h-8 w-8">
{gravatarUrl && (
<AvatarImage src={gravatarUrl} alt={user.username} />
@@ -193,7 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="text-muted-foreground hover:text-foreground hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
{dropdownContent}
@@ -364,7 +370,9 @@ export function SidebarNav() {
<TooltipTrigger asChild>
<Link
href="/bugreport"
aria-current={pathname === "/bugreport" ? "page" : undefined}
aria-current={
pathname === "/bugreport" ? "page" : undefined
}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
pathname === "/bugreport"
@@ -382,11 +390,13 @@ export function SidebarNav() {
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/settings"
aria-current={pathname === "/settings" ? "page" : undefined}
href="/settings/account"
aria-current={
pathname.startsWith("/settings") ? "page" : undefined
}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
pathname === "/settings"
pathname.startsWith("/settings")
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
@@ -415,11 +425,13 @@ export function SidebarNav() {
{t("nav.bugreport")}
</Link>
<Link
href="/settings"
aria-current={pathname === "/settings" ? "page" : undefined}
href="/settings/account"
aria-current={
pathname.startsWith("/settings") ? "page" : undefined
}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
pathname === "/settings"
pathname.startsWith("/settings")
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
+1 -1
View File
@@ -66,7 +66,7 @@ export function TopBar() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer">
<Link href="/settings/account" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
{t("nav.settings")}
</Link>
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// `<SettingsShell>` reads the active route from `usePathname()`. We stub
// next/navigation so the SSR test render works without an App-Router
// runtime. The mock reads `mockPathnameRef.value` at call-time (not at
// module-load) so each test can mutate the ref before rendering and the
// shell will see the updated path.
const mockPathnameRef = { value: "/settings/account" };
vi.mock("next/navigation", () => ({
usePathname: () => mockPathnameRef.value,
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
SETTINGS_SECTION_SLUGS,
SETTINGS_SECTIONS,
SettingsShell,
isSettingsSectionSlug,
} from "../settings-shell";
function renderShell(props: {
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
pathname?: string;
locale?: "en" | "de";
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
return renderToStaticMarkup(
<I18nProvider initialLocale={props.locale ?? "en"}>
<SettingsShell active={props.active}>
<div>section body</div>
</SettingsShell>
</I18nProvider>,
);
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares exactly the eight sections agreed for v1.4", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"ai",
"api",
"advanced",
"about",
]);
});
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
expect(isSettingsSectionSlug(slug)).toBe(true);
}
expect(isSettingsSectionSlug("nope")).toBe(false);
expect(isSettingsSectionSlug("")).toBe(false);
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
});
it("each section has a non-empty title key under settings.sections.<slug>", () => {
for (const section of SETTINGS_SECTIONS) {
expect(section.titleKey).toBe(`settings.sections.${section.slug}.title`);
}
});
});
describe("<SettingsShell>", () => {
it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => {
const html = renderShell({ active: "account" });
for (const slug of SETTINGS_SECTION_SLUGS) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
// but the markup is always present so it's keyboard-discoverable
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
}
});
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
const html = renderShell({ active: "account" });
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
// `/settings#withings` → `/settings/integrations`) — they must NOT
// sneak back in.
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('href="/settings/ai"');
expect(html).not.toContain('href="/settings#');
expect(html).not.toContain('href="/settings"');
});
it('marks the active section with aria-current="page" (and only that one)', () => {
const html = renderShell({ active: "notifications" });
// Both layouts emit the active link, so two `aria-current="page"`.
const occurrences = html.match(/aria-current="page"/g);
expect(occurrences?.length ?? 0).toBe(2);
// And the active link is on the notifications href. Attribute order
// in React SSR is alphabetic, so `aria-current` precedes `href` —
// the regex deliberately doesn't pin order to stay robust against
// future React renderer changes.
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("derives the active slug from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/api" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("resolves every section title via the i18n provider — English", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).toContain("Account");
expect(html).toContain("Integrations");
expect(html).toContain("Notifications");
expect(html).toContain("Dashboard");
expect(html).toContain("AI Insights");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Advanced");
expect(html).toContain("About");
});
it("resolves every section title via the i18n provider — German", () => {
const html = renderShell({ active: "account", locale: "de" });
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
expect(html).toContain("Benachrichtigungen");
expect(html).toContain("Übersicht");
expect(html).toContain("KI-Auswertungen");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
expect(html).toContain("Über");
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).not.toContain("settings.sections.");
});
});
@@ -0,0 +1,73 @@
"use client";
/**
* `<SectionPlaceholder>` — the temporary body of every `/settings/[section]`
* route until the matching extraction PR lands.
*
* The shell + routing surface ship in PR A2-shell so reviewers can validate
* the URL structure in isolation. The follow-up PRs (A2-account, A2-about,
* A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder
* with the real content for each slug, copying from
* `src/app/settings/page.legacy.tsx`.
*/
import {
Bell,
Info,
KeyRound,
LayoutDashboard,
Link2,
Settings2,
Sparkles,
User,
type LucideIcon,
} from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import type { SettingsSectionSlug } from "./section-slugs";
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
integrations: Link2,
notifications: Bell,
dashboard: LayoutDashboard,
ai: Sparkles,
api: KeyRound,
advanced: Settings2,
about: Info,
};
export interface SectionPlaceholderProps {
slug: SettingsSectionSlug;
}
export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
const { t } = useTranslations();
const Icon = SLUG_ICON[slug];
const sectionTitle = t(`settings.sections.${slug}.title`);
const sectionDescription = t(`settings.sections.${slug}.description`);
return (
<section
aria-labelledby={`settings-section-${slug}-title`}
className="space-y-6"
>
<header className="space-y-1">
<h1
id={`settings-section-${slug}-title`}
className="text-2xl font-semibold tracking-tight"
>
{sectionTitle}
</h1>
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
</header>
<EmptyState
icon={<Icon className="size-6" />}
title={sectionTitle}
description={t("settings.sections.placeholder.coming_soon")}
/>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Server-safe constants for the v1.4 settings split. Kept in a separate
* module from `<SettingsShell>` (a client component) so server-rendered code
* paths — `generateStaticParams()`, the page guard, future tests — can import
* the slug list without dragging the whole client bundle into the server
* graph.
*/
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"ai",
"api",
"advanced",
"about",
] as const;
export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number];
export function isSettingsSectionSlug(
value: string,
): value is SettingsSectionSlug {
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
/**
* `<SettingsShell>` — sidebar + main column shell for the v1.4 settings split.
*
* Each section lives at its own route (`/settings/[section]/page.tsx`) and
* mounts inside this shell. The shell owns nothing but navigation: the active
* section is read from the route, the section heading + content are rendered
* by the page itself.
*
* Mobile-first per `docs/ui-guidelines.md`:
* - <md: section selector renders as a horizontal scroll strip at the top so
* a thumb-tap can swap sections without leaving the viewport.
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
*/
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bell,
Info,
KeyRound,
LayoutDashboard,
Link2,
Settings2,
Sparkles,
User,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "./section-slugs";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
// from `./section-slugs` directly, since this file is `"use client"` and
// values exported from a client module become unusable proxies on the
// server. (Discovered the hard way: `SETTINGS_SECTION_SLUGS.map(...)` in
// `generateStaticParams()` blows up at build time when imported through a
// client boundary.)
export {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
type SettingsSectionSlug,
};
interface SettingsSection {
slug: SettingsSectionSlug;
/** i18n key under `settings.sections.<slug>.title`. */
titleKey: string;
icon: LucideIcon;
}
/**
* Source of truth for the section list. Order matches the in-app navigation
* order so the sidebar, mobile strip, and `generateStaticParams()` all line
* up. Don't reorder without updating tests.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
icon: Link2,
},
{
slug: "notifications",
titleKey: "settings.sections.notifications.title",
icon: Bell,
},
{
slug: "dashboard",
titleKey: "settings.sections.dashboard.title",
icon: LayoutDashboard,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
slug: "advanced",
titleKey: "settings.sections.advanced.title",
icon: Settings2,
},
{ slug: "about", titleKey: "settings.sections.about.title", icon: Info },
] as const;
export interface SettingsShellProps {
/**
* Optional override for the active section. When omitted the shell reads
* the active slug from `usePathname()` (the production behaviour). Tests
* pass an explicit value so they don't need to mock the router.
*/
active?: SettingsSectionSlug;
children: React.ReactNode;
}
function deriveActiveSlug(
pathname: string | null,
override?: SettingsSectionSlug,
): SettingsSectionSlug {
if (override) return override;
if (!pathname) return "account";
// Match `/settings/<slug>` and ignore any trailing segments (none today,
// but cheap insurance for future nested routes).
const match = pathname.match(/^\/settings\/([^/]+)/);
const candidate = match?.[1] ?? "";
return isSettingsSectionSlug(candidate) ? candidate : "account";
}
export function SettingsShell({ active, children }: SettingsShellProps) {
const pathname = usePathname();
const { t } = useTranslations();
const activeSlug = deriveActiveSlug(pathname, active);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
{/* Mobile section strip — horizontal scroll, hidden on md+. */}
<nav
aria-label={t("settings.shell.sectionsNav")}
className="-mx-4 mb-4 overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
{SETTINGS_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
{/* Desktop sticky sidebar */}
<aside
aria-label={t("settings.shell.sectionsNav")}
className="hidden md:block"
>
<div className="sticky top-20">
<ul className="space-y-1">
{SETTINGS_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</div>
</aside>
{/* Main column — page renders its own h1 + subtitle */}
<main className="min-w-0">{children}</main>
</div>
</div>
);
}
@@ -83,11 +83,11 @@ describe("buildChecklist", () => {
it("attaches deep-link hrefs", () => {
const items = buildChecklist(inputs());
const hrefs = Object.fromEntries(items.map((i) => [i.id, i.href]));
expect(hrefs.profile).toBe("/settings#profile");
expect(hrefs.profile).toBe("/settings/account");
expect(hrefs.measurement).toBe("/measurements");
expect(hrefs.medication).toBe("/medications");
expect(hrefs.withings).toBe("/settings#withings");
expect(hrefs.notifications).toBe("/settings#notifications");
expect(hrefs.withings).toBe("/settings/integrations");
expect(hrefs.notifications).toBe("/settings/notifications");
});
});
+9 -4
View File
@@ -19,7 +19,12 @@ export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
export interface ChecklistItem {
id: ChecklistItemId;
done: boolean;
/** Pre-built href the CTA should jump to. Hash anchors target settings sections. */
/**
* Pre-built href the CTA should jump to. The settings entries point at
* the v1.4 split routes under `/settings/[section]` (introduced by PR
* A2-shell), so the CTA lands directly on the right section instead of
* relying on a hash anchor on the legacy monolith.
*/
href: string;
/** True if the user explicitly hid this row. */
dismissed: boolean;
@@ -58,7 +63,7 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
{
id: "profile",
done: profileDone,
href: "/settings#profile",
href: "/settings/account",
dismissed: inputs.dismissedIds.has("profile"),
},
{
@@ -76,13 +81,13 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
{
id: "withings",
done: inputs.withingsConnected,
href: "/settings#withings",
href: "/settings/integrations",
dismissed: inputs.dismissedIds.has("withings"),
},
{
id: "notifications",
done: inputs.notificationsConfigured,
href: "/settings#notifications",
href: "/settings/notifications",
dismissed: inputs.dismissedIds.has("notifications"),
},
];