feat(giriş): giriş ve kayıt ekranları
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
|
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/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.
|
- 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`.
|
- 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`.
|
- 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`
|
- API örnekleri: `backend/api.http`
|
||||||
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
|
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
|
||||||
### Satırın üstündeki "Send Request" bağlantısına tıklayın.
|
### 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
|
@baseUrl = http://localhost:8080/api
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "Suphi",
|
"name": "Suphi",
|
||||||
"email": "suphi@ornek.com",
|
"email": "yeni@ornek.com",
|
||||||
"password": "gizli-sifre-123",
|
"password": "gizli-sifre-123",
|
||||||
"household_name": "Çakır Hanesi"
|
"household_name": "Çakır Hanesi"
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,7 @@ POST {{baseUrl}}/auth/login
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"email": "suphi@ornek.com",
|
"email": "demo@ornek.com",
|
||||||
"password": "gizli-sifre-123",
|
"password": "gizli-sifre-123",
|
||||||
"remember_me": true
|
"remember_me": true
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,9 +1,10 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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
|
## 📌 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`.
|
- 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).
|
- 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.
|
- 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.
|
- 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ç).
|
- Çı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ı.
|
- 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.
|
- 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
|
## ❓ 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 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 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 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ı.**
|
||||||
|
|||||||
+3
-3
@@ -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 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 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 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 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 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ |
|
||||||
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ |
|
| **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ü | ⏳ |
|
| **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ı
|
- ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
|
||||||
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
- ✅ 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ü
|
- ✅ 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
|
||||||
|
|||||||
Generated
+155
-1
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@hookform/resolvers": "^7.0.5",
|
||||||
"@tailwindcss/vite": "^4.3.5",
|
"@tailwindcss/vite": "^4.3.5",
|
||||||
"@tanstack/react-query": "^5.107.1",
|
"@tanstack/react-query": "^5.107.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -17,10 +18,13 @@
|
|||||||
"radix-ui": "^1.8.1",
|
"radix-ui": "^1.8.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
|
"react-hook-form": "^7.92.0",
|
||||||
"react-router": "^8.5.1",
|
"react-router": "^8.5.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwindcss": "^4.3.5",
|
"tailwindcss": "^4.3.5",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
@@ -698,6 +702,112 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -3035,6 +3145,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.5",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.5.tgz",
|
||||||
@@ -5984,6 +6100,28 @@
|
|||||||
"react": "^19.3.1"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -6354,6 +6492,22 @@
|
|||||||
"npm": ">= 3.0.0"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@hookform/resolvers": "^7.0.5",
|
||||||
"@tailwindcss/vite": "^4.3.5",
|
"@tailwindcss/vite": "^4.3.5",
|
||||||
"@tanstack/react-query": "^5.107.1",
|
"@tanstack/react-query": "^5.107.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -21,10 +22,13 @@
|
|||||||
"radix-ui": "^1.8.1",
|
"radix-ui": "^1.8.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
|
"react-hook-form": "^7.92.0",
|
||||||
"react-router": "^8.5.1",
|
"react-router": "^8.5.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwindcss": "^4.3.5",
|
"tailwindcss": "^4.3.5",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children({
|
||||||
|
id,
|
||||||
|
'aria-invalid': !!error,
|
||||||
|
'aria-describedby': message ? messageId : undefined,
|
||||||
|
})}
|
||||||
|
{message && (
|
||||||
|
<p id={messageId} className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Şifre alanı: göster/gizle düğmeli. */
|
||||||
|
export function PasswordInput(props: ComponentProps<typeof Input>) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input {...props} type={visible ? 'text' : 'password'} className={cn('pr-12', props.className)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={visible ? 'Şifreyi gizle' : 'Şifreyi göster'}
|
||||||
|
aria-pressed={visible}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<p role="alert" className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Sonner
|
||||||
|
theme={theme}
|
||||||
|
position="top-center"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{ className: 'font-sans' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>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 (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -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<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-5 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-4"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
// Kakeibo: h-11 (44 px dokunma alanı); mobilde text-base (16 px) → iOS odaklanınca yakınlaştırmaz
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -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<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-muted/40">
|
||||||
|
<div className="flex justify-end p-3">
|
||||||
|
<ThemeToggle compact />
|
||||||
|
</div>
|
||||||
|
<main className="flex flex-1 flex-col items-center px-4 pb-10 sm:justify-center">
|
||||||
|
<Logo size={52} withText className="mb-6" />
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground">{footer}</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,67 +1,107 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { LogIn } from 'lucide-react'
|
import { Loader2, LogIn } from 'lucide-react'
|
||||||
import { Link } from 'react-router'
|
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 { paths } from '@/components/layout/nav-items'
|
||||||
import { Logo } from '@/components/logo'
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 { 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() {
|
export function LoginPage() {
|
||||||
useDocumentTitle('Giriş')
|
|
||||||
const login = useLogin()
|
const login = useLogin()
|
||||||
const [email, setEmail] = useState('')
|
const [params] = useSearchParams()
|
||||||
const [password, setPassword] = useState('')
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
mode: 'onTouched', // alan terk edilince doğrula, sonra yazarken güncelle
|
||||||
|
defaultValues: { email: '', password: '', remember_me: true },
|
||||||
|
})
|
||||||
|
|
||||||
const submit = (e: FormEvent) => {
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
e.preventDefault()
|
try {
|
||||||
// Başarılı olunca RequireGuest, ?donus= adresine yönlendirir
|
const me = await login.mutateAsync(values)
|
||||||
login.mutate({ email, password, remember_me: true })
|
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 (
|
return (
|
||||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4">
|
<AuthShell
|
||||||
<Logo size={56} withText />
|
title={s.title}
|
||||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
description={s.description}
|
||||||
<input
|
footer={
|
||||||
className="h-11 rounded-md border bg-background px-3"
|
<>
|
||||||
type="email"
|
{s.noAccount}{' '}
|
||||||
placeholder="E-posta"
|
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
autoComplete="email"
|
{s.toRegister}
|
||||||
value={email}
|
</Link>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||||
|
<FormField label={s.email} error={errors.email}>
|
||||||
|
{(f) => (
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={s.password} error={errors.password}>
|
||||||
|
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="remember_me"
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(v) => field.onChange(v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium">{s.remember}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
className="h-11 rounded-md border bg-background px-3"
|
<FormError message={errors.root?.message} />
|
||||||
type="password"
|
|
||||||
placeholder="Şifre"
|
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||||
autoComplete="current-password"
|
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||||
value={password}
|
{isSubmitting ? s.submitting : s.submit}
|
||||||
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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<Link to={paths.register} className="text-center text-sm text-muted-foreground underline">
|
</AuthShell>
|
||||||
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,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<RegisterForm>({
|
||||||
|
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 (
|
||||||
|
<AuthShell
|
||||||
|
title={s.title}
|
||||||
|
description={s.description}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{s.hasAccount}{' '}
|
||||||
|
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
{s.toLogin}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||||
|
<FormField label={s.name} error={errors.name}>
|
||||||
|
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={s.email} error={errors.email}>
|
||||||
|
{(f) => (
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
|
||||||
|
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{/* Yeni hane mi, davetle katılma mı? */}
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div role="radiogroup" className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['new', s.modeNew, Home],
|
||||||
|
['join', s.modeJoin, Users],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={field.value === value}
|
||||||
|
onClick={() => field.onChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
||||||
|
field.value === value && 'bg-background text-foreground shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'new' ? (
|
||||||
|
<FormField label={s.householdName} error={errors.household_name} hint={s.householdHint(name)}>
|
||||||
|
{(f) => <Input {...f} {...register('household_name')} placeholder={name ? `${name} Hanesi` : ''} />}
|
||||||
|
</FormField>
|
||||||
|
) : (
|
||||||
|
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
|
||||||
|
{(f) => (
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
{...register('invite_code')}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={12}
|
||||||
|
className="font-mono tracking-widest uppercase"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||||
|
{isSubmitting ? s.submitting : s.submit}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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<typeof loginSchema>
|
||||||
|
|
||||||
|
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<typeof registerSchema>
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
@@ -45,6 +45,55 @@ export const t = {
|
|||||||
theme: 'Tema',
|
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: {
|
common: {
|
||||||
loading: 'Yükleniyor…',
|
loading: 'Yükleniyor…',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { RouterProvider } from 'react-router'
|
import { RouterProvider } from 'react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { queryClient } from '@/lib/query'
|
import { queryClient } from '@/lib/query'
|
||||||
|
import { Toaster } from '@/components/toaster'
|
||||||
import { ThemeProvider } from '@/lib/theme'
|
import { ThemeProvider } from '@/lib/theme'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createBrowserRouter, type RouteObject } from 'react-router'
|
|||||||
import { AppLayout } from '@/components/layout/app-layout'
|
import { AppLayout } from '@/components/layout/app-layout'
|
||||||
import { paths } from '@/components/layout/nav-items'
|
import { paths } from '@/components/layout/nav-items'
|
||||||
import { RequireAuth, RequireGuest } from '@/components/layout/require-auth'
|
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 { DashboardPage } from '@/features/dashboard/page'
|
||||||
import { DesignPreview } from '@/features/dev/design-preview'
|
import { DesignPreview } from '@/features/dev/design-preview'
|
||||||
import { NotFoundPage } from '@/features/errors/not-found-page'
|
import { NotFoundPage } from '@/features/errors/not-found-page'
|
||||||
|
|||||||
Reference in New Issue
Block a user