From b269411823c19b5fecb801f3cc535152b504a2c3 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 00:52:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(giri=C5=9F):=20giri=C5=9F=20ve=20kay=C4=B1?= =?UTF-8?q?t=20ekranlar=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Giriş: satır içi doğrulama, şifre göster/gizle, beni hatırla, hoş geldin bildirimi - Kayıt: yeni hane kur / davetle katıl, /kayit?davet=KOD bağlantısı - Sunucu hataları ilgili alanda gösterilir (kayıtlı e-posta, geçersiz davet) - react-hook-form + zod, sonner bildirimleri, shadcn input/label/checkbox/card - Ortak form bileşenleri (FormField, PasswordInput, FormError) - 8 form kuralı testi (toplam 47); api.http demo hesaplara güncellendi - Faz 2 tamamlandı Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + backend/api.http | 5 +- docs/MEMORY.md | 6 +- docs/PHASES.md | 6 +- frontend/package-lock.json | 156 ++++++++++++++++- frontend/package.json | 6 +- frontend/src/components/form-field.tsx | 75 +++++++++ frontend/src/components/toaster.tsx | 19 +++ frontend/src/components/ui/card.tsx | 102 +++++++++++ frontend/src/components/ui/checkbox.tsx | 32 ++++ frontend/src/components/ui/input.tsx | 19 +++ frontend/src/components/ui/label.tsx | 21 +++ frontend/src/features/auth/auth-shell.tsx | 40 +++++ frontend/src/features/auth/login-page.tsx | 146 ++++++++++------ frontend/src/features/auth/register-page.tsx | 168 +++++++++++++++++++ frontend/src/features/auth/schemas.test.ts | 50 ++++++ frontend/src/features/auth/schemas.ts | 42 +++++ frontend/src/lib/strings.ts | 49 ++++++ frontend/src/main.tsx | 2 + frontend/src/router.tsx | 3 +- 20 files changed, 886 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/form-field.tsx create mode 100644 frontend/src/components/toaster.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/features/auth/auth-shell.tsx create mode 100644 frontend/src/features/auth/register-page.tsx create mode 100644 frontend/src/features/auth/schemas.test.ts create mode 100644 frontend/src/features/auth/schemas.ts diff --git a/CLAUDE.md b/CLAUDE.md index ca7b001..7655251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl - 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`. +- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`). - 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/backend/api.http b/backend/api.http index 612e826..e9c9dcc 100644 --- a/backend/api.http +++ b/backend/api.http @@ -1,5 +1,6 @@ ### Kakeibo API — örnek istekler (VS Code REST Client eklentisi) ### Satırın üstündeki "Send Request" bağlantısına tıklayın. +### Yerel demo hesapları: demo@ornek.com / demo12345 (hane sahibi) · es@ornek.com / demo12345 (üye) @baseUrl = http://localhost:8080/api @@ -15,7 +16,7 @@ Content-Type: application/json { "name": "Suphi", - "email": "suphi@ornek.com", + "email": "yeni@ornek.com", "password": "gizli-sifre-123", "household_name": "Çakır Hanesi" } @@ -37,7 +38,7 @@ POST {{baseUrl}}/auth/login Content-Type: application/json { - "email": "suphi@ornek.com", + "email": "demo@ornek.com", "password": "gizli-sifre-123", "remember_me": true } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index fdeed37..9a1aa81 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,9 +1,10 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**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. +**Faz 3 – Başlangıç:** Faz 3 planını sun (canlıya alma: çok aşamalı Dockerfile, Axum'un frontend'i sunması + SPA yönlendirmesi, Dokploy, canlı MongoDB kontrolü — KAKEIBO_PROMPT §12.3 sorularını kullanıcıyla birlikte, domain + HTTPS, yedekleme). Sunucuya erişim/bilgi kullanıcıdan alınacak. ## 📌 Alınan Kararlar +- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`). - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. - Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz). - Node v26 (LTS) sistemde mevcut; ayrı kurulum gerekmedi. @@ -28,6 +29,8 @@ - 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ı. +- Kayıt ekranı: "Yeni hane kur" / "Davetle katıl"; davet bağlantısı `/kayit?davet=KOD`. Sunucu 409 → e-posta alanı, "Davet…" 400 → kod alanı. +- Yönlendirme sonrası toast için `mutateAsync` + await kullanıldı (mutate() callback'leri bileşen kalkınca çalışmayabilir). - 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 @@ -58,3 +61,4 @@ - **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. +- **Adım 5:** react-hook-form, zod 4, @hookform/resolvers, sonner; shadcn input/label/checkbox/card (44 px / 20 px'e büyütüldü). `components/form-field.tsx`, `components/toaster.tsx`, `features/auth/` (auth-shell, login-page, register-page, schemas + 8 vitest). Metinler `t.auth`. CDP e2e 11 kontrol geçti (boş form, yanlış şifre, giriş + toast, davet linki, kayıtlı e-posta → alan hatası, geçersiz davet → alan hatası, yeni hane). Frontend 47 test. **Faz 2 tamamlandı.** diff --git a/docs/PHASES.md b/docs/PHASES.md index 6e11d41..fb20e3b 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -6,8 +6,8 @@ |---|---|---|---| | **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ | | **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ | -| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | 🔄 | -| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ⏳ | +| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ✅ | +| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | 🔄 | | **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ | | **Faz 5** | Günlük defter | Harcama girişi | ⏳ | | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ | @@ -36,4 +36,4 @@ - ✅ 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 +- ✅ 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 672ec49..0cff873 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@fontsource-variable/inter": "^5.3.0", + "@hookform/resolvers": "^7.0.5", "@tailwindcss/vite": "^4.3.5", "@tanstack/react-query": "^5.107.1", "class-variance-authority": "^0.7.1", @@ -17,10 +18,13 @@ "radix-ui": "^1.8.1", "react": "^19.3.1", "react-dom": "^19.3.1", + "react-hook-form": "^7.92.0", "react-router": "^8.5.1", "shadcn": "^4.23.0", + "sonner": "^2.0.8", "tailwindcss": "^4.3.5", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.6.7" }, "devDependencies": { "@types/node": "^24.19.2", @@ -698,6 +702,112 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hookform/resolvers": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-7.0.5.tgz", + "integrity": "sha512-E7ca3tpIJxHPGhFOfXLlCCZh7up6EcnUTbgTt0SRUENXagq8DJnob9rK+VqsNCsfUuO6mMU5NYNm6BaL18flgQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1 || ^3.0.0", + "arktype": "^2.0.0", + "ata-validator": "^1.23.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^4.0.0", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0 || ^18.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "typebox": "^1.0.0", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=6.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@standard-schema/spec": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3035,6 +3145,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.5.tgz", @@ -5984,6 +6100,28 @@ "react": "^19.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.92.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.92.0.tgz", + "integrity": "sha512-O5ILyY5fYHN7N13Pfxpe55KboyNFsJdM03BHpshQkClJd8raugiAUGY7VjifITrfhVxyx81ko25azXCoayhf1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -6354,6 +6492,22 @@ "npm": ">= 3.0.0" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 10714ce..2a0751d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", + "@hookform/resolvers": "^7.0.5", "@tailwindcss/vite": "^4.3.5", "@tanstack/react-query": "^5.107.1", "class-variance-authority": "^0.7.1", @@ -21,10 +22,13 @@ "radix-ui": "^1.8.1", "react": "^19.3.1", "react-dom": "^19.3.1", + "react-hook-form": "^7.92.0", "react-router": "^8.5.1", "shadcn": "^4.23.0", + "sonner": "^2.0.8", "tailwindcss": "^4.3.5", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.6.7" }, "devDependencies": { "@types/node": "^24.19.2", diff --git a/frontend/src/components/form-field.tsx b/frontend/src/components/form-field.tsx new file mode 100644 index 0000000..e2aef3c --- /dev/null +++ b/frontend/src/components/form-field.tsx @@ -0,0 +1,75 @@ +import { useId, useState, type ComponentProps, type ReactNode } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import type { FieldError } from 'react-hook-form' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' + +/** + * Etiket + giriş alanı + (varsa) hata/ipucu metni. + * Erişilebilirlik: hata `aria-invalid` ve `aria-describedby` ile alana bağlanır, + * ekran okuyucu alanı odaklayınca hatayı okur. + */ +export function FormField({ + label, + error, + hint, + className, + children, +}: { + label: string + error?: FieldError + hint?: string + className?: string + /** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */ + children: (field: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode +}) { + const id = useId() + const messageId = `${id}-message` + const message = error?.message ?? hint + + return ( +
+ + {children({ + id, + 'aria-invalid': !!error, + 'aria-describedby': message ? messageId : undefined, + })} + {message && ( +

+ {message} +

+ )} +
+ ) +} + +/** Şifre alanı: göster/gizle düğmeli. */ +export function PasswordInput(props: ComponentProps) { + const [visible, setVisible] = useState(false) + return ( +
+ + +
+ ) +} + +/** Sunucudan gelen, belirli bir alana ait olmayan hata (ör. "E-posta veya şifre hatalı"). */ +export function FormError({ message }: { message?: string }) { + if (!message) return null + return ( +

+ {message} +

+ ) +} diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx new file mode 100644 index 0000000..f6f8409 --- /dev/null +++ b/frontend/src/components/toaster.tsx @@ -0,0 +1,19 @@ +import { Toaster as Sonner } from 'sonner' +import { useTheme } from '@/lib/theme' + +/** + * Bildirimler (toast). Kullanım: `import { toast } from 'sonner'` → `toast.success('Kaydedildi')` + * Mobilde alt menüyle çakışmasın diye üstte gösterilir. + */ +export function Toaster() { + const { theme } = useTheme() + return ( + + ) +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..cf02f48 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,102 @@ +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/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c346af5 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"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/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..09599fd --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" +import { cn } from "cn" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..425b3b5 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import { cn } from "cn" +import { Label as LabelPrimitive } from "radix-ui" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/frontend/src/features/auth/auth-shell.tsx b/frontend/src/features/auth/auth-shell.tsx new file mode 100644 index 0000000..0c8e025 --- /dev/null +++ b/frontend/src/features/auth/auth-shell.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import { Logo } from '@/components/logo' +import { ThemeToggle } from '@/components/theme-toggle' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { useDocumentTitle } from '@/lib/use-document-title' + +/** Giriş / kayıt ekranlarının ortak çerçevesi: ortada kart, üstte logo. */ +export function AuthShell({ + title, + description, + footer, + children, +}: { + title: string + description: string + footer: ReactNode + children: ReactNode +}) { + useDocumentTitle(title) + return ( +
+
+ +
+
+ + + + +

{title}

+
+ {description} +
+ {children} +
+

{footer}

+
+
+ ) +} diff --git a/frontend/src/features/auth/login-page.tsx b/frontend/src/features/auth/login-page.tsx index 93b1f47..9ebb713 100644 --- a/frontend/src/features/auth/login-page.tsx +++ b/frontend/src/features/auth/login-page.tsx @@ -1,67 +1,107 @@ -import { useState, type FormEvent } from 'react' -import { LogIn } from 'lucide-react' -import { Link } from 'react-router' +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2, LogIn } from 'lucide-react' +import { Controller, useForm } from 'react-hook-form' +import { Link, useSearchParams } from 'react-router' +import { toast } from 'sonner' +import { FormError, FormField, PasswordInput } from '@/components/form-field' import { paths } from '@/components/layout/nav-items' -import { Logo } from '@/components/logo' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { ApiError } from '@/lib/api' import { useLogin } from '@/lib/auth' -import { useDocumentTitle } from '@/lib/use-document-title' +import { t } from '@/lib/strings' +import { AuthShell } from './auth-shell' +import { loginSchema, type LoginForm } from './schemas' + +const s = t.auth.login -// 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 [params] = useSearchParams() + const { + register, + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onTouched', // alan terk edilince doğrula, sonra yazarken güncelle + defaultValues: { email: '', password: '', remember_me: true }, + }) - const submit = (e: FormEvent) => { - e.preventDefault() - // Başarılı olunca RequireGuest, ?donus= adresine yönlendirir - login.mutate({ email, password, remember_me: true }) - } + const onSubmit = handleSubmit(async (values) => { + try { + const me = await login.mutateAsync(values) + toast.success(s.welcome(me.user.name)) + // Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya) + } catch (e) { + setError('root', { message: e instanceof ApiError ? e.message : String(e) }) + } + }) + + // Kayıt sayfasına geçerken dönüş adresi kaybolmasın + const donus = params.get('donus') + const registerLink = donus ? `${paths.register}?donus=${encodeURIComponent(donus)}` : paths.register return ( -
- -
- setEmail(e.target.value)} + + {s.noAccount}{' '} + + {s.toRegister} + + + } + > + + + {(f) => ( + + )} + + + + {(f) => } + + + ( + + )} /> - 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/auth/register-page.tsx b/frontend/src/features/auth/register-page.tsx new file mode 100644 index 0000000..c0d0127 --- /dev/null +++ b/frontend/src/features/auth/register-page.tsx @@ -0,0 +1,168 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Home, Loader2, UserPlus, Users } from 'lucide-react' +import { Controller, useForm, useWatch } from 'react-hook-form' +import { Link, useSearchParams } from 'react-router' +import { toast } from 'sonner' +import { FormError, FormField, PasswordInput } from '@/components/form-field' +import { paths } from '@/components/layout/nav-items' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ApiError } from '@/lib/api' +import { useRegister } from '@/lib/auth' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import { AuthShell } from './auth-shell' +import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas' + +const s = t.auth.register + +export function RegisterPage() { + const registerAccount = useRegister() + const [params] = useSearchParams() + // Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu + const invite = params.get('davet') ?? '' + + const { + register, + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(registerSchema), + mode: 'onTouched', + defaultValues: { + name: '', + email: '', + password: '', + mode: invite ? 'join' : 'new', + household_name: '', + invite_code: invite, + }, + }) + const mode = useWatch({ control, name: 'mode' }) + const name = useWatch({ control, name: 'name' }).trim() + + const onSubmit = handleSubmit(async (v) => { + try { + const me = await registerAccount.mutateAsync({ + name: v.name, + email: v.email, + password: v.password, + ...(v.mode === 'join' + ? { invite_code: normalizeInviteCode(v.invite_code) } + : { household_name: v.household_name || undefined }), + }) + toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name)) + } catch (e) { + if (!(e instanceof ApiError)) return setError('root', { message: String(e) }) + // Sunucu hatasını ilgili alana bağla + if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true }) + else if (v.mode === 'join' && e.code === 'bad_request' && e.message.includes('Davet')) + setError('invite_code', { message: e.message }, { shouldFocus: true }) + else setError('root', { message: e.message }) + } + }) + + const donus = params.get('donus') + const loginLink = donus ? `${paths.login}?donus=${encodeURIComponent(donus)}` : paths.login + + return ( + + {s.hasAccount}{' '} + + {s.toLogin} + + + } + > +
+ + {(f) => } + + + + {(f) => ( + + )} + + + + {(f) => } + + + {/* Yeni hane mi, davetle katılma mı? */} +
+ {s.modeLabel} + ( +
+ {( + [ + ['new', s.modeNew, Home], + ['join', s.modeJoin, Users], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} + /> + + {mode === 'new' ? ( + + {(f) => } + + ) : ( + + {(f) => ( + + )} + + )} +
+ + + + + +
+ ) +} diff --git a/frontend/src/features/auth/schemas.test.ts b/frontend/src/features/auth/schemas.test.ts new file mode 100644 index 0000000..d258000 --- /dev/null +++ b/frontend/src/features/auth/schemas.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { loginSchema, normalizeInviteCode, registerSchema } from './schemas' + +const base = { name: 'Suphi', email: 'a@b.co', password: '12345678', mode: 'new' as const, household_name: '', invite_code: '' } +const issues = (r: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }) => + Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message])) + +describe('loginSchema', () => { + it('boş alanlarda Türkçe mesaj', () => { + expect(issues(loginSchema.safeParse({ email: '', password: '', remember_me: true }))).toEqual({ + email: 'E-posta gerekli', + password: 'Şifre gerekli', + }) + }) + it('geçersiz e-posta', () => { + expect(issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email).toBe( + 'Geçerli bir e-posta adresi girin', + ) + }) + it('e-postadaki boşlukları kırpar', () => { + const r = loginSchema.safeParse({ email: ' a@b.co ', password: 'x', remember_me: true }) + expect(r.success && r.data.email).toBe('a@b.co') + }) +}) + +describe('registerSchema', () => { + it('geçerli yeni hane kaydı', () => { + expect(registerSchema.safeParse(base).success).toBe(true) + }) + it('kısa şifre ve boş ad', () => { + expect(issues(registerSchema.safeParse({ ...base, name: ' ', password: '123' }))).toEqual({ + name: 'Adını yaz', + password: 'Şifre en az 8 karakter olmalı', + }) + }) + it('davet modunda kod zorunlu ve 8 karakter', () => { + expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe('Davet kodunu yaz') + expect(issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code).toBe( + 'Davet kodu 8 karakter olmalı', + ) + expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true) + }) + it('yeni hane modunda davet kodu yok sayılır', () => { + expect(registerSchema.safeParse({ ...base, invite_code: 'x' }).success).toBe(true) + }) +}) + +it('normalizeInviteCode', () => { + expect(normalizeInviteCode(' abcd-efgh ')).toBe('ABCDEFGH') +}) diff --git a/frontend/src/features/auth/schemas.ts b/frontend/src/features/auth/schemas.ts new file mode 100644 index 0000000..069a1e3 --- /dev/null +++ b/frontend/src/features/auth/schemas.ts @@ -0,0 +1,42 @@ +/** + * Giriş / kayıt form kuralları. Backend'deki doğrulamayla aynı sınırlar + * (`backend/src/routes/auth.rs`) — kullanıcı hatayı göndermeden görür, backend yine de kontrol eder. + */ +import { z } from 'zod' +import { t } from '@/lib/strings' + +const m = t.auth.validation + +const email = z.string().trim().min(1, { error: m.emailRequired }).pipe(z.email({ error: m.emailInvalid })) + +export const loginSchema = z.object({ + email, + password: z.string().min(1, { error: m.passwordRequired }), + remember_me: z.boolean(), +}) +export type LoginForm = z.infer + +export const registerSchema = z + .object({ + name: z.string().trim().min(1, { error: m.nameRequired }).max(80, { error: m.nameTooLong }), + email, + password: z + .string() + .min(8, { error: m.passwordTooShort }) + .max(128, { error: m.passwordTooLong }), + mode: z.enum(['new', 'join']), + household_name: z.string().trim().max(80, { error: m.householdTooLong }), + invite_code: z.string().trim(), + }) + .superRefine((v, ctx) => { + if (v.mode !== 'join') return + const code = normalizeInviteCode(v.invite_code) + if (!code) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteRequired }) + else if (code.length !== 8) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid }) + }) +export type RegisterForm = z.infer + +/** Backend ile aynı: boşluk/tire atılır, büyük harf. "abcd-efgh" → "ABCDEFGH" */ +export function normalizeInviteCode(code: string): string { + return code.replace(/[^a-z0-9]/gi, '').toUpperCase() +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index d7d6c70..6eb8cbd 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -45,6 +45,55 @@ export const t = { theme: 'Tema', }, + auth: { + login: { + title: 'Tekrar hoş geldin', + description: 'Defterine devam etmek için giriş yap.', + email: 'E-posta', + password: 'Şifre', + remember: 'Beni hatırla', + rememberHint: '30 gün boyunca bu cihazda oturum açık kalır.', + submit: 'Giriş yap', + submitting: 'Giriş yapılıyor…', + noAccount: 'Hesabın yok mu?', + toRegister: 'Kayıt ol', + welcome: (name: string) => `Hoş geldin, ${name} 👋`, + }, + register: { + title: 'Hesap oluştur', + description: 'Hane bütçeni Kakeibo yöntemiyle tutmaya başla.', + name: 'Adın', + email: 'E-posta', + password: 'Şifre', + passwordHint: 'En az 8 karakter.', + modeLabel: 'Hane', + modeNew: 'Yeni hane kur', + modeJoin: 'Davetle katıl', + householdName: 'Hane adı', + householdHint: (name: string) => (name ? `Boş bırakırsan: "${name} Hanesi"` : 'İsteğe bağlı'), + inviteCode: 'Davet kodu', + inviteHint: 'Hane sahibinden aldığın 8 karakterlik kod (ör. ABCD-EFGH).', + submit: 'Hesabı oluştur', + submitting: 'Oluşturuluyor…', + hasAccount: 'Zaten hesabın var mı?', + toLogin: 'Giriş yap', + welcome: (household: string) => `Hesabın hazır! ${household} defterin açıldı 📒`, + joined: (household: string) => `${household} hanesine katıldın 🎉`, + }, + validation: { + emailRequired: 'E-posta gerekli', + emailInvalid: 'Geçerli bir e-posta adresi girin', + passwordRequired: 'Şifre gerekli', + passwordTooShort: 'Şifre en az 8 karakter olmalı', + passwordTooLong: 'Şifre en fazla 128 karakter olabilir', + nameRequired: 'Adını yaz', + nameTooLong: 'Ad en fazla 80 karakter olabilir', + householdTooLong: 'Hane adı en fazla 80 karakter olabilir', + inviteRequired: 'Davet kodunu yaz', + inviteInvalid: 'Davet kodu 8 karakter olmalı', + }, + }, + common: { loading: 'Yükleniyor…', }, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2778b94..12aba2b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from 'react-router' import './index.css' import { queryClient } from '@/lib/query' +import { Toaster } from '@/components/toaster' import { ThemeProvider } from '@/lib/theme' import { router } from './router' @@ -12,6 +13,7 @@ createRoot(document.getElementById('root')!).render( + , diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index af6b2eb..f7d2073 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,7 +2,8 @@ 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 { LoginPage } from '@/features/auth/login-page' +import { RegisterPage } from '@/features/auth/register-page' import { DashboardPage } from '@/features/dashboard/page' import { DesignPreview } from '@/features/dev/design-preview' import { NotFoundPage } from '@/features/errors/not-found-page'