feat(arayüz): sayfa yönlendirme, korumalı sayfalar ve uygulama iskeleti
- react-router: Türkçe adresler (/defter, /plan, /birikim, ...), 404 sayfası - Korumalı sayfalar: oturum yoksa /giris?donus=..., girişten sonra geri dönüş - Açık yönlendirme koruması (safeReturnPath, 10 test) - İskelet: mobilde 5 öğeli alt menü, masaüstünde sol kenar çubuğu - Diğer sayfası (değerlendirme, raporlar, ayarlar, tema, çıkış) - Arayüz metinleri lib/strings.ts'de toplanmaya başlandı - Tasarım önizlemesi /dev/tasarim (yalnızca geliştirmede) - Düzeltme: çıkışta önbellek temizliği ekranı güncellemiyordu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+43
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar />
|
||||
|
||||
{/* Mobil üst çubuk */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
|
||||
<Logo size={28} withText />
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="md:pl-64">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
<ScrollRestoration />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<Logo size={32} withText />
|
||||
</div>
|
||||
<nav aria-label={t.nav.main} className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||
{sideNav.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
||||
isActive && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="space-y-3 border-t p-4">
|
||||
<AccountSummary />
|
||||
<ThemeToggle compact className="w-full" />
|
||||
<LogoutButton className="w-full justify-start" />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<nav
|
||||
aria-label={t.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
>
|
||||
<ul className="grid grid-cols-5">
|
||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-16 flex-col items-center justify-center gap-1 text-xs font-medium text-muted-foreground transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-14 items-center justify-center rounded-full transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSummary() {
|
||||
const { data: me } = useMe()
|
||||
if (!me) return null
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{me.user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{me.household.name} · {me.user.role === 'owner' ? t.account.owner : t.account.member}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoutButton({ className }: { className?: string }) {
|
||||
const logout = useLogout()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
<LogOut /> {t.account.logout}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 (
|
||||
<div className="grid min-h-dvh place-items-center" role="status" aria-label={t.common.loading}>
|
||||
<Logo size={56} className="animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
|
||||
export function RequireAuth() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (me.isPending) return <Splash />
|
||||
if (!me.data) {
|
||||
const back = location.pathname + location.search
|
||||
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
|
||||
return <Navigate to={to} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <Splash />
|
||||
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
|
||||
{description && <p className="mt-1 text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
|
||||
<Construction className="size-8" aria-hidden />
|
||||
<p>{t.comingSoon(when)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -23,14 +26,16 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
title={label}
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
<span>{label}</span>
|
||||
<span className={compact ? 'sr-only' : undefined}>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4">
|
||||
<Logo size={56} withText />
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3"
|
||||
type="email"
|
||||
placeholder="E-posta"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
|
||||
<Button type="submit" size="lg" disabled={login.isPending}>
|
||||
<LogIn /> Giriş
|
||||
</Button>
|
||||
</form>
|
||||
<Link to={paths.register} className="text-center text-sm text-muted-foreground underline">
|
||||
Hesabınız yok mu? Kayıt olun
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// GEÇİCİ: Adım 5'te kayıt formu (davet kodu dahil).
|
||||
export function RegisterPage() {
|
||||
useDocumentTitle('Kayıt')
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4 text-center">
|
||||
<Logo size={56} withText className="justify-center" />
|
||||
<p className="text-muted-foreground">Kayıt ekranı Faz 2 – Adım 5'te geliştirilecek.</p>
|
||||
<Link to={paths.login} className="text-sm underline">
|
||||
Girişe dön
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
||||
<ComingSoon when="Faz 7'de" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<HealthResponse>('/health'),
|
||||
@@ -42,7 +40,6 @@ export default function App() {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<SessionPanel />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Ana gruplar</h2>
|
||||
@@ -102,59 +99,3 @@ export default function App() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<h2 className="text-xl font-semibold">Oturum (geçici test paneli)</h2>
|
||||
{me.isPending && <p className="text-muted-foreground">Oturum kontrol ediliyor…</p>}
|
||||
{me.data && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
👋 <strong>{me.data.user.name}</strong> · {me.data.household.name} ·{' '}
|
||||
<span className="text-muted-foreground">{me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'}</span>
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||
<LogOut /> Çıkış
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{me.data === null && (
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||
type="email"
|
||||
placeholder="E-posta"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={login.isPending}>
|
||||
<LogIn /> Giriş
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="py-10 text-center">
|
||||
<p className="mb-4 text-6xl" aria-hidden>
|
||||
🧭
|
||||
</p>
|
||||
<PageHeader title={t.pages.notFound.title} />
|
||||
<p className="-mt-4 mb-6 text-muted-foreground">{t.pages.notFound.description}</p>
|
||||
<Button asChild>
|
||||
<Link to={paths.dashboard}>
|
||||
<House /> {t.pages.notFound.action}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function LedgerPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
|
||||
<ComingSoon when="Faz 5'te" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageHeader title={t.pages.more.title} />
|
||||
|
||||
<nav className="divide-y overflow-hidden rounded-xl border bg-card">
|
||||
{moreNav.map(({ to, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="flex min-h-14 items-center gap-3 px-4 transition-colors hover:bg-muted"
|
||||
>
|
||||
<Icon className="size-5 text-muted-foreground" aria-hidden />
|
||||
<span className="flex-1 font-medium">{label}</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="mt-6 space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{t.account.theme}</h2>
|
||||
<ThemeToggle className="w-full [&>button]:flex-1" />
|
||||
</section>
|
||||
|
||||
<section className="mt-6 flex items-center justify-between gap-3 rounded-xl border bg-card p-4">
|
||||
<AccountSummary />
|
||||
<LogoutButton />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function PlanningPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
||||
<ComingSoon when="Faz 4'te" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function ReportsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} />
|
||||
<ComingSoon when="Faz 9'da" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function ReviewPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.review.title} description={t.pages.review.description} />
|
||||
<ComingSoon when="Faz 6'da" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function SavingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.savings.title} description={t.pages.savings.description} />
|
||||
<ComingSoon when="Faz 8'de" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
||||
<ComingSoon when="ilerleyen fazlarda" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -52,9 +52,11 @@ export function useLogout() {
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<void>('/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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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('/')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -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: <RequireGuest />,
|
||||
children: [
|
||||
{ path: paths.login, element: <LoginPage /> },
|
||||
{ path: paths.register, element: <RegisterPage /> },
|
||||
],
|
||||
},
|
||||
// Oturumlu (iskelet: kenar çubuğu / alt menü)
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: paths.ledger, element: <LedgerPage /> },
|
||||
{ path: paths.planning, element: <PlanningPage /> },
|
||||
{ path: paths.savings, element: <SavingsPage /> },
|
||||
{ path: paths.review, element: <ReviewPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ path: paths.settings, element: <SettingsPage /> },
|
||||
{ path: paths.more, element: <MorePage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Tasarım önizlemesi yalnızca geliştirmede (canlı derlemeye girmez)
|
||||
if (import.meta.env.DEV) {
|
||||
routes.unshift({ path: '/dev/tasarim', element: <DesignPreview /> })
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter(routes)
|
||||
Reference in New Issue
Block a user