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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user