diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb9dbb3..e0fee12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,31 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
+## [0.2.0] - 2026-11-05
+
+Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock).
+
+### Eklendi
+
+- Marka paleti: mavi/beyaz oklch tema (açık), lacivert/açık mavi (koyu); anlamsal `--income`, `--expense`, `--budget`, `--warning` değişkenleri ve grafik renkleri.
+- Açık / Koyu / Sistem tema (next-themes) ve her ekranda tema seçici.
+- Giriş ekranı (`/login`): kullanıcı adı veya e-posta + şifre, zod doğrulama, şifre göster/gizle, yükleniyor durumu; kayıt bağlantısı yok. `/` her zaman `/login`'e yönlenir.
+- Uygulama kabuğu: masaüstünde daraltılabilir yan menü, mobilde alt gezinme çubuğu (ortada Hızlı ekle, "Daha fazla" ile menü), üst bar (hane seçici, Hızlı ekle, tema, kullanıcı menüsü: ad, rol, sürüm, Çıkış yap). Henüz gelmeyen sayfalar "Yakında" olarak pasif.
+- Hızlı ekle (gider / gelir / transfer) seçimi; mobilde alttan açılan Drawer, masaüstünde Dialog (`ResponsiveDialog`).
+- Dashboard: karşılama ve özet kartları (Suspense + Skeleton).
+- Admin kullanıcı yönetimi (`/admin/kullanicilar`): TanStack Table v9 ile Türkçe duyarlı arama, sıralama, sayfalama; oluşturma, düzenleme, rol değiştirme, şifre sıfırlama, askıya alma/kaldırma, silme (onaylı). Kendini silememe ve son admini düşürememe kuralları (`checkAdminAction`).
+- Paylaşılan zod şemaları (`src/shared/schemas`: giriş, kullanıcı, şifre, askı), Türkçe arama/sıralama (`foldTr`, `compareTr`), TL ve İstanbul saatli tarih biçimleri (`formatMoney`, `formatDate`).
+- PWA temeli: `manifest.webmanifest`, tema rengi, Apple web app meta, ikonlar.
+- `npm run icons`: `public/brand/icon.png`'den favicon.ico, uygulama, Apple ve maskable PWA ikonları üretir (sharp).
+- Yer tutucu marka dosyaları (`public/brand/logo.svg`, `logo-dark.svg`, `icon.png`).
+- 404, hata (`retry`) ve global hata sayfaları; yükleniyor ekranları.
+- `ActionResult` sonuç tipi (Faz 3 Server Action sözleşmesi).
+
+### Değişti
+
+- `use-mobile` hook'u `useSyncExternalStore` ile yeniden yazıldı (effect içinde setState yok, hydration güvenli).
+- ESLint: `_` önekli bilinçli kullanılmayan değişkenlere izin.
+
## [0.1.0] - 2026-11-05
Faz 1: Framework, klasör yapısı ve altyapı.
@@ -25,5 +50,6 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
-[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...HEAD
+[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...HEAD
+[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0
[0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index aa3bbda..af77d27 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,10 @@ npm run dev # http://localhost:3000
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
+## Marka dosyaları
+
+`public/brand/` altındaki `logo.svg`, `logo-dark.svg` ve `icon.png` (512×512) şu an **yer tutucudur**. Gerçek logoları aynı adlarla koyup `npm run icons` çalıştırın.
+
## Docker imajı
```bash
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 05e726d..a301bdb 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -5,6 +5,15 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
+ {
+ rules: {
+ // "_" ile başlayan adlar bilinçli olarak kullanılmayan değişkenlerdir.
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" },
+ ],
+ },
+ },
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
diff --git a/package-lock.json b/package-lock.json
index 903eb71..f62a378 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,8 +24,10 @@
"react-hook-form": "^7.91.0",
"server-only": "^0.0.1",
"shadcn": "^4.23.0",
+ "sharp": "^0.35.5",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
+ "vaul": "^1.1.2",
"zod": "^4.6.7"
},
"devDependencies": {
@@ -42,6 +44,9 @@
"tsx": "^4.23.15",
"typescript": "^5",
"vitest": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=26 <27"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1650,7 +1655,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -6776,7 +6780,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -11199,7 +11202,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -11249,7 +11251,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
"integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -12340,6 +12341,19 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/vaul": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
+ "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
diff --git a/package.json b/package.json
index a6a3fc2..a2686e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "butcem",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": {
@@ -19,7 +19,8 @@
"db:up": "docker compose up -d --wait",
"db:down": "docker compose down",
"seed": "tsx --env-file=.env.local scripts/seed.ts",
- "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset"
+ "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset",
+ "icons": "tsx scripts/generate-icons.ts"
},
"dependencies": {
"@hookform/resolvers": "^7.0.2",
@@ -38,8 +39,10 @@
"react-hook-form": "^7.91.0",
"server-only": "^0.0.1",
"shadcn": "^4.23.0",
+ "sharp": "^0.35.5",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
+ "vaul": "^1.1.2",
"zod": "^4.6.7"
},
"devDependencies": {
diff --git a/public/brand/.gitkeep b/public/brand/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/public/brand/icon.png b/public/brand/icon.png
new file mode 100644
index 0000000..b83858d
Binary files /dev/null and b/public/brand/icon.png differ
diff --git a/public/brand/logo-dark.svg b/public/brand/logo-dark.svg
new file mode 100644
index 0000000..700c969
--- /dev/null
+++ b/public/brand/logo-dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/public/brand/logo.svg b/public/brand/logo.svg
new file mode 100644
index 0000000..60fd7b4
--- /dev/null
+++ b/public/brand/logo.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..0147bc1
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..faf8265
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png
new file mode 100644
index 0000000..609e48e
Binary files /dev/null and b/public/icons/maskable-512.png differ
diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts
new file mode 100644
index 0000000..c55f90d
--- /dev/null
+++ b/scripts/generate-icons.ts
@@ -0,0 +1,90 @@
+/**
+ * Uygulama ikonlarını logodan üretir: npm run icons
+ *
+ * Kaynak: public/brand/icon.png (kare, en az 512×512).
+ * Çıktılar (commit edilir):
+ * src/app/favicon.ico 16/32/48 px (PNG gömülü ICO)
+ * src/app/icon.png 192 px →
+ * src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
+ * public/icons/icon-192.png PWA manifest
+ * public/icons/icon-512.png PWA manifest
+ * public/icons/maskable-512.png PWA manifest (purpose: maskable, %80 güvenli alan)
+ *
+ * Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın.
+ */
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import sharp from "sharp";
+
+const ROOT = process.cwd();
+const SOURCE = path.join(ROOT, "public/brand/icon.png");
+/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
+const BRAND_BG = "#105bbe";
+
+async function png(size: number, opts: { flatten?: boolean; padding?: number } = {}) {
+ const inner = Math.round(size * (1 - (opts.padding ?? 0) * 2));
+ const resized = await sharp(SOURCE).resize(inner, inner, { fit: "contain" }).png().toBuffer();
+ let img = sharp({
+ create: {
+ width: size,
+ height: size,
+ channels: 4,
+ background: opts.flatten || opts.padding ? BRAND_BG : { r: 0, g: 0, b: 0, alpha: 0 },
+ },
+ }).composite([{ input: resized, gravity: "center" }]);
+ if (opts.flatten) img = img.flatten({ background: BRAND_BG });
+ return img.png({ compressionLevel: 9 }).toBuffer();
+}
+
+/** PNG görüntülerinden ICO dosyası oluşturur (Vista+ tüm tarayıcılar PNG gömülü ICO destekler). */
+function toIco(images: { size: number; data: Buffer }[]): Buffer {
+ const header = Buffer.alloc(6);
+ header.writeUInt16LE(0, 0); // reserved
+ header.writeUInt16LE(1, 2); // type: icon
+ header.writeUInt16LE(images.length, 4);
+ const entries: Buffer[] = [];
+ let offset = 6 + images.length * 16;
+ for (const { size, data } of images) {
+ const e = Buffer.alloc(16);
+ e.writeUInt8(size >= 256 ? 0 : size, 0);
+ e.writeUInt8(size >= 256 ? 0 : size, 1);
+ e.writeUInt8(0, 2); // palette
+ e.writeUInt8(0, 3); // reserved
+ e.writeUInt16LE(1, 4); // color planes
+ e.writeUInt16LE(32, 6); // bpp
+ e.writeUInt32LE(data.length, 8);
+ e.writeUInt32LE(offset, 12);
+ offset += data.length;
+ entries.push(e);
+ }
+ return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
+}
+
+async function main() {
+ const meta = await sharp(SOURCE).metadata();
+ if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) {
+ throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`);
+ }
+ await mkdir(path.join(ROOT, "public/icons"), { recursive: true });
+
+ const out: [string, Buffer][] = [
+ ["src/app/icon.png", await png(192)],
+ ["src/app/apple-icon.png", await png(180, { flatten: true })],
+ ["public/icons/icon-192.png", await png(192)],
+ ["public/icons/icon-512.png", await png(512)],
+ ["public/icons/maskable-512.png", await png(512, { padding: 0.1 })],
+ [
+ "src/app/favicon.ico",
+ toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
+ ],
+ ];
+ for (const [file, data] of out) {
+ await writeFile(path.join(ROOT, file), data);
+ console.log(`✓ ${file} (${data.length} bayt)`);
+ }
+}
+
+main().catch((err: unknown) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+});
diff --git a/src/app/(app)/admin/kullanicilar/page.tsx b/src/app/(app)/admin/kullanicilar/page.tsx
new file mode 100644
index 0000000..6875a05
--- /dev/null
+++ b/src/app/(app)/admin/kullanicilar/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import { PageHeader } from "@/components/page-header";
+import { UsersTable } from "@/features/admin-users/components/users-table";
+import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock";
+import { MOCK_SESSION_USER } from "@/features/auth/mock";
+import { tr } from "@/i18n/tr";
+
+export const metadata: Metadata = { title: tr.adminUsers.title };
+
+export default function AdminUsersPage() {
+ // FAZ 3: const users = await listUsers() (Better Auth admin API)
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx
new file mode 100644
index 0000000..7cc8092
--- /dev/null
+++ b/src/app/(app)/admin/layout.tsx
@@ -0,0 +1,11 @@
+import { notFound } from "next/navigation";
+import { MOCK_SESSION_USER } from "@/features/auth/mock";
+
+/**
+ * Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez).
+ * FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol.
+ */
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+ if (MOCK_SESSION_USER.role !== "admin") notFound();
+ return children;
+}
diff --git a/src/app/(app)/dashboard/loading.tsx b/src/app/(app)/dashboard/loading.tsx
new file mode 100644
index 0000000..d1e7bb2
--- /dev/null
+++ b/src/app/(app)/dashboard/loading.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
+
+export default function DashboardLoading() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..7d40c44
--- /dev/null
+++ b/src/app/(app)/dashboard/page.tsx
@@ -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 {
+ 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 (
+
+ {cards.map((c) => (
+
+
+ {tr.dashboard.cards[c.key]}
+
+ {c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
+
+
+ {c.amountMinor === null ? (
+
+ {tr.dashboard.noData}
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+export default function DashboardPage() {
+ const user = MOCK_SESSION_USER;
+ return (
+ <>
+
+ }>
+
+
+ >
+ );
+}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
new file mode 100644
index 0000000..0aa1c1b
--- /dev/null
+++ b/src/app/(app)/layout.tsx
@@ -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 (
+
+
+
+
+
+ {/* Mobilde alt menü yüksekliği kadar boşluk */}
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..a1c295c
--- /dev/null
+++ b/src/app/(auth)/layout.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx
new file mode 100644
index 0000000..3d0e25a
--- /dev/null
+++ b/src/app/(auth)/login/login-form.tsx
@@ -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(null);
+ const form = useForm({
+ 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 (
+
+ );
+}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..1c00480
--- /dev/null
+++ b/src/app/(auth)/login/page.tsx
@@ -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 (
+
+
+
+
+ {tr.auth.loginTitle}
+ {tr.auth.loginDescription}
+
+
+
+
+
+ {tr.auth.noSignUp}
+
+ );
+}
diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png
new file mode 100644
index 0000000..d442934
Binary files /dev/null and b/src/app/apple-icon.png differ
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..084a220
--- /dev/null
+++ b/src/app/error.tsx
@@ -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 (
+
+ {tr.errors.errorTitle}
+ {tr.errors.errorDescription}
+ {digest ? (
+
+ {tr.errors.reference}: {digest}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
index af98450..fbab8dd 100644
Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 0000000..2f84f61
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -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 / etiketini render eder). */
+export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
+ return (
+
+
+ {tr.errors.errorTitle}
+ {tr.errors.errorDescription}
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 6a25b1b..ae2ff36 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -18,6 +18,14 @@
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
+ --color-income: var(--income);
+ --color-income-foreground: var(--income-foreground);
+ --color-expense: var(--expense);
+ --color-expense-foreground: var(--expense-foreground);
+ --color-budget: var(--budget);
+ --color-budget-foreground: var(--budget-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
@@ -48,73 +56,98 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+/*
+ * BÜTÇEM marka paleti — mavi ve beyaz tonları (oklch). Sabit renk kodu yalnızca burada.
+ * Anlamsal renkler: --income (gelir/yeşil), --expense (gider/kırmızı),
+ * --budget (bütçe-plan/mavi), --warning (uyarı/turuncu). Grafikler de bunları kullanır.
+ */
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
+ --background: oklch(0.99 0.004 250);
+ --foreground: oklch(0.22 0.04 262);
--card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
+ --card-foreground: oklch(0.22 0.04 262);
--popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
+ --popover-foreground: oklch(0.22 0.04 262);
+ --primary: oklch(0.49 0.17 258);
+ --primary-foreground: oklch(0.99 0.004 250);
+ --secondary: oklch(0.955 0.016 250);
+ --secondary-foreground: oklch(0.3 0.06 260);
+ --muted: oklch(0.955 0.012 250);
+ --muted-foreground: oklch(0.5 0.03 258);
+ --accent: oklch(0.935 0.03 250);
+ --accent-foreground: oklch(0.3 0.08 260);
+ --destructive: oklch(0.56 0.21 25);
+ --border: oklch(0.905 0.016 252);
+ --input: oklch(0.89 0.018 252);
+ --ring: oklch(0.6 0.15 258);
+
+ --income: oklch(0.58 0.15 152);
+ --income-foreground: oklch(0.99 0.004 250);
+ --expense: oklch(0.56 0.21 25);
+ --expense-foreground: oklch(0.99 0.004 250);
+ --budget: oklch(0.55 0.17 258);
+ --budget-foreground: oklch(0.99 0.004 250);
+ --warning: oklch(0.68 0.17 55);
+ --warning-foreground: oklch(0.22 0.04 262);
+
+ --chart-1: var(--income);
+ --chart-2: var(--budget);
+ --chart-3: var(--expense);
+ --chart-4: var(--warning);
+ --chart-5: oklch(0.65 0.08 230);
--radius: 0.625rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --sidebar: oklch(0.975 0.008 250);
+ --sidebar-foreground: oklch(0.22 0.04 262);
+ --sidebar-primary: oklch(0.49 0.17 258);
+ --sidebar-primary-foreground: oklch(0.99 0.004 250);
+ --sidebar-accent: oklch(0.935 0.03 250);
+ --sidebar-accent-foreground: oklch(0.3 0.08 260);
+ --sidebar-border: oklch(0.905 0.016 252);
+ --sidebar-ring: oklch(0.6 0.15 258);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --background: oklch(0.18 0.04 264);
+ --foreground: oklch(0.96 0.01 250);
+ --card: oklch(0.22 0.045 264);
+ --card-foreground: oklch(0.96 0.01 250);
+ --popover: oklch(0.22 0.045 264);
+ --popover-foreground: oklch(0.96 0.01 250);
+ --primary: oklch(0.76 0.12 245);
+ --primary-foreground: oklch(0.18 0.04 264);
+ --secondary: oklch(0.27 0.045 264);
+ --secondary-foreground: oklch(0.96 0.01 250);
+ --muted: oklch(0.26 0.04 264);
+ --muted-foreground: oklch(0.73 0.03 250);
+ --accent: oklch(0.3 0.06 262);
+ --accent-foreground: oklch(0.96 0.01 250);
+ --destructive: oklch(0.7 0.18 22);
+ --border: oklch(1 0 0 / 11%);
+ --input: oklch(1 0 0 / 16%);
+ --ring: oklch(0.7 0.12 245);
+
+ --income: oklch(0.75 0.16 152);
+ --income-foreground: oklch(0.18 0.04 264);
+ --expense: oklch(0.7 0.18 22);
+ --expense-foreground: oklch(0.18 0.04 264);
+ --budget: oklch(0.73 0.13 245);
+ --budget-foreground: oklch(0.18 0.04 264);
+ --warning: oklch(0.79 0.15 62);
+ --warning-foreground: oklch(0.18 0.04 264);
+
+ --chart-1: var(--income);
+ --chart-2: var(--budget);
+ --chart-3: var(--expense);
+ --chart-4: var(--warning);
+ --chart-5: oklch(0.7 0.07 230);
+ --sidebar: oklch(0.2 0.045 264);
+ --sidebar-foreground: oklch(0.96 0.01 250);
+ --sidebar-primary: oklch(0.76 0.12 245);
+ --sidebar-primary-foreground: oklch(0.18 0.04 264);
+ --sidebar-accent: oklch(0.28 0.055 262);
+ --sidebar-accent-foreground: oklch(0.96 0.01 250);
+ --sidebar-border: oklch(1 0 0 / 11%);
+ --sidebar-ring: oklch(0.7 0.12 245);
}
@layer base {
@@ -128,3 +161,8 @@
@apply font-sans;
}
}
+
+/* Mobil alt menü + iOS ana ekran çentiği için güvenli alan */
+@utility pb-safe {
+ padding-bottom: env(safe-area-inset-bottom);
+}
diff --git a/src/app/icon.png b/src/app/icon.png
new file mode 100644
index 0000000..0147bc1
Binary files /dev/null and b/src/app/icon.png differ
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b6038a9..6d12f99 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
-
- {children}
+
+
+ {children}
+
);
}
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..b48912b
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,14 @@
+import { Loader2 } from "lucide-react";
+import { tr } from "@/i18n/tr";
+
+export default function Loading() {
+ return (
+
+
+ {tr.common.loading}
+
+ );
+}
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..6e4962f
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,26 @@
+import type { MetadataRoute } from "next";
+import { tr } from "@/i18n/tr";
+
+/** PWA manifest: "Ana ekrana ekle" ile kurulabilir. İkonlar: npm run icons. */
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ id: "/",
+ name: tr.app.name,
+ short_name: tr.app.name,
+ description: tr.app.tagline,
+ lang: "tr-TR",
+ dir: "ltr",
+ start_url: "/dashboard",
+ scope: "/",
+ display: "standalone",
+ orientation: "portrait",
+ background_color: "#fafcfe",
+ theme_color: "#105bbe",
+ categories: ["finance", "productivity"],
+ icons: [
+ { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
+ { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
+ { src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
+ ],
+ };
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..140a90c
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -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 (
+
+ 404
+ {tr.errors.notFoundTitle}
+ {tr.errors.notFoundDescription}
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 2b1508c..0abc6c8 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 (
-
- {tr.app.name}
- {tr.app.setupDescription}
-
- {tr.app.version} {APP_VERSION}
-
-
- );
+ redirect("/login");
}
diff --git a/src/components/admin/.gitkeep b/src/components/admin/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/components/brand/logo.tsx b/src/components/brand/logo.tsx
new file mode 100644
index 0000000..d544b6c
--- /dev/null
+++ b/src/components/brand/logo.tsx
@@ -0,0 +1,27 @@
+import Image from "next/image";
+import { cn } from "@/lib/utils";
+import { tr } from "@/i18n/tr";
+
+/**
+ * Marka logosu: açık temada logo.svg, koyu temada logo-dark.svg (CSS ile seçilir,
+ * hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında.
+ */
+export function Logo({ className, priority }: { className?: string; priority?: boolean }) {
+ const common = { width: 120, height: 32, priority, unoptimized: true } as const;
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/layout/.gitkeep b/src/components/layout/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx
new file mode 100644
index 0000000..d0e0d20
--- /dev/null
+++ b/src/components/layout/app-header.tsx
@@ -0,0 +1,31 @@
+import { SidebarTrigger } from "@/components/ui/sidebar";
+import type { SessionUser } from "@/features/auth/types";
+import type { HouseholdSummary } from "@/features/households/types";
+import { tr } from "@/i18n/tr";
+import { HouseholdSwitcher } from "./household-switcher";
+import { QuickAddButton } from "./quick-add";
+import { ThemeToggle } from "./theme-toggle";
+import { UserMenu } from "./user-menu";
+
+/** Üst bar: menü düğmesi (masaüstü), hane seçici, hızlı ekle, tema ve kullanıcı menüsü. */
+export function AppHeader({
+ user,
+ households,
+ version,
+}: {
+ user: SessionUser;
+ households: HouseholdSummary[];
+ version: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx
new file mode 100644
index 0000000..4a4982e
--- /dev/null
+++ b/src/components/layout/app-sidebar.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { Logo } from "@/components/brand/logo";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { tr } from "@/i18n/tr";
+import { ADMIN_NAV, MAIN_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
+
+function NavList({ items, pathname }: { items: NavItem[]; pathname: string }) {
+ const { setOpenMobile } = useSidebar();
+ return (
+
+ {items.map((item) => {
+ const Icon = item.icon;
+ if (!isAvailable(item)) {
+ return (
+
+
+
+ {item.label}
+
+
+ {tr.common.comingSoon}
+
+
+ );
+ }
+ const active = isActivePath(pathname, item.href);
+ return (
+
+
+ setOpenMobile(false)}
+ >
+
+ {item.label}
+
+
+
+ );
+ })}
+
+ );
+}
+
+/** Masaüstünde sabit yan menü; mobilde "Daha fazla" ile açılan Sheet. */
+export function AppSidebar({ isAdmin, version }: { isAdmin: boolean; version: string }) {
+ const pathname = usePathname();
+ return (
+
+
+
+
+
+
+
+
+ {tr.nav.main}
+
+
+
+
+ {isAdmin ? (
+
+ {tr.nav.admin}
+
+
+
+
+ ) : null}
+
+
+ {tr.app.name} · {tr.app.version} {version}
+
+
+
+ );
+}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
new file mode 100644
index 0000000..ce8bc15
--- /dev/null
+++ b/src/components/layout/bottom-nav.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { Menu, Plus } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useSidebar } from "@/components/ui/sidebar";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+import { BOTTOM_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
+import { useQuickAdd } from "./quick-add";
+
+const itemClass =
+ "flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-medium outline-none focus-visible:bg-muted";
+
+function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) {
+ const Icon = item.icon;
+ if (!isAvailable(item)) {
+ return (
+
+
+ {item.label}
+
+ );
+ }
+ const active = isActivePath(pathname, item.href);
+ return (
+
+
+ {item.label}
+
+ );
+}
+
+/** Mobil alt gezinme çubuğu (md altı). */
+export function BottomNav() {
+ const pathname = usePathname();
+ const { openQuickAdd } = useQuickAdd();
+ const { setOpenMobile } = useSidebar();
+ return (
+
+ );
+}
diff --git a/src/components/layout/household-switcher.tsx b/src/components/layout/household-switcher.tsx
new file mode 100644
index 0000000..733738f
--- /dev/null
+++ b/src/components/layout/household-switcher.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { House } from "lucide-react";
+import { useState } from "react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { HouseholdSummary } from "@/features/households/types";
+import { tr } from "@/i18n/tr";
+
+/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */
+export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) {
+ const [value, setValue] = useState(households[0]?.id);
+ const current = households.find((h) => h.id === value);
+ if (households.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts
new file mode 100644
index 0000000..1ede442
--- /dev/null
+++ b/src/components/layout/nav-config.ts
@@ -0,0 +1,59 @@
+import {
+ ArrowLeftRight,
+ CalendarClock,
+ ChartColumn,
+ Coins,
+ House,
+ LayoutDashboard,
+ Package,
+ Settings,
+ ShoppingCart,
+ Target,
+ UserCog,
+ Users,
+ Wallet,
+ type LucideIcon,
+} from "lucide-react";
+import { tr } from "@/i18n/tr";
+
+export type NavItem = {
+ href: string;
+ label: string;
+ icon: LucideIcon;
+ /** Sayfanın ekleneceği faz; mevcut fazdan büyükse "Yakında" olarak pasif gösterilir. */
+ phase: number;
+};
+
+/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
+export const CURRENT_PHASE = 2;
+
+export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
+
+export const MAIN_NAV: NavItem[] = [
+ { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
+ { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 },
+ { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 },
+ { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
+ { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
+ { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
+ { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
+ { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
+ { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
+ { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
+ { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
+];
+
+export const ADMIN_NAV: NavItem[] = [
+ { href: "/admin/kullanicilar", label: tr.nav.adminUsers, icon: UserCog, phase: 2 },
+ { href: "/admin/haneler", label: tr.nav.adminHouseholds, icon: House, phase: 5 },
+];
+
+/** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */
+export const BOTTOM_NAV = {
+ left: [MAIN_NAV[0]!, MAIN_NAV[1]!],
+ right: [MAIN_NAV[6]!],
+};
+
+export function isActivePath(pathname: string, href: string) {
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
diff --git a/src/components/layout/quick-add.tsx b/src/components/layout/quick-add.tsx
new file mode 100644
index 0000000..1b8aa46
--- /dev/null
+++ b/src/components/layout/quick-add.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Plus } from "lucide-react";
+import { createContext, use, useState } from "react";
+import { toast } from "sonner";
+import { ResponsiveDialog } from "@/components/responsive-dialog";
+import { Button } from "@/components/ui/button";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+
+type QuickAddKind = "expense" | "income" | "transfer";
+
+const QuickAddContext = createContext<{ openQuickAdd: () => void } | null>(null);
+
+export function useQuickAdd() {
+ const ctx = use(QuickAddContext);
+ if (!ctx) throw new Error("useQuickAdd, QuickAddProvider içinde kullanılmalı");
+ return ctx;
+}
+
+const CHOICES: {
+ kind: QuickAddKind;
+ label: string;
+ hint: string;
+ icon: typeof Plus;
+ tone: string;
+}[] = [
+ {
+ kind: "expense",
+ label: tr.quickAdd.expense,
+ hint: tr.quickAdd.expenseHint,
+ icon: ArrowUpRight,
+ tone: "text-expense bg-expense/10",
+ },
+ {
+ kind: "income",
+ label: tr.quickAdd.income,
+ hint: tr.quickAdd.incomeHint,
+ icon: ArrowDownLeft,
+ tone: "text-income bg-income/10",
+ },
+ {
+ kind: "transfer",
+ label: tr.quickAdd.transfer,
+ hint: tr.quickAdd.transferHint,
+ icon: ArrowLeftRight,
+ tone: "text-budget bg-budget/10",
+ },
+];
+
+/** Hızlı ekle (gider / gelir / transfer) — her ekrandan açılabilir. */
+export function QuickAddProvider({ children }: { children: React.ReactNode }) {
+ const [open, setOpen] = useState(false);
+
+ function choose(kind: QuickAddKind) {
+ setOpen(false);
+ // FAZ 6: ilgili işlem formu açılacak.
+ toast.info(`${CHOICES.find((c) => c.kind === kind)?.label}: ${tr.quickAdd.notYet}`);
+ }
+
+ return (
+ setOpen(true) }}>
+ {children}
+
+
+ {CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => (
+
+ ))}
+
+
+
+ );
+}
+
+/** Masaüstü üst bar düğmesi. */
+export function QuickAddButton({ className }: { className?: string }) {
+ const { openQuickAdd } = useQuickAdd();
+ return (
+
+ );
+}
diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx
new file mode 100644
index 0000000..ec35167
--- /dev/null
+++ b/src/components/layout/theme-toggle.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Monitor, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { tr } from "@/i18n/tr";
+
+const OPTIONS = [
+ { value: "light", label: tr.theme.light, icon: Sun },
+ { value: "dark", label: tr.theme.dark, icon: Moon },
+ { value: "system", label: tr.theme.system, icon: Monitor },
+] as const;
+
+/** Açık / Koyu / Sistem tema seçici. İkon CSS ile değişir (hydration güvenli). */
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ return (
+
+
+
+
+
+ {tr.theme.label}
+
+ {OPTIONS.map(({ value, label, icon: Icon }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx
new file mode 100644
index 0000000..3987572
--- /dev/null
+++ b/src/components/layout/user-menu.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { LogOut } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { SessionUser } from "@/features/auth/types";
+import { tr } from "@/i18n/tr";
+
+export function initials(name: string) {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((p) => p.charAt(0).toLocaleUpperCase("tr-TR"))
+ .join("");
+}
+
+/** Kullanıcı menüsü: ad, rol, sürüm ve Çıkış yap. */
+export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
+ const router = useRouter();
+
+ function logout() {
+ // FAZ 3: authClient.signOut() sonrası yönlendirme.
+ toast.success(tr.userMenu.loggedOut);
+ router.replace("/login");
+ }
+
+ return (
+
+
+
+
+
+
+ {user.name}
+ @{user.username}
+
+ {tr.roles[user.role]}
+
+
+
+
+
+ {tr.userMenu.logout}
+
+
+
+ {tr.app.name} · {tr.app.version} {version}
+
+
+
+ );
+}
diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx
new file mode 100644
index 0000000..c3fe65d
--- /dev/null
+++ b/src/components/page-header.tsx
@@ -0,0 +1,20 @@
+/** Sayfa başlığı + açıklama + sağda isteğe bağlı aksiyonlar. */
+export function PageHeader({
+ title,
+ description,
+ actions,
+}: {
+ title: string;
+ description?: string;
+ actions?: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ );
+}
diff --git a/src/components/password-input.tsx b/src/components/password-input.tsx
new file mode 100644
index 0000000..cccb94a
--- /dev/null
+++ b/src/components/password-input.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { Eye, EyeOff } from "lucide-react";
+import { useState } from "react";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "@/components/ui/input-group";
+import { tr } from "@/i18n/tr";
+
+/** Göster/gizle düğmeli şifre alanı. */
+export function PasswordInput(props: Omit, "type">) {
+ const [visible, setVisible] = useState(false);
+ const label = visible ? tr.common.hidePassword : tr.common.showPassword;
+ return (
+
+
+
+ setVisible((v) => !v)}
+ >
+ {visible ? : }
+
+
+
+ );
+}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
new file mode 100644
index 0000000..204b893
--- /dev/null
+++ b/src/components/providers.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { ThemeProvider } from "next-themes";
+import { Toaster } from "@/components/ui/sonner";
+import { TooltipProvider } from "@/components/ui/tooltip";
+
+/** Kök istemci sağlayıcıları: tema (açık/koyu/sistem), tooltip ve bildirimler. */
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/components/responsive-dialog.tsx b/src/components/responsive-dialog.tsx
new file mode 100644
index 0000000..adec77b
--- /dev/null
+++ b/src/components/responsive-dialog.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+ className?: string;
+};
+
+/**
+ * Masaüstünde Dialog, mobilde (neredeyse) tam ekran alttan açılan Drawer.
+ * Formlar bu bileşenle açılır (Bölüm 4: "formlar mobilde tam ekran Sheet/Drawer").
+ */
+export function ResponsiveDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ children,
+ footer,
+ className,
+}: Props) {
+ const isMobile = useIsMobile();
+
+ if (isMobile) {
+ return (
+
+
+
+ {title}
+ {description ? {description} : null}
+
+ {children}
+ {footer ? {footer} : null}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..882ce4c
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
+
+import { Button } from "@/components/ui/button";
+
+function AlertDialog({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm";
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..af96733
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { Avatar as AvatarPrimitive } from "radix-ui";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..6a15d9b
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,44 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "cn";
+import { Slot } from "radix-ui";
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..425a48a
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,87 @@
+import * as React from "react";
+import { cn } from "cn";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..31dfdd7
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+import { CheckIcon } from "lucide-react";
+
+function Checkbox({ className, ...props }: React.ComponentProps
) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..cf47226
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui";
+
+function Collapsible({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..15afa8f
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { Dialog as DialogPrimitive } from "radix-ui";
+
+import { Button } from "@/components/ui/button";
+import { XIcon } from "lucide-react";
+
+function Dialog({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+ );
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..af68d7b
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+function Drawer({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DrawerTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DrawerPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DrawerClose({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DrawerContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DrawerTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..af34d99
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx
new file mode 100644
index 0000000..8b63f51
--- /dev/null
+++ b/src/components/ui/field.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useMemo } from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "cn";
+
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+