diff --git a/CLAUDE.md b/CLAUDE.md index 8008696..ca7b001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,9 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl - Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü). - API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`). - Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`. +- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features//page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`). +- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile. +- Geliştirmede tasarım önizlemesi: `/dev/tasarim`. - Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`. - API örnekleri: `backend/api.http` - Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_` DB, `tests/common` yardımcıları). diff --git a/docs/MEMORY.md b/docs/MEMORY.md index e30f16a..fdeed37 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 2 – Adım 4:** react-router kurulumu, korumalı sayfalar (useMe null → /giris), layout: mobilde alt menü (📊 Panel, ✍️ Defter, 🗓️ Plan, 🐷 Birikim, ☰ Diğer), masaüstünde sol kenar çubuğu, boş sayfa iskeletleri, App.tsx önizlemesi kaldırılır (tasarım önizlemesi isterse /dev sayfası). +**Faz 2 – Adım 5:** Giriş ve kayıt ekranları: react-hook-form + zod (+ @hookform/resolvers), shadcn input/label/checkbox/card, "Beni hatırla", kayıtta hane adı ve davet kodu (`?davet=KOD` ile önceden dolu), sonner toast, satır içi doğrulama mesajları. `features/auth/login-page.tsx` içindeki GEÇİCİ formlar değiştirilir. Sonra Faz 2 kapanır. ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -25,7 +25,10 @@ - Logo: Taslak A (indigo zemin, spiral krem defter, ¥) seçildi. Kaynak `frontend/brand/logo.svg` + `logo-fullbleed.svg` (maskable/Apple, içerik %82). Uygulama içi `components/logo.tsx` aynı çizim (elle senkron tutulmalı). Manifest Faz 10'da (vite-plugin-pwa). - ts-rs 12: `backend/.cargo/config.toml` → `TS_RS_EXPORT_DIR=../frontend/src/types`. TS adları: User, HouseholdSummary, Household, Member, Invite, CatalogItem, Catalog{Create,Update,Order}Request, RegisterRequest, LoginRequest, MeResponse, HealthResponse, ApiErrorBody, Group, Role. - QueryClient: 4xx tekrar denenmez; herhangi bir sorgu 401 alırsa `me` = null. Çıkışta `qc.clear()`. -- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5. +- Adresler Türkçe: / (Panel), /defter, /plan, /birikim, /diger (mobil), /degerlendirme, /raporlar, /ayarlar, /giris, /kayit. Mobil alt menü: Panel, Defter, Plan, Birikim, Diğer. +- Çıkış: `qc.clear()` KULLANMA (açık ekranlar silinen sorguya bağlı kalıyor) → önce `me=null`, sonra `removeQueries` (me hariç). +- Canlıda SPA yönlendirmesi (Axum: bilinmeyen yol → index.html) Faz 3'te yapılmalı. +- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5. ## ❓ Açık Konular - Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+). @@ -54,3 +57,4 @@ - **Adım 1:** Tailwind v4 (@tailwindcss/vite), shadcn init (radix, preset nova; `cn` paketi = shadcn'in clsx+tailwind-merge yerine geçeni, incelendi), Inter (@fontsource-variable, çevrimdışı). `index.css`: grup + durum token'ları, odak halkası, `money` utility, reduced-motion. `lib/format.ts` (+22 vitest), `lib/theme.tsx`, `lib/groups.ts`, `components/theme-toggle.tsx`. Butonlar 44/48 px'e büyütüldü. Geçici önizleme ekranı (App.tsx), 375 px headless ekran görüntüsüyle iki temada kontrol edildi. - **Adım 2:** 3 logo taslağı (A indigo defter, B açık defter + para, C hanko) 512/64/32/16 px önizlemeyle sunuldu; kullanıcı A'yı seçti. `scripts/icons.sh` (`npm run icons`): favicon.svg/.ico, icon-192/512, icon-maskable-512, apple-touch-icon (180), brand/icon-1024 (Tauri). Maskable güvenli alan görsel olarak doğrulandı. `index.html`: ikon linkleri, theme-color, description. - **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti. +- **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi. diff --git a/docs/PHASES.md b/docs/PHASES.md index b510a9d..6e11d41 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -35,5 +35,5 @@ - ✅ 1. Tailwind v4 + shadcn/ui (radix, nova), tasarım token'ları (4 grup + durum renkleri, AA doğrulandı), Inter, tema (aydınlık/karanlık/sistem), `lib/format.ts` + Vitest - ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları - ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu -- 🔄 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü -- ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada +- ✅ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü +- 🔄 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 887c09e..672ec49 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "radix-ui": "^1.8.1", "react": "^19.3.1", "react-dom": "^19.3.1", + "react-router": "^8.5.1", "shadcn": "^4.23.0", "tailwindcss": "^4.3.5", "tw-animate-css": "^1.4.0" @@ -2682,6 +2683,12 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.19", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.19.tgz", @@ -4225,6 +4232,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6018,6 +6031,36 @@ } } }, + "node_modules/react-router": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz", + "integrity": "sha512-5u1eQ46QjuXjNfEnLwzrnou1Bm5dquO3xjVYkkMYCudtkicD2AQHtx+fvXzPATtPJx8TW6DR1x1TDRqsfHyZBw==", + "license": "MIT", + "dependencies": { + "@remix-run/route-pattern": "^0.22.1", + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "@types/react-dom": ">=19.2.0", + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4a17ff5..10714ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "radix-ui": "^1.8.1", "react": "^19.3.1", "react-dom": "^19.3.1", + "react-router": "^8.5.1", "shadcn": "^4.23.0", "tailwindcss": "^4.3.5", "tw-animate-css": "^1.4.0" diff --git a/frontend/src/components/layout/app-layout.tsx b/frontend/src/components/layout/app-layout.tsx new file mode 100644 index 0000000..bd9b191 --- /dev/null +++ b/frontend/src/components/layout/app-layout.tsx @@ -0,0 +1,142 @@ +import { LogOut } from 'lucide-react' +import { NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router' +import { Logo } from '@/components/logo' +import { ThemeToggle } from '@/components/theme-toggle' +import { Button } from '@/components/ui/button' +import { useLogout, useMe } from '@/lib/auth' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import { bottomNav, moreNav, paths, sideNav } from './nav-items' + +/** + * Oturum açılmış sayfaların iskeleti. + * - Mobil (< md): üstte logo, altta 5 öğeli sabit menü + * - Masaüstü (≥ md): solda kenar çubuğu + */ +export function AppLayout() { + return ( +
+ + + {/* Mobil üst çubuk */} +
+ +
+ + {/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır. + pb-28 → mobilde alt menünün altında içerik kalmasın. */} +
+
+ +
+
+ + + +
+ ) +} + +function Sidebar() { + return ( + + ) +} + +function BottomNav() { + const { pathname } = useLocation() + // "Diğer" altındaki sayfalardayken de "Diğer" seçili görünsün + const inMore = moreNav.some((i) => pathname.startsWith(i.to)) + + return ( + + ) +} + +export function AccountSummary() { + const { data: me } = useMe() + if (!me) return null + return ( +
+

{me.user.name}

+

+ {me.household.name} · {me.user.role === 'owner' ? t.account.owner : t.account.member} +

+
+ ) +} + +export function LogoutButton({ className }: { className?: string }) { + const logout = useLogout() + return ( + + ) +} diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts new file mode 100644 index 0000000..6086806 --- /dev/null +++ b/frontend/src/components/layout/nav-items.ts @@ -0,0 +1,51 @@ +import { + CalendarDays, + ChartLine, + ClipboardCheck, + LayoutDashboard, + Menu, + NotebookPen, + PiggyBank, + Settings, + type LucideIcon, +} from 'lucide-react' +import { t } from '@/lib/strings' + +export type NavItem = { to: string; label: string; icon: LucideIcon } + +/** Sayfa adresleri tek yerde */ +export const paths = { + dashboard: '/', + ledger: '/defter', + planning: '/plan', + savings: '/birikim', + more: '/diger', + review: '/degerlendirme', + reports: '/raporlar', + settings: '/ayarlar', + login: '/giris', + register: '/kayit', +} as const + +const dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard } +const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen } +const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays } +const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank } +const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck } +const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine } +const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings } + +/** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */ +export const bottomNav: NavItem[] = [ + dashboard, + ledger, + planning, + savings, + { to: paths.more, label: t.nav.more, icon: Menu }, +] + +/** "Diğer" altında toplananlar (mobil) */ +export const moreNav: NavItem[] = [review, reports, settings] + +/** Masaüstü sol menü: hepsi doğrudan */ +export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings] diff --git a/frontend/src/components/layout/require-auth.tsx b/frontend/src/components/layout/require-auth.tsx new file mode 100644 index 0000000..8f3191f --- /dev/null +++ b/frontend/src/components/layout/require-auth.tsx @@ -0,0 +1,42 @@ +import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router' +import { Logo } from '@/components/logo' +import { useMe } from '@/lib/auth' +import { safeReturnPath } from '@/lib/return-path' +import { t } from '@/lib/strings' +import { paths } from './nav-items' + +/** Oturum kontrol edilirken gösterilen tam ekran açılış. */ +export function Splash() { + return ( +
+ +
+ ) +} + +/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */ +export function RequireAuth() { + const me = useMe() + const location = useLocation() + + if (me.isPending) return + if (!me.data) { + const back = location.pathname + location.search + const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}` + return + } + return +} + +/** + * Yalnızca oturumsuzken: giriş/kayıt. + * Oturum açılınca (giriş/kayıt başarılı olduğunda da) `?donus=` adresine, yoksa panele gönderir. + * Yönlendirme yalnızca burada yapılır — formlar ayrıca `navigate` çağırmaz (yarış durumu olmasın). + */ +export function RequireGuest() { + const me = useMe() + const [params] = useSearchParams() + if (me.isPending) return + if (me.data) return + return +} diff --git a/frontend/src/components/page.tsx b/frontend/src/components/page.tsx new file mode 100644 index 0000000..72b64ae --- /dev/null +++ b/frontend/src/components/page.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { Construction } from 'lucide-react' +import { t } from '@/lib/strings' +import { useDocumentTitle } from '@/lib/use-document-title' + +/** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */ +export function PageHeader({ + title, + description, + actions, +}: { + title: string + description?: string + actions?: ReactNode +}) { + useDocumentTitle(title) + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions} +
+ ) +} + +/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */ +export function ComingSoon({ when }: { when: string }) { + return ( +
+ +

{t.comingSoon(when)}

+
+ ) +} diff --git a/frontend/src/components/theme-toggle.tsx b/frontend/src/components/theme-toggle.tsx index 6f396fd..d0d46ed 100644 --- a/frontend/src/components/theme-toggle.tsx +++ b/frontend/src/components/theme-toggle.tsx @@ -8,8 +8,11 @@ const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [ { value: 'system', label: 'Sistem', icon: Monitor }, ] -/** Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir). */ -export function ThemeToggle({ className }: { className?: string }) { +/** + * Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir). + * `compact`: yalnızca ikonlar (dar alanlar için; etiketler ekran okuyucuda kalır). + */ +export function ThemeToggle({ className, compact = false }: { className?: string; compact?: boolean }) { const { theme, setTheme } = useTheme() return (
setTheme(value)} className={cn( 'inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors', + compact && 'flex-1 px-0', theme === value && 'bg-background text-foreground shadow-sm', )} > - {label} + {label} ))}
diff --git a/frontend/src/features/.gitkeep b/frontend/src/features/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/features/auth/login-page.tsx b/frontend/src/features/auth/login-page.tsx new file mode 100644 index 0000000..93b1f47 --- /dev/null +++ b/frontend/src/features/auth/login-page.tsx @@ -0,0 +1,67 @@ +import { useState, type FormEvent } from 'react' +import { LogIn } from 'lucide-react' +import { Link } from 'react-router' +import { paths } from '@/components/layout/nav-items' +import { Logo } from '@/components/logo' +import { Button } from '@/components/ui/button' +import { useLogin } from '@/lib/auth' +import { useDocumentTitle } from '@/lib/use-document-title' + +// GEÇİCİ: Faz 2 – Adım 5'te react-hook-form + zod ile gerçek giriş ekranı gelecek. +export function LoginPage() { + useDocumentTitle('Giriş') + const login = useLogin() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + const submit = (e: FormEvent) => { + e.preventDefault() + // Başarılı olunca RequireGuest, ?donus= adresine yönlendirir + login.mutate({ email, password, remember_me: true }) + } + + return ( +
+ +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + {login.isError &&

{login.error.message}

} + +
+ + Hesabınız yok mu? Kayıt olun + +
+ ) +} + +// GEÇİCİ: Adım 5'te kayıt formu (davet kodu dahil). +export function RegisterPage() { + useDocumentTitle('Kayıt') + return ( +
+ +

Kayıt ekranı Faz 2 – Adım 5'te geliştirilecek.

+ + Girişe dön + +
+ ) +} diff --git a/frontend/src/features/dashboard/page.tsx b/frontend/src/features/dashboard/page.tsx new file mode 100644 index 0000000..d401154 --- /dev/null +++ b/frontend/src/features/dashboard/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function DashboardPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/App.tsx b/frontend/src/features/dev/design-preview.tsx similarity index 60% rename from frontend/src/App.tsx rename to frontend/src/features/dev/design-preview.tsx index e2f0652..4a3a327 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/features/dev/design-preview.tsx @@ -1,19 +1,17 @@ -import { useState, type FormEvent } from 'react' +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { LogIn, LogOut, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { Logo } from '@/components/logo' import { ThemeToggle } from '@/components/theme-toggle' import { Button } from '@/components/ui/button' import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format' import { api } from '@/lib/api' -import { useLogin, useLogout, useMe } from '@/lib/auth' import { GROUPS } from '@/lib/groups' import { queryKeys } from '@/lib/query' import type { HealthResponse } from '@/types/HealthResponse' -// Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi. -// Adım 4'te gerçek layout ve sayfalarla değiştirilecek. -export default function App() { +/** Tasarım sistemi önizlemesi — yalnızca geliştirmede: /dev/tasarim */ +export function DesignPreview() { const health = useQuery({ queryKey: queryKeys.health, queryFn: () => api.get('/health'), @@ -42,7 +40,6 @@ export default function App() { )}

-

Ana gruplar

@@ -102,59 +99,3 @@ export default function App() { ) } - -/** Adım 3 geçici paneli: oturum akışını uçtan uca dener. Adım 5'te gerçek giriş ekranı gelir. */ -function SessionPanel() { - const me = useMe() - const login = useLogin() - const logout = useLogout() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - - const submit = (e: FormEvent) => { - e.preventDefault() - login.mutate({ email, password, remember_me: true }) - } - - return ( -
-

Oturum (geçici test paneli)

- {me.isPending &&

Oturum kontrol ediliyor…

} - {me.data && ( -
-

- 👋 {me.data.user.name} · {me.data.household.name} ·{' '} - {me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'} -

- -
- )} - {me.data === null && ( -
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - -
- )} - {login.isError &&

{login.error.message}

} -
- ) -} diff --git a/frontend/src/features/errors/not-found-page.tsx b/frontend/src/features/errors/not-found-page.tsx new file mode 100644 index 0000000..258e33c --- /dev/null +++ b/frontend/src/features/errors/not-found-page.tsx @@ -0,0 +1,23 @@ +import { House } from 'lucide-react' +import { Link } from 'react-router' +import { paths } from '@/components/layout/nav-items' +import { PageHeader } from '@/components/page' +import { Button } from '@/components/ui/button' +import { t } from '@/lib/strings' + +export function NotFoundPage() { + return ( +
+

+ 🧭 +

+ +

{t.pages.notFound.description}

+ +
+ ) +} diff --git a/frontend/src/features/ledger/page.tsx b/frontend/src/features/ledger/page.tsx new file mode 100644 index 0000000..75111ce --- /dev/null +++ b/frontend/src/features/ledger/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function LedgerPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/features/more/page.tsx b/frontend/src/features/more/page.tsx new file mode 100644 index 0000000..c3d45b7 --- /dev/null +++ b/frontend/src/features/more/page.tsx @@ -0,0 +1,40 @@ +import { ChevronRight } from 'lucide-react' +import { Link } from 'react-router' +import { AccountSummary, LogoutButton } from '@/components/layout/app-layout' +import { moreNav } from '@/components/layout/nav-items' +import { PageHeader } from '@/components/page' +import { ThemeToggle } from '@/components/theme-toggle' +import { t } from '@/lib/strings' + +/** Mobil "☰ Diğer": alt menüye sığmayan sayfalar, tema ve hesap. */ +export function MorePage() { + return ( + <> + + + + +
+

{t.account.theme}

+ +
+ +
+ + +
+ + ) +} diff --git a/frontend/src/features/planning/page.tsx b/frontend/src/features/planning/page.tsx new file mode 100644 index 0000000..eb3d2b4 --- /dev/null +++ b/frontend/src/features/planning/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function PlanningPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/features/reports/page.tsx b/frontend/src/features/reports/page.tsx new file mode 100644 index 0000000..3349b6e --- /dev/null +++ b/frontend/src/features/reports/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function ReportsPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/features/review/page.tsx b/frontend/src/features/review/page.tsx new file mode 100644 index 0000000..cec75e3 --- /dev/null +++ b/frontend/src/features/review/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function ReviewPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/features/savings/page.tsx b/frontend/src/features/savings/page.tsx new file mode 100644 index 0000000..f7e867e --- /dev/null +++ b/frontend/src/features/savings/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function SavingsPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/features/settings/page.tsx b/frontend/src/features/settings/page.tsx new file mode 100644 index 0000000..69a34a4 --- /dev/null +++ b/frontend/src/features/settings/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon, PageHeader } from '@/components/page' +import { t } from '@/lib/strings' + +export function SettingsPage() { + return ( + <> + + + + ) +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 72935c1..d9d1f29 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -52,9 +52,11 @@ export function useLogout() { return useMutation({ mutationFn: () => api.post('/auth/logout'), onSettled: () => { - // Başka kullanıcının verisi ekranda/önbellekte kalmasın - qc.clear() + // Önce oturumu kapat (korumalı sayfalar → giriş ekranı), sonra diğer verileri sil: + // başka kullanıcının verisi önbellekte kalmasın. + // Not: qc.clear() kullanılmaz — açık ekranlar silinen sorguya bağlı kalıp güncellenmez. qc.setQueryData(queryKeys.me, null) + qc.removeQueries({ predicate: (q) => q.queryKey[0] !== queryKeys.me[0] }) }, }) } diff --git a/frontend/src/lib/return-path.test.ts b/frontend/src/lib/return-path.test.ts new file mode 100644 index 0000000..4147f2d --- /dev/null +++ b/frontend/src/lib/return-path.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { safeReturnPath } from './return-path' + +describe('safeReturnPath', () => { + it.each(['/defter', '/plan?ay=12', '/'])('uygulama içi yol kabul: %s', (p) => { + expect(safeReturnPath(p)).toBe(p) + }) + + it.each([null, '', 'defter', '//kotu.com', '/\\kotu.com', 'https://kotu.com', 'javascript:alert(1)'])( + 'güvensiz → panele: %s', + (p) => { + expect(safeReturnPath(p)).toBe('/') + }, + ) +}) diff --git a/frontend/src/lib/return-path.ts b/frontend/src/lib/return-path.ts new file mode 100644 index 0000000..2485877 --- /dev/null +++ b/frontend/src/lib/return-path.ts @@ -0,0 +1,10 @@ +/** + * Girişten sonra dönülecek yol (`/giris?donus=...`). + * Yalnızca uygulama içi yollar kabul edilir — açık yönlendirme (open redirect) saldırısını önler: + * `?donus=//kotu-site.com` veya `?donus=https://...` → panele gider. + */ +export function safeReturnPath(value: string | null, fallback = '/'): string { + if (!value || !value.startsWith('/')) return fallback + if (value.startsWith('//') || value.startsWith('/\\')) return fallback + return value +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts new file mode 100644 index 0000000..d7d6c70 --- /dev/null +++ b/frontend/src/lib/strings.ts @@ -0,0 +1,51 @@ +/** + * Arayüz metinleri tek yerde (KAKEIBO_PROMPT §15 — ileride çok dil desteği için). + * Yeni ekran eklerken metinleri buraya, ilgili bölümün altına ekleyin. + */ +export const t = { + app: { + name: 'Kakeibo', + tagline: 'Kişisel Ev Finans Defteri', + }, + + nav: { + dashboard: 'Panel', + ledger: 'Defter', + planning: 'Plan', + savings: 'Birikim', + more: 'Diğer', + review: 'Değerlendirme', + reports: 'Raporlar', + settings: 'Ayarlar', + main: 'Ana menü', + }, + + pages: { + dashboard: { title: 'Panel', description: 'Gelir, harcama ve birikimin tek bakışta özeti.' }, + ledger: { title: 'Günlük Defter', description: 'Haftalık harcama girişleri.' }, + planning: { title: 'Aylık Plan', description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.' }, + savings: { title: 'Birikimler', description: 'TL, altın ve döviz varlıkların, kâr/zarar.' }, + review: { title: 'Ay Sonu Değerlendirmesi', description: "Kakeibo'nun dört sorusu ve dönem kapanışı." }, + reports: { title: 'Raporlar', description: 'Gelir–gider, kategori ve bütçe sapma raporları.' }, + settings: { title: 'Ayarlar', description: 'Kategoriler, ödeme türleri, hane ve hesap.' }, + more: { title: 'Diğer' }, + notFound: { + title: 'Sayfa bulunamadı', + description: 'Aradığınız sayfa taşınmış ya da hiç var olmamış olabilir.', + action: 'Panele dön', + }, + }, + + comingSoon: (when: string) => `Bu ekran ${when} geliştirilecek.`, + + account: { + owner: 'Hane sahibi', + member: 'Üye', + logout: 'Çıkış yap', + theme: 'Tema', + }, + + common: { + loading: 'Yükleniyor…', + }, +} as const diff --git a/frontend/src/lib/use-document-title.ts b/frontend/src/lib/use-document-title.ts new file mode 100644 index 0000000..0206634 --- /dev/null +++ b/frontend/src/lib/use-document-title.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react' +import { t } from './strings' + +/** Tarayıcı sekmesi başlığı: "Defter · Kakeibo" */ +export function useDocumentTitle(title?: string) { + useEffect(() => { + document.title = title ? `${title} · ${t.app.name}` : t.app.name + }, [title]) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 02bdea4..2778b94 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from 'react-router' import './index.css' -import App from './App.tsx' import { queryClient } from '@/lib/query' import { ThemeProvider } from '@/lib/theme' +import { router } from './router' createRoot(document.getElementById('root')!).render( - + , diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..af6b2eb --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,53 @@ +import { createBrowserRouter, type RouteObject } from 'react-router' +import { AppLayout } from '@/components/layout/app-layout' +import { paths } from '@/components/layout/nav-items' +import { RequireAuth, RequireGuest } from '@/components/layout/require-auth' +import { LoginPage, RegisterPage } from '@/features/auth/login-page' +import { DashboardPage } from '@/features/dashboard/page' +import { DesignPreview } from '@/features/dev/design-preview' +import { NotFoundPage } from '@/features/errors/not-found-page' +import { LedgerPage } from '@/features/ledger/page' +import { MorePage } from '@/features/more/page' +import { PlanningPage } from '@/features/planning/page' +import { ReportsPage } from '@/features/reports/page' +import { ReviewPage } from '@/features/review/page' +import { SavingsPage } from '@/features/savings/page' +import { SettingsPage } from '@/features/settings/page' + +const routes: RouteObject[] = [ + // Oturumsuz + { + element: , + children: [ + { path: paths.login, element: }, + { path: paths.register, element: }, + ], + }, + // Oturumlu (iskelet: kenar çubuğu / alt menü) + { + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: paths.ledger, element: }, + { path: paths.planning, element: }, + { path: paths.savings, element: }, + { path: paths.review, element: }, + { path: paths.reports, element: }, + { path: paths.settings, element: }, + { path: paths.more, element: }, + { path: '*', element: }, + ], + }, + ], + }, +] + +// Tasarım önizlemesi yalnızca geliştirmede (canlı derlemeye girmez) +if (import.meta.env.DEV) { + routes.unshift({ path: '/dev/tasarim', element: }) +} + +export const router = createBrowserRouter(routes)