feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu

- Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types)
- Hata ve health yanıtları tipli yapılara çevrildi
- lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı
- lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır)
- lib/auth.ts: useMe, useLogin, useRegister, useLogout
- Vitest: 7 API istemcisi testi (toplam 29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 00:39:56 +03:00
parent f9be08114d
commit 906f05f62f
41 changed files with 558 additions and 49 deletions
+33
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@tailwindcss/vite": "^4.3.5",
"@tanstack/react-query": "^5.107.1",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.2",
"lucide-react": "^1.72.0",
@@ -3356,6 +3357,38 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.107.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.107.1.tgz",
"integrity": "sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.107.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.107.1.tgz",
"integrity": "sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.107.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
"react": "^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@tailwindcss/vite": "^4.3.5",
"@tanstack/react-query": "^5.107.1",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.2",
"lucide-react": "^1.72.0",
+73 -17
View File
@@ -1,27 +1,25 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LogIn, LogOut, 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'
type Health = { status: string; db: string; version: string }
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() {
const [health, setHealth] = useState<Health | null>(null)
const [error, setError] = useState(false)
const health = useQuery({
queryKey: queryKeys.health,
queryFn: () => api.get<HealthResponse>('/health'),
})
const [today] = useState(() => toIsoDate(new Date()))
useEffect(() => {
fetch('/api/health')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then(setHealth)
.catch(() => setError(true))
}, [])
return (
<main className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8">
<header className="flex flex-wrap items-center justify-between gap-4">
@@ -35,15 +33,17 @@ export default function App() {
</header>
<p className="text-sm">
{error && <span className="text-expense">● Backend'e ulaşılamadı</span>}
{!error && !health && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
{health && (
{health.isError && <span className="text-expense">● {health.error.message}</span>}
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
{health.data && (
<span className="text-income">
● Backend çalışıyor (v{health.version}, DB: {health.db})
● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
</span>
)}
</p>
<SessionPanel />
<section className="space-y-3">
<h2 className="text-xl font-semibold">Ana gruplar</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -102,3 +102,59 @@ 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>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api, ApiError } from './api'
function mockFetch(status: number, body?: unknown) {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
}),
)
return globalThis.fetch as ReturnType<typeof vi.fn>
}
afterEach(() => vi.restoreAllMocks())
describe('api', () => {
it('başarılı JSON yanıtı döner, /api önekini ekler', async () => {
const f = mockFetch(200, { status: 'ok' })
await expect(api.get('/health')).resolves.toEqual({ status: 'ok' })
expect(f).toHaveBeenCalledWith('/api/health', expect.objectContaining({ method: 'GET' }))
})
it('gövdeyi JSON olarak gönderir', async () => {
const f = mockFetch(201, { id: '1' })
await api.post('/categories', { name: 'Market' })
const init = f.mock.calls[0][1] as RequestInit
expect(init.body).toBe('{"name":"Market"}')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
})
it('204 → undefined', async () => {
mockFetch(204)
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
})
it('backend hatasını Türkçe mesajıyla ApiError yapar', async () => {
mockFetch(409, { error: { code: 'conflict', message: 'Bu e-posta adresi zaten kayıtlı' } })
const err = await api.post('/auth/register', {}).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ApiError)
expect(err).toMatchObject({
status: 409,
code: 'conflict',
message: 'Bu e-posta adresi zaten kayıtlı',
})
})
it('401 → isUnauthorized', async () => {
mockFetch(401, { error: { code: 'unauthorized', message: 'Oturum açmanız gerekiyor' } })
const err = (await api.get('/auth/me').catch((e: unknown) => e)) as ApiError
expect(err.isUnauthorized).toBe(true)
})
it('JSON olmayan hata (ör. proxy 502) → genel Türkçe mesaj', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Bad Gateway', { status: 502 }))
await expect(api.get('/health')).rejects.toMatchObject({
status: 502,
message: 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.',
})
})
it('ağ hatası → code "network"', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
await expect(api.get('/health')).rejects.toMatchObject({ status: 0, code: 'network' })
})
})
+62
View File
@@ -0,0 +1,62 @@
/**
* Backend ile konuşan tek yer. Tüm istekler `/api` altına gider
* (geliştirmede Vite proxy, canlıda aynı sunucu → CORS yok).
*
* Oturum, httpOnly cookie ile taşınır: JavaScript token'ı görmez, tarayıcı kendisi gönderir.
*/
import type { ApiErrorBody } from '@/types/ApiErrorBody'
/** Backend'in döndürdüğü hata, Türkçe mesajıyla. Arayüzde doğrudan `error.message` gösterilir. */
export class ApiError extends Error {
readonly status: number
/** `unauthorized`, `bad_request`, `conflict`, `not_found`, `network`, ... */
readonly code: string
constructor(status: number, code: string, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
get isUnauthorized() {
return this.status === 401
}
}
const NETWORK_MESSAGE = 'Sunucuya ulaşılamadı. İnternet bağlantınızı kontrol edip tekrar deneyin.'
const UNKNOWN_MESSAGE = 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.'
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
let res: Response
try {
res = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch {
throw new ApiError(0, 'network', NETWORK_MESSAGE)
}
if (res.status === 204) return undefined as T
const data: unknown = await res.json().catch(() => null)
if (!res.ok) {
const detail = (data as Partial<ApiErrorBody> | null)?.error
throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE)
}
return data as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T = void>(path: string) => request<T>('DELETE', path),
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Oturum durumu ve giriş / kayıt / çıkış işlemleri.
*
* "Kim giriş yapmış?" bilgisinin tek kaynağı `useMe()`:
* - `undefined` → henüz kontrol ediliyor
* - `null` → oturum yok
* - `MeResponse`→ giriş yapılmış
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { LoginRequest } from '@/types/LoginRequest'
import type { MeResponse } from '@/types/MeResponse'
import type { RegisterRequest } from '@/types/RegisterRequest'
import { api, ApiError } from './api'
import { queryKeys } from './query'
async function fetchMe(): Promise<MeResponse | null> {
try {
return await api.get<MeResponse>('/auth/me')
} catch (e) {
// Oturum yoksa bu bir hata değil, bilinen bir durum
if (e instanceof ApiError && e.isUnauthorized) return null
throw e
}
}
export function useMe() {
return useQuery({
queryKey: queryKeys.me,
queryFn: fetchMe,
staleTime: 5 * 60_000,
})
}
export function useLogin() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: LoginRequest) => api.post<MeResponse>('/auth/login', body),
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
})
}
export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post<void>('/auth/logout'),
onSettled: () => {
// Başka kullanıcının verisi ekranda/önbellekte kalmasın
qc.clear()
qc.setQueryData(queryKeys.me, null)
},
})
}
+3 -1
View File
@@ -3,8 +3,10 @@
* Renk asla tek başına anlam taşımaz: her zaman emoji/ikon + etiketle birlikte kullanılır.
*/
import { BookOpen, House, ShoppingBag, Zap, type LucideIcon } from 'lucide-react'
import type { Group } from '@/types/Group'
export type GroupCode = 'needs' | 'wants' | 'culture' | 'unexpected'
/** Backend'den üretilen tip (ts-rs): `"needs" | "wants" | "culture" | "unexpected"` */
export type GroupCode = Group
export type GroupMeta = {
code: GroupCode
+42
View File
@@ -0,0 +1,42 @@
/**
* TanStack Query ayarları: sunucu verisinin önbelleği, yeniden deneme, yenileme.
*/
import { QueryCache, QueryClient } from '@tanstack/react-query'
import { ApiError } from './api'
/** Önbellek anahtarları tek yerde (yazım hatası olmasın) */
export const queryKeys = {
me: ['me'] as const,
health: ['health'] as const,
household: ['household'] as const,
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
}
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
function shouldRetry(failureCount: number, error: unknown) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false
return failureCount < 2
}
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
// Herhangi bir istek 401 alırsa oturum bitmiş demektir → "me" boşaltılır,
// korumalı sayfalar (Adım 4) kullanıcıyı giriş ekranına yönlendirir.
if (error instanceof ApiError && error.isUnauthorized) {
queryClient.setQueryData(queryKeys.me, null)
}
},
}),
defaultOptions: {
queries: {
retry: shouldRetry,
staleTime: 30_000, // 30 sn içinde aynı veri tekrar istenmez
refetchOnWindowFocus: true, // sekmeye dönünce güncel veri (ör. eşin eklediği harcama)
},
mutations: {
retry: false,
},
},
})
+7 -3
View File
@@ -1,13 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
import { queryClient } from '@/lib/query'
import { ThemeProvider } from '@/lib/theme'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
View File
+7
View File
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApiErrorDetail } from "./ApiErrorDetail";
/**
* Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
*/
export type ApiErrorBody = { error: ApiErrorDetail, };
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ApiErrorDetail = {
/**
* Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
*/
code: string,
/**
* Kullanıcıya gösterilecek Türkçe mesaj
*/
message: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Group } from "./Group";
export type CatalogCreateRequest = { group?: Group, name: string, emoji?: string, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Group } from "./Group";
export type CatalogItem = { id: string, group?: Group, name: string, emoji: string | null, sort_order: number, is_active: boolean, version: number, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Group } from "./Group";
export type CatalogOrderRequest = { group?: Group, ids: Array<string>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CatalogUpdateRequest = { name?: string, emoji?: string, is_active?: boolean, version?: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Group = "needs" | "wants" | "culture" | "unexpected";
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HealthResponse = {
/**
* `ok` veya `degraded`
*/
status: string,
/**
* `ok` veya `error`
*/
db: string, version: string, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Member } from "./Member";
export type Household = { id: string, name: string, members: Array<Member>, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HouseholdSummary = { id: string, name: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Invite = { id: string, code: string, expires_at: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoginRequest = { email: string, password: string, remember_me: boolean, };
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HouseholdSummary } from "./HouseholdSummary";
import type { User } from "./User";
export type MeResponse = { user: User, household: HouseholdSummary, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Role } from "./Role";
export type Member = { id: string, name: string, email: string, role: Role, };
+7
View File
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RegisterRequest = { name: string, email: string, password: string, household_name?: string,
/**
* Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
*/
invite_code?: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Role = "owner" | "member";
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Role } from "./Role";
export type User = { id: string, name: string, email: string, role: Role, };