Compare commits
4 Commits
f9be08114d
...
9cbeaaaf76
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cbeaaaf76 | |||
| b269411823 | |||
| c6d4c30461 | |||
| 906f05f62f |
@@ -57,12 +57,19 @@ npm run db:seed
|
||||
npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler
|
||||
npm test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest)
|
||||
npm run lint # clippy + oxlint
|
||||
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
|
||||
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
|
||||
```
|
||||
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
|
||||
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
||||
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları.
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
|
||||
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
|
||||
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<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.
|
||||
- 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_<uuid>` DB, `tests/common` yardımcıları).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# ts-rs: Rust DTO'larından üretilen TypeScript tipleri buraya yazılır (npm run types)
|
||||
[env]
|
||||
TS_RS_EXPORT_DIR = { value = "../frontend/src/types", relative = true }
|
||||
Generated
+32
@@ -1298,6 +1298,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
@@ -2429,6 +2430,15 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
@@ -2656,6 +2666,28 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"ts-rs-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-builder"
|
||||
version = "0.22.0"
|
||||
|
||||
@@ -17,6 +17,7 @@ time = "0.3.55"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
ts-rs = "12.0.1"
|
||||
uuid = { version = "1.29.2", features = ["v4", "v7"] }
|
||||
validator = { version = "0.21.0", features = ["derive"] }
|
||||
|
||||
|
||||
+10
-3
@@ -1,5 +1,10 @@
|
||||
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
|
||||
### Satırın üstündeki "Send Request" bağlantısına tıklayın.
|
||||
### ┌──────────────────────────────────────────────────────────────────────┐
|
||||
### │ GİRİŞ İÇİN HAZIR HESAPLAR (yalnızca yerel geliştirme veritabanı): │
|
||||
### │ demo@ornek.com / demo12345 → hane sahibi (Demo Hanesi) │
|
||||
### │ es@ornek.com / demo12345 → üye (aynı hane) │
|
||||
### └──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
@baseUrl = http://localhost:8080/api
|
||||
|
||||
@@ -9,13 +14,15 @@ GET {{baseUrl}}/health
|
||||
# ─────────────────────────── Kimlik doğrulama ───────────────────────────
|
||||
# REST Client cookie'leri otomatik saklar: kayıt/giriş sonrası diğer istekler oturumlu gider.
|
||||
|
||||
### Kayıt (kullanıcı + hane + örnek kategoriler/ödeme türleri oluşur)
|
||||
### KAYIT ÖRNEĞİ — bu bir hesap DEĞİL, hesap OLUŞTURMA isteğidir.
|
||||
### Hesap ancak bu isteği gönderince oluşur (kullanıcı + hane + örnek kategoriler/ödeme türleri).
|
||||
### Aynı e-postayla ikinci kez gönderirseniz 409 "zaten kayıtlı" alırsınız.
|
||||
POST {{baseUrl}}/auth/register
|
||||
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 +44,7 @@ POST {{baseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "suphi@ornek.com",
|
||||
"email": "demo@ornek.com",
|
||||
"password": "gizli-sifre-123",
|
||||
"remember_me": true
|
||||
}
|
||||
|
||||
+25
-6
@@ -6,7 +6,24 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ApiErrorBody {
|
||||
pub error: ApiErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ApiErrorDetail {
|
||||
/// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
|
||||
pub code: String,
|
||||
/// Kullanıcıya gösterilecek Türkçe mesaj
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
@@ -59,11 +76,13 @@ impl IntoResponse for AppError {
|
||||
self.to_string()
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
let body = ApiErrorBody {
|
||||
error: ApiErrorDetail {
|
||||
code: code.into(),
|
||||
message,
|
||||
},
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum Group {
|
||||
/// 🏠 Zorunlu
|
||||
Needs,
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum Role {
|
||||
Owner,
|
||||
Member,
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{Json, extract::State, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use mongodb::bson::{Uuid, doc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
@@ -26,7 +27,8 @@ use crate::{
|
||||
|
||||
// ---------- İstek / yanıt yapıları ----------
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, optional_fields)]
|
||||
pub struct RegisterRequest {
|
||||
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
||||
name: String,
|
||||
@@ -41,7 +43,8 @@ pub struct RegisterRequest {
|
||||
invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(length(min = 1, message = "E-posta gerekli"))]
|
||||
email: String,
|
||||
@@ -51,13 +54,15 @@ pub struct LoginRequest {
|
||||
remember_me: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct MeResponse {
|
||||
user: UserDto,
|
||||
household: HouseholdDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "User")]
|
||||
struct UserDto {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -65,7 +70,8 @@ struct UserDto {
|
||||
role: Role,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "HouseholdSummary")]
|
||||
struct HouseholdDto {
|
||||
id: String,
|
||||
name: String,
|
||||
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
@@ -35,7 +36,8 @@ pub struct ListQuery {
|
||||
include_inactive: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "CatalogCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
group: Option<Group>,
|
||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||
@@ -44,7 +46,8 @@ pub struct CreateRequest {
|
||||
emoji: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "CatalogUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||
name: Option<String>,
|
||||
@@ -54,17 +57,20 @@ pub struct UpdateRequest {
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "CatalogOrderRequest", optional_fields)]
|
||||
pub struct OrderRequest {
|
||||
group: Option<Group>,
|
||||
#[validate(length(max = 200, message = "Liste çok uzun"))]
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "CatalogItem")]
|
||||
pub struct ItemDto {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
group: Option<Group>,
|
||||
name: String,
|
||||
emoji: Option<String>,
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{db, state::AppState};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct HealthResponse {
|
||||
/// `ok` veya `degraded`
|
||||
status: &'static str,
|
||||
/// `ok` veya `error`
|
||||
db: &'static str,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
/// Sağlık kontrolü (Dokploy health check bunu kullanır).
|
||||
/// DB erişilemezse 503 döner.
|
||||
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
|
||||
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
match db::ping(&state.db).await {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({ "status": "ok", "db": "ok", "version": version })),
|
||||
Json(HealthResponse {
|
||||
status: "ok",
|
||||
db: "ok",
|
||||
version,
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "status": "degraded", "db": "error", "version": version })),
|
||||
Json(HealthResponse {
|
||||
status: "degraded",
|
||||
db: "error",
|
||||
version,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::{
|
||||
};
|
||||
use mongodb::bson::{DateTime, Uuid, doc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
@@ -21,14 +22,16 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Household")]
|
||||
pub struct HouseholdResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
members: Vec<MemberDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Member")]
|
||||
struct MemberDto {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -36,7 +39,8 @@ struct MemberDto {
|
||||
role: Role,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Invite")]
|
||||
pub struct InviteDto {
|
||||
id: String,
|
||||
code: String,
|
||||
|
||||
+13
-2
@@ -1,9 +1,10 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 2 – Adım 3:** `lib/api.ts` (fetch sarmalayıcı, `{error:{code,message}}` → `ApiError`), TanStack Query kurulumu, ts-rs ile Rust DTO → `frontend/src/types/` tip üretimi, oturum durumu (`useMe` / auth context).
|
||||
**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.
|
||||
@@ -23,7 +24,14 @@
|
||||
- Tema seçimi localStorage (`kakeibo-theme`), yalnızca cihaz kolaylığı; ileride `users.preferences.theme` ile senkron olabilir.
|
||||
- `parseMoney`: virgül varsa ondalık; virgül yoksa son noktadan sonra 1–2 rakam → ondalık ("1.25" = 1,25 ₺; "1.250" = 1250 ₺).
|
||||
- Logo: Taslak A (indigo zemin, spiral krem defter, ¥) seçildi. Kaynak `frontend/brand/logo.svg` + `logo-fullbleed.svg` (maskable/Apple, içerik %82). Uygulama içi `components/logo.tsx` aynı çizim (elle senkron tutulmalı). Manifest Faz 10'da (vite-plugin-pwa).
|
||||
- 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.
|
||||
- ts-rs 12: `backend/.cargo/config.toml` → `TS_RS_EXPORT_DIR=../frontend/src/types`. TS adları: User, HouseholdSummary, Household, Member, Invite, CatalogItem, Catalog{Create,Update,Order}Request, RegisterRequest, LoginRequest, MeResponse, HealthResponse, ApiErrorBody, Group, Role.
|
||||
- QueryClient: 4xx tekrar denenmez; herhangi bir sorgu 401 alırsa `me` = null. Çıkışta `qc.clear()`.
|
||||
- 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
|
||||
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
|
||||
@@ -51,3 +59,6 @@
|
||||
### 2026-11-12 — Faz 2
|
||||
- **Adım 1:** Tailwind v4 (@tailwindcss/vite), shadcn init (radix, preset nova; `cn` paketi = shadcn'in clsx+tailwind-merge yerine geçeni, incelendi), Inter (@fontsource-variable, çevrimdışı). `index.css`: grup + durum token'ları, odak halkası, `money` utility, reduced-motion. `lib/format.ts` (+22 vitest), `lib/theme.tsx`, `lib/groups.ts`, `components/theme-toggle.tsx`. Butonlar 44/48 px'e büyütüldü. Geçici önizleme ekranı (App.tsx), 375 px headless ekran görüntüsüyle iki temada kontrol edildi.
|
||||
- **Adım 2:** 3 logo taslağı (A indigo defter, B açık defter + para, C hanko) 512/64/32/16 px önizlemeyle sunuldu; kullanıcı A'yı seçti. `scripts/icons.sh` (`npm run icons`): favicon.svg/.ico, icon-192/512, icon-maskable-512, apple-touch-icon (180), brand/icon-1024 (Tauri). Maskable güvenli alan görsel olarak doğrulandı. `index.html`: ikon linkleri, theme-color, description.
|
||||
- **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti.
|
||||
- **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi.
|
||||
- **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ı.**
|
||||
|
||||
+5
-5
@@ -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ü | ⏳ |
|
||||
@@ -34,6 +34,6 @@
|
||||
## Faz 2 Adımları
|
||||
- ✅ 1. Tailwind v4 + shadcn/ui (radix, nova), tasarım token'ları (4 grup + durum renkleri, AA doğrulandı), Inter, tema (aydınlık/karanlık/sistem), `lib/format.ts` + Vitest
|
||||
- ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
|
||||
- 🔄 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
||||
- ⏳ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
||||
- ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
||||
- ✅ 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
|
||||
|
||||
Generated
+231
-1
@@ -9,16 +9,22 @@
|
||||
"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",
|
||||
"cn": "^0.5.2",
|
||||
"lucide-react": "^1.72.0",
|
||||
"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",
|
||||
@@ -696,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",
|
||||
@@ -2681,6 +2793,12 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/route-pattern": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
|
||||
"integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.19.tgz",
|
||||
@@ -3027,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",
|
||||
@@ -3356,6 +3480,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",
|
||||
@@ -4192,6 +4348,12 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-es": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5938,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",
|
||||
@@ -5985,6 +6169,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz",
|
||||
"integrity": "sha512-5u1eQ46QjuXjNfEnLwzrnou1Bm5dquO3xjVYkkMYCudtkicD2AQHtx+fvXzPATtPJx8TW6DR1x1TDRqsfHyZBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/route-pattern": "^0.22.1",
|
||||
"cookie-es": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=19.2.0",
|
||||
"@types/react-dom": ">=19.2.0",
|
||||
"react": ">=19.2.7",
|
||||
"react-dom": ">=19.2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -6278,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",
|
||||
|
||||
@@ -13,16 +13,22 @@
|
||||
},
|
||||
"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",
|
||||
"cn": "^0.5.2",
|
||||
"lucide-react": "^1.72.0",
|
||||
"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",
|
||||
|
||||
@@ -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,142 @@
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { bottomNav, moreNav, paths, sideNav } from './nav-items'
|
||||
|
||||
/**
|
||||
* Oturum açılmış sayfaların iskeleti.
|
||||
* - Mobil (< md): üstte logo, altta 5 öğeli sabit menü
|
||||
* - Masaüstü (≥ md): solda kenar çubuğu
|
||||
*/
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar />
|
||||
|
||||
{/* Mobil üst çubuk */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
|
||||
<Logo size={28} withText />
|
||||
</header>
|
||||
|
||||
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
||||
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
||||
<div className="md:pl-64">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
<ScrollRestoration />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<Logo size={32} withText />
|
||||
</div>
|
||||
<nav aria-label={t.nav.main} className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||
{sideNav.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
||||
isActive && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="space-y-3 border-t p-4">
|
||||
<AccountSummary />
|
||||
<ThemeToggle compact className="w-full" />
|
||||
<LogoutButton className="w-full justify-start" />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function BottomNav() {
|
||||
const { pathname } = useLocation()
|
||||
// "Diğer" altındaki sayfalardayken de "Diğer" seçili görünsün
|
||||
const inMore = moreNav.some((i) => pathname.startsWith(i.to))
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
>
|
||||
<ul className="grid grid-cols-5">
|
||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-16 flex-col items-center justify-center gap-1 text-xs font-medium text-muted-foreground transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-14 items-center justify-center rounded-full transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSummary() {
|
||||
const { data: me } = useMe()
|
||||
if (!me) return null
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{me.user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{me.household.name} · {me.user.role === 'owner' ? t.account.owner : t.account.member}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoutButton({ className }: { className?: string }) {
|
||||
const logout = useLogout()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
<LogOut /> {t.account.logout}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CalendarDays,
|
||||
ChartLine,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
NotebookPen,
|
||||
PiggyBank,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export type NavItem = { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
/** Sayfa adresleri tek yerde */
|
||||
export const paths = {
|
||||
dashboard: '/',
|
||||
ledger: '/defter',
|
||||
planning: '/plan',
|
||||
savings: '/birikim',
|
||||
more: '/diger',
|
||||
review: '/degerlendirme',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
login: '/giris',
|
||||
register: '/kayit',
|
||||
} as const
|
||||
|
||||
const dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard }
|
||||
const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen }
|
||||
const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays }
|
||||
const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank }
|
||||
const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck }
|
||||
const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine }
|
||||
const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings }
|
||||
|
||||
/** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */
|
||||
export const bottomNav: NavItem[] = [
|
||||
dashboard,
|
||||
ledger,
|
||||
planning,
|
||||
savings,
|
||||
{ to: paths.more, label: t.nav.more, icon: Menu },
|
||||
]
|
||||
|
||||
/** "Diğer" altında toplananlar (mobil) */
|
||||
export const moreNav: NavItem[] = [review, reports, settings]
|
||||
|
||||
/** Masaüstü sol menü: hepsi doğrudan */
|
||||
export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings]
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { safeReturnPath } from '@/lib/return-path'
|
||||
import { t } from '@/lib/strings'
|
||||
import { paths } from './nav-items'
|
||||
|
||||
/** Oturum kontrol edilirken gösterilen tam ekran açılış. */
|
||||
export function Splash() {
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center" role="status" aria-label={t.common.loading}>
|
||||
<Logo size={56} className="animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
|
||||
export function RequireAuth() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (me.isPending) return <Splash />
|
||||
if (!me.data) {
|
||||
const back = location.pathname + location.search
|
||||
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
|
||||
return <Navigate to={to} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
/**
|
||||
* Yalnızca oturumsuzken: giriş/kayıt.
|
||||
* Oturum açılınca (giriş/kayıt başarılı olduğunda da) `?donus=` adresine, yoksa panele gönderir.
|
||||
* Yönlendirme yalnızca burada yapılır — formlar ayrıca `navigate` çağırmaz (yarış durumu olmasın).
|
||||
*/
|
||||
export function RequireGuest() {
|
||||
const me = useMe()
|
||||
const [params] = useSearchParams()
|
||||
if (me.isPending) return <Splash />
|
||||
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Construction } from 'lucide-react'
|
||||
import { t } from '@/lib/strings'
|
||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||
|
||||
/** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
useDocumentTitle(title)
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
|
||||
{description && <p className="mt-1 text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */
|
||||
export function ComingSoon({ when }: { when: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
|
||||
<Construction className="size-8" aria-hidden />
|
||||
<p>{t.comingSoon(when)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,11 @@ const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'system', label: 'Sistem', icon: Monitor },
|
||||
]
|
||||
|
||||
/** Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir). */
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
/**
|
||||
* Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir).
|
||||
* `compact`: yalnızca ikonlar (dar alanlar için; etiketler ekran okuyucuda kalır).
|
||||
*/
|
||||
export function ThemeToggle({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div
|
||||
@@ -23,14 +26,16 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
title={label}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors',
|
||||
compact && 'flex-1 px-0',
|
||||
theme === value && 'bg-background text-foreground shadow-sm',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
<span>{label}</span>
|
||||
<span className={compact ? 'sr-only' : undefined}>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { 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 { t } from '@/lib/strings'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import { loginSchema, type LoginForm } from './schemas'
|
||||
|
||||
const s = t.auth.login
|
||||
|
||||
export function LoginPage() {
|
||||
const login = useLogin()
|
||||
const [params] = useSearchParams()
|
||||
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 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 (
|
||||
<AuthShell
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
footer={
|
||||
<>
|
||||
{s.noAccount}{' '}
|
||||
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
{s.toRegister}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
||||
<ComingSoon when="Faz 7'de" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format'
|
||||
import { api } from '@/lib/api'
|
||||
import { GROUPS } from '@/lib/groups'
|
||||
import { queryKeys } from '@/lib/query'
|
||||
import type { HealthResponse } from '@/types/HealthResponse'
|
||||
|
||||
type Health = { status: string; db: string; version: string }
|
||||
|
||||
// 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)
|
||||
/** Tasarım sistemi önizlemesi — yalnızca geliştirmede: /dev/tasarim */
|
||||
export function DesignPreview() {
|
||||
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 +31,16 @@ 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>
|
||||
|
||||
|
||||
<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">
|
||||
@@ -0,0 +1,23 @@
|
||||
import { House } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageHeader } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="mb-4 text-6xl" aria-hidden>
|
||||
🧭
|
||||
</p>
|
||||
<PageHeader title={t.pages.notFound.title} />
|
||||
<p className="-mt-4 mb-6 text-muted-foreground">{t.pages.notFound.description}</p>
|
||||
<Button asChild>
|
||||
<Link to={paths.dashboard}>
|
||||
<House /> {t.pages.notFound.action}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function LedgerPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
|
||||
<ComingSoon when="Faz 5'te" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { AccountSummary, LogoutButton } from '@/components/layout/app-layout'
|
||||
import { moreNav } from '@/components/layout/nav-items'
|
||||
import { PageHeader } from '@/components/page'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
/** Mobil "☰ Diğer": alt menüye sığmayan sayfalar, tema ve hesap. */
|
||||
export function MorePage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.more.title} />
|
||||
|
||||
<nav className="divide-y overflow-hidden rounded-xl border bg-card">
|
||||
{moreNav.map(({ to, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="flex min-h-14 items-center gap-3 px-4 transition-colors hover:bg-muted"
|
||||
>
|
||||
<Icon className="size-5 text-muted-foreground" aria-hidden />
|
||||
<span className="flex-1 font-medium">{label}</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="mt-6 space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{t.account.theme}</h2>
|
||||
<ThemeToggle className="w-full [&>button]:flex-1" />
|
||||
</section>
|
||||
|
||||
<section className="mt-6 flex items-center justify-between gap-3 rounded-xl border bg-card p-4">
|
||||
<AccountSummary />
|
||||
<LogoutButton />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function PlanningPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
||||
<ComingSoon when="Faz 4'te" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function ReportsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} />
|
||||
<ComingSoon when="Faz 9'da" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function ReviewPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.review.title} description={t.pages.review.description} />
|
||||
<ComingSoon when="Faz 6'da" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function SavingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.savings.title} description={t.pages.savings.description} />
|
||||
<ComingSoon when="Faz 8'de" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
||||
<ComingSoon when="ilerleyen fazlarda" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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: () => {
|
||||
// Önce oturumu kapat (korumalı sayfalar → giriş ekranı), sonra diğer verileri sil:
|
||||
// başka kullanıcının verisi önbellekte kalmasın.
|
||||
// Not: qc.clear() kullanılmaz — açık ekranlar silinen sorguya bağlı kalıp güncellenmez.
|
||||
qc.setQueryData(queryKeys.me, null)
|
||||
qc.removeQueries({ predicate: (q) => q.queryKey[0] !== queryKeys.me[0] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeReturnPath } from './return-path'
|
||||
|
||||
describe('safeReturnPath', () => {
|
||||
it.each(['/defter', '/plan?ay=12', '/'])('uygulama içi yol kabul: %s', (p) => {
|
||||
expect(safeReturnPath(p)).toBe(p)
|
||||
})
|
||||
|
||||
it.each([null, '', 'defter', '//kotu.com', '/\\kotu.com', 'https://kotu.com', 'javascript:alert(1)'])(
|
||||
'güvensiz → panele: %s',
|
||||
(p) => {
|
||||
expect(safeReturnPath(p)).toBe('/')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Girişten sonra dönülecek yol (`/giris?donus=...`).
|
||||
* Yalnızca uygulama içi yollar kabul edilir — açık yönlendirme (open redirect) saldırısını önler:
|
||||
* `?donus=//kotu-site.com` veya `?donus=https://...` → panele gider.
|
||||
*/
|
||||
export function safeReturnPath(value: string | null, fallback = '/'): string {
|
||||
if (!value || !value.startsWith('/')) return fallback
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Arayüz metinleri tek yerde (KAKEIBO_PROMPT §15 — ileride çok dil desteği için).
|
||||
* Yeni ekran eklerken metinleri buraya, ilgili bölümün altına ekleyin.
|
||||
*/
|
||||
export const t = {
|
||||
app: {
|
||||
name: 'Kakeibo',
|
||||
tagline: 'Kişisel Ev Finans Defteri',
|
||||
},
|
||||
|
||||
nav: {
|
||||
dashboard: 'Panel',
|
||||
ledger: 'Defter',
|
||||
planning: 'Plan',
|
||||
savings: 'Birikim',
|
||||
more: 'Diğer',
|
||||
review: 'Değerlendirme',
|
||||
reports: 'Raporlar',
|
||||
settings: 'Ayarlar',
|
||||
main: 'Ana menü',
|
||||
},
|
||||
|
||||
pages: {
|
||||
dashboard: { title: 'Panel', description: 'Gelir, harcama ve birikimin tek bakışta özeti.' },
|
||||
ledger: { title: 'Günlük Defter', description: 'Haftalık harcama girişleri.' },
|
||||
planning: { title: 'Aylık Plan', description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.' },
|
||||
savings: { title: 'Birikimler', description: 'TL, altın ve döviz varlıkların, kâr/zarar.' },
|
||||
review: { title: 'Ay Sonu Değerlendirmesi', description: "Kakeibo'nun dört sorusu ve dönem kapanışı." },
|
||||
reports: { title: 'Raporlar', description: 'Gelir–gider, kategori ve bütçe sapma raporları.' },
|
||||
settings: { title: 'Ayarlar', description: 'Kategoriler, ödeme türleri, hane ve hesap.' },
|
||||
more: { title: 'Diğer' },
|
||||
notFound: {
|
||||
title: 'Sayfa bulunamadı',
|
||||
description: 'Aradığınız sayfa taşınmış ya da hiç var olmamış olabilir.',
|
||||
action: 'Panele dön',
|
||||
},
|
||||
},
|
||||
|
||||
comingSoon: (when: string) => `Bu ekran ${when} geliştirilecek.`,
|
||||
|
||||
account: {
|
||||
owner: 'Hane sahibi',
|
||||
member: 'Üye',
|
||||
logout: 'Çıkış yap',
|
||||
theme: 'Tema',
|
||||
},
|
||||
|
||||
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…',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
import { t } from './strings'
|
||||
|
||||
/** Tarayıcı sekmesi başlığı: "Defter · Kakeibo" */
|
||||
export function useDocumentTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} · ${t.app.name}` : t.app.name
|
||||
}, [title])
|
||||
}
|
||||
+11
-4
@@ -1,13 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from 'react-router'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { queryClient } from '@/lib/query'
|
||||
import { Toaster } from '@/components/toaster'
|
||||
import { ThemeProvider } from '@/lib/theme'
|
||||
import { router } from './router'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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 } 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'
|
||||
import { LedgerPage } from '@/features/ledger/page'
|
||||
import { MorePage } from '@/features/more/page'
|
||||
import { PlanningPage } from '@/features/planning/page'
|
||||
import { ReportsPage } from '@/features/reports/page'
|
||||
import { ReviewPage } from '@/features/review/page'
|
||||
import { SavingsPage } from '@/features/savings/page'
|
||||
import { SettingsPage } from '@/features/settings/page'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
// Oturumsuz
|
||||
{
|
||||
element: <RequireGuest />,
|
||||
children: [
|
||||
{ path: paths.login, element: <LoginPage /> },
|
||||
{ path: paths.register, element: <RegisterPage /> },
|
||||
],
|
||||
},
|
||||
// Oturumlu (iskelet: kenar çubuğu / alt menü)
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: paths.ledger, element: <LedgerPage /> },
|
||||
{ path: paths.planning, element: <PlanningPage /> },
|
||||
{ path: paths.savings, element: <SavingsPage /> },
|
||||
{ path: paths.review, element: <ReviewPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ path: paths.settings, element: <SettingsPage /> },
|
||||
{ path: paths.more, element: <MorePage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Tasarım önizlemesi yalnızca geliştirmede (canlı derlemeye girmez)
|
||||
if (import.meta.env.DEV) {
|
||||
routes.unshift({ path: '/dev/tasarim', element: <DesignPreview /> })
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter(routes)
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"db:reset": "docker exec kakeibo-mongo mongosh --quiet --eval \"db.getSiblingDB('kakeibo_dev').dropDatabase(); db.adminCommand({listDatabases:1}).databases.filter(d => d.name.startsWith('kakeibo_test_')).forEach(d => db.getSiblingDB(d.name).dropDatabase()); print('kakeibo_dev ve test veritabanları sıfırlandı.')\"",
|
||||
"test": "cd backend && cargo test && cd .. && npm --prefix frontend test",
|
||||
"lint": "cd backend && cargo clippy && cd .. && npm --prefix frontend run lint",
|
||||
"icons": "bash scripts/icons.sh"
|
||||
"icons": "bash scripts/icons.sh",
|
||||
"types": "cd backend && cargo test -q export_bindings"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.7"
|
||||
|
||||
Reference in New Issue
Block a user