feat(layout): login, uygulama kabuğu, alt menü, hızlı ekle ve dashboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:35:10 +03:00
parent 4dbd2eb441
commit 1cf9fd9d8c
31 changed files with 1061 additions and 14 deletions
+14
View File
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
export default function DashboardLoading() {
return (
<>
<div className="space-y-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-80 max-w-full" />
</div>
<SummaryCardsSkeleton />
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
export const metadata: Metadata = { title: tr.dashboard.title };
type SummaryCard = {
key: keyof typeof tr.dashboard.cards;
tone?: string;
amountMinor: number | null;
};
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
async function getSummary(): Promise<SummaryCard[]> {
return [
{ key: "monthIncome", tone: "text-income", amountMinor: null },
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
{ key: "totalBalance", amountMinor: null },
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
{ key: "netWorth", amountMinor: null },
];
}
async function SummaryCards() {
const cards = await getSummary();
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{cards.map((c) => (
<Card key={c.key} size="sm">
<CardHeader>
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
</CardTitle>
</CardHeader>
{c.amountMinor === null ? (
<CardContent className="text-xs text-muted-foreground">
{tr.dashboard.noData}
</CardContent>
) : null}
</Card>
))}
</div>
);
}
export default function DashboardPage() {
const user = MOCK_SESSION_USER;
return (
<>
<PageHeader
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={tr.dashboard.welcomeDescription}
/>
<Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards />
</Suspense>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { AppHeader } from "@/components/layout/app-header";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { BottomNav } from "@/components/layout/bottom-nav";
import { QuickAddProvider } from "@/components/layout/quick-add";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { MOCK_HOUSEHOLDS } from "@/features/households/mock";
import { APP_VERSION } from "@/lib/version";
/**
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle.
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login.
*/
export default function AppLayout({ children }: { children: React.ReactNode }) {
const user = MOCK_SESSION_USER;
return (
<SidebarProvider>
<QuickAddProvider>
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
<SidebarInset>
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} />
{/* Mobilde alt menü yüksekliği kadar boşluk */}
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
</SidebarInset>
<BottomNav />
</QuickAddProvider>
</SidebarProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { ThemeToggle } from "@/components/layout/theme-toggle";
/** Oturum gerektirmeyen ekranlar (yalnızca giriş). */
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-dvh flex-1 flex-col items-center justify-center bg-gradient-to-b from-primary/5 via-background to-background px-4 py-10">
<div className="absolute top-3 right-3">
<ThemeToggle />
</div>
{children}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr";
import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
export function LoginForm() {
const router = useRouter();
const [formError, setFormError] = useState<string | null>(null);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { identifier: "", password: "" },
});
async function onSubmit(_values: LoginInput) {
setFormError(null);
// FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username
await new Promise((r) => setTimeout(r, 400));
router.replace("/dashboard");
}
const submitting = form.formState.isSubmitting;
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate aria-busy={submitting}>
<FieldGroup>
<Controller
name="identifier"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="identifier">{tr.auth.identifier}</FieldLabel>
<Input
{...field}
id="identifier"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="email"
placeholder={tr.auth.identifierPlaceholder}
aria-invalid={fieldState.invalid}
autoFocus
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="password">{tr.auth.password}</FieldLabel>
<PasswordInput
{...field}
id="password"
autoComplete="current-password"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{formError ? <FieldError>{formError}</FieldError> : null}
<Button type="submit" size="lg" disabled={submitting} className="w-full">
{submitting ? <Loader2 className="animate-spin" /> : null}
{submitting ? tr.auth.submitting : tr.auth.submit}
</Button>
</FieldGroup>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Logo } from "@/components/brand/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { LoginForm } from "./login-form";
export const metadata: Metadata = { title: tr.auth.loginTitle };
export default function LoginPage() {
return (
<main className="flex w-full max-w-sm flex-col items-center gap-6">
<Logo priority className="scale-125" />
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl">{tr.auth.loginTitle}</CardTitle>
<CardDescription>{tr.auth.loginDescription}</CardDescription>
</CardHeader>
<CardContent>
<LoginForm />
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground">{tr.auth.noSignUp}</p>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
/** Rota hata sınırı. İç detay gösterilmez; yalnızca sunucu loglarıyla eşleşen hata kodu (digest). */
export default function ErrorPage({ error, retry }: { error: unknown; retry: () => void }) {
useEffect(() => {
console.error(error);
}, [error]);
const digest = error instanceof Error && "digest" in error ? String(error.digest) : undefined;
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
{digest ? (
<p className="text-xs text-muted-foreground">
{tr.errors.reference}: <code>{digest}</code>
</p>
) : null}
<Button onClick={() => retry()}>{tr.errors.retry}</Button>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { tr } from "@/i18n/tr";
import "./globals.css";
/** Kök layout hataları için son çare sınırı (kendi <html>/<body> etiketini render eder). */
export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
return (
<html lang="tr">
<body className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
<button
type="button"
onClick={() => retry()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
{tr.errors.retry}
</button>
</body>
</html>
);
}
+17 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { tr } from "@/i18n/tr";
import "./globals.css";
@@ -18,17 +19,31 @@ export const metadata: Metadata = {
title: { default: tr.app.name, template: `%s · ${tr.app.name}` },
description: tr.app.tagline,
applicationName: tr.app.name,
appleWebApp: { capable: true, title: tr.app.name, statusBarStyle: "default" },
formatDetection: { telephone: false },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
// Manifest/tarayıcı çubuğu rengi CSS değişkeni okuyamaz; --background değerlerinin hex karşılığı.
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fafcfe" },
{ media: "(prefers-color-scheme: dark)", color: "#081123" },
],
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="tr" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">{children}</body>
<html
lang="tr"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Loader2 } from "lucide-react";
import { tr } from "@/i18n/tr";
export default function Loading() {
return (
<div
role="status"
className="flex flex-1 items-center justify-center gap-2 p-8 text-muted-foreground"
>
<Loader2 className="size-5 animate-spin" />
<span>{tr.common.loading}</span>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<p className="text-6xl font-semibold text-primary tabular-nums">404</p>
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.notFoundTitle}</h1>
<p className="max-w-md text-muted-foreground">{tr.errors.notFoundDescription}</p>
<Button asChild>
<Link href="/">{tr.errors.backHome}</Link>
</Button>
</main>
);
}
+3 -12
View File
@@ -1,15 +1,6 @@
import { tr } from "@/i18n/tr";
import { APP_VERSION } from "@/lib/version";
import { redirect } from "next/navigation";
/** Geçici açılış sayfası — Faz 2'de /login'e yönlendirme ile değişecek. */
/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-3xl font-semibold tracking-tight">{tr.app.name}</h1>
<p className="max-w-md text-muted-foreground">{tr.app.setupDescription}</p>
<p className="text-sm text-muted-foreground">
{tr.app.version} {APP_VERSION}
</p>
</main>
);
redirect("/login");
}