feat(web): responsive app shell + tab navigation

This commit is contained in:
Munkherdene 2026-08-22 20:39:22 +08:00
parent 071b5bdb6f
commit 08cae39f59
6 changed files with 73 additions and 19 deletions

19
src/app/(app)/AppNav.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { TabBar, type TabKey } from "@/ds";
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
function activeTabFor(pathname: string | null): TabKey {
const match = TAB_ORDER.find((key) => pathname?.startsWith(`/${key}`));
return match ?? "home";
}
/** Client-only sliver of the shell: reads the current route so the layout
* (a server component) can stay server-rendered while `TabBar` still knows
* which tab is active. */
export function AppNav() {
const pathname = usePathname();
return <TabBar active={activeTabFor(pathname)} />;
}

View file

@ -0,0 +1,6 @@
import { t } from "@/i18n/common";
// Placeholder — Task 9 replaces this with the real dashboard.
export default function HomePage() {
return <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>;
}

20
src/app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,20 @@
import type { ReactNode } from "react";
import { AppQueryProvider } from "@/api/queryClient";
import { AppNav } from "./AppNav";
/** Shell for every authed route (guarded by middleware). Bottom tab bar on
* mobile, left sidebar on desktop `TabBar` handles its own responsive
* switch, this just positions it next to a centered, max-width content
* column and wraps everything in the shared React Query provider. */
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AppQueryProvider>
<div className="md:flex md:min-h-screen">
<AppNav />
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-24 pt-6 md:pb-6 md:pt-8">
{children}
</main>
</div>
</AppQueryProvider>
);
}

View file

@ -0,0 +1,16 @@
import { render, screen } from "@testing-library/react";
import { it, expect, vi } from "vitest";
import { t } from "@/i18n/common";
vi.mock("next/navigation", () => ({
usePathname: () => "/home",
}));
import { AppNav } from "./AppNav";
it("renders all five tab labels", () => {
render(<AppNav />);
for (const label of Object.values(t.tabs)) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});

View file

@ -1,21 +1,5 @@
import { ActionButton } from "@seed-design/react";
import { redirect } from "next/navigation";
export default function Home() {
return (
<main style={{ padding: 24 }}>
<h1 style={{ fontWeight: 700 }}>Mercury</h1>
<button
style={{
background: "var(--mercury-brand-yellow)",
color: "var(--mercury-on-brand)",
padding: "12px 20px",
borderRadius: 12,
fontWeight: 600,
}}
>
Холбох
</button>
<ActionButton variant="brandSolid">Seed OK</ActionButton>
</main>
);
export default function RootPage() {
redirect("/home");
}

View file

@ -1,7 +1,16 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
// Mirrors tsconfig's "@/*" -> "./src/*" path mapping (Next resolves this
// natively; Vite/Vitest need it spelled out) so ds/ modules that import
// via "@/..." (e.g. TabBar.tsx -> "@/i18n/common") resolve under Vitest too.
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],