feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu
- Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types) - Hata ve health yanıtları tipli yapılara çevrildi - lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı - lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır) - lib/auth.ts: useMe, useLogin, useRegister, useLogout - Vitest: 7 API istemcisi testi (toplam 29) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,12 +57,15 @@ npm run db:seed
|
|||||||
npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler
|
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 test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest)
|
||||||
npm run lint # clippy + oxlint
|
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 icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
|
||||||
```
|
```
|
||||||
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
|
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
|
||||||
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
- 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 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ü).
|
- 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`.
|
||||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||||
- API örnekleri: `backend/api.http`
|
- API örnekleri: `backend/api.http`
|
||||||
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
||||||
|
|||||||
@@ -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",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
@@ -2429,6 +2430,15 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.21"
|
version = "2.0.21"
|
||||||
@@ -2656,6 +2666,28 @@ dependencies = [
|
|||||||
"tracing-log",
|
"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]]
|
[[package]]
|
||||||
name = "typed-builder"
|
name = "typed-builder"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ time = "0.3.55"
|
|||||||
tokio = { version = "1.54.1", features = ["full"] }
|
tokio = { version = "1.54.1", features = ["full"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
ts-rs = "12.0.1"
|
||||||
uuid = { version = "1.29.2", features = ["v4", "v7"] }
|
uuid = { version = "1.29.2", features = ["v4", "v7"] }
|
||||||
validator = { version = "0.21.0", features = ["derive"] }
|
validator = { version = "0.21.0", features = ["derive"] }
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -6,7 +6,24 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
@@ -59,11 +76,13 @@ impl IntoResponse for AppError {
|
|||||||
self.to_string()
|
self.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
let body = ApiErrorBody {
|
||||||
status,
|
error: ApiErrorDetail {
|
||||||
Json(json!({ "error": { "code": code, "message": message } })),
|
code: code.into(),
|
||||||
)
|
message,
|
||||||
.into_response()
|
},
|
||||||
|
};
|
||||||
|
(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.
|
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
pub enum Group {
|
pub enum Group {
|
||||||
/// 🏠 Zorunlu
|
/// 🏠 Zorunlu
|
||||||
Needs,
|
Needs,
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
use mongodb::bson::Uuid;
|
use mongodb::bson::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
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")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
Owner,
|
Owner,
|
||||||
Member,
|
Member,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use axum::{Json, extract::State, http::StatusCode};
|
|||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use mongodb::bson::{Uuid, doc};
|
use mongodb::bson::{Uuid, doc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -26,7 +27,8 @@ use crate::{
|
|||||||
|
|
||||||
// ---------- İstek / yanıt yapıları ----------
|
// ---------- İstek / yanıt yapıları ----------
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, optional_fields)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
||||||
name: String,
|
name: String,
|
||||||
@@ -41,7 +43,8 @@ pub struct RegisterRequest {
|
|||||||
invite_code: Option<String>,
|
invite_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
#[validate(length(min = 1, message = "E-posta gerekli"))]
|
#[validate(length(min = 1, message = "E-posta gerekli"))]
|
||||||
email: String,
|
email: String,
|
||||||
@@ -51,13 +54,15 @@ pub struct LoginRequest {
|
|||||||
remember_me: bool,
|
remember_me: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
pub struct MeResponse {
|
pub struct MeResponse {
|
||||||
user: UserDto,
|
user: UserDto,
|
||||||
household: HouseholdDto,
|
household: HouseholdDto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "User")]
|
||||||
struct UserDto {
|
struct UserDto {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -65,7 +70,8 @@ struct UserDto {
|
|||||||
role: Role,
|
role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "HouseholdSummary")]
|
||||||
struct HouseholdDto {
|
struct HouseholdDto {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use mongodb::bson::Uuid;
|
use mongodb::bson::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -35,7 +36,8 @@ pub struct ListQuery {
|
|||||||
include_inactive: bool,
|
include_inactive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "CatalogCreateRequest", optional_fields)]
|
||||||
pub struct CreateRequest {
|
pub struct CreateRequest {
|
||||||
group: Option<Group>,
|
group: Option<Group>,
|
||||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||||
@@ -44,7 +46,8 @@ pub struct CreateRequest {
|
|||||||
emoji: Option<String>,
|
emoji: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "CatalogUpdateRequest", optional_fields)]
|
||||||
pub struct UpdateRequest {
|
pub struct UpdateRequest {
|
||||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -54,17 +57,20 @@ pub struct UpdateRequest {
|
|||||||
version: Option<i32>,
|
version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "CatalogOrderRequest", optional_fields)]
|
||||||
pub struct OrderRequest {
|
pub struct OrderRequest {
|
||||||
group: Option<Group>,
|
group: Option<Group>,
|
||||||
#[validate(length(max = 200, message = "Liste çok uzun"))]
|
#[validate(length(max = 200, message = "Liste çok uzun"))]
|
||||||
ids: Vec<String>,
|
ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "CatalogItem")]
|
||||||
pub struct ItemDto {
|
pub struct ItemDto {
|
||||||
id: String,
|
id: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
group: Option<Group>,
|
group: Option<Group>,
|
||||||
name: String,
|
name: String,
|
||||||
emoji: Option<String>,
|
emoji: Option<String>,
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use serde_json::{Value, json};
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::{db, state::AppState};
|
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).
|
/// Sağlık kontrolü (Dokploy health check bunu kullanır).
|
||||||
/// DB erişilemezse 503 döner.
|
/// 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");
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
match db::ping(&state.db).await {
|
match db::ping(&state.db).await {
|
||||||
Ok(()) => (
|
Ok(()) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(json!({ "status": "ok", "db": "ok", "version": version })),
|
Json(HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
db: "ok",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
||||||
(
|
(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
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 mongodb::bson::{DateTime, Uuid, doc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthUser,
|
auth::AuthUser,
|
||||||
@@ -21,14 +22,16 @@ use crate::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Household")]
|
||||||
pub struct HouseholdResponse {
|
pub struct HouseholdResponse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
members: Vec<MemberDto>,
|
members: Vec<MemberDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Member")]
|
||||||
struct MemberDto {
|
struct MemberDto {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -36,7 +39,8 @@ struct MemberDto {
|
|||||||
role: Role,
|
role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Invite")]
|
||||||
pub struct InviteDto {
|
pub struct InviteDto {
|
||||||
id: String,
|
id: String,
|
||||||
code: String,
|
code: String,
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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 2 – Adım 4:** react-router kurulumu, korumalı sayfalar (useMe null → /giris), layout: mobilde alt menü (📊 Panel, ✍️ Defter, 🗓️ Plan, 🐷 Birikim, ☰ Diğer), masaüstünde sol kenar çubuğu, boş sayfa iskeletleri, App.tsx önizlemesi kaldırılır (tasarım önizlemesi isterse /dev sayfası).
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
- Tema seçimi localStorage (`kakeibo-theme`), yalnızca cihaz kolaylığı; ileride `users.preferences.theme` ile senkron olabilir.
|
- 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 ₺).
|
- `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).
|
- 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).
|
||||||
|
- 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()`.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
## ❓ Açık Konular
|
## ❓ Açık Konular
|
||||||
@@ -51,3 +53,4 @@
|
|||||||
### 2026-11-12 — Faz 2
|
### 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 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 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.
|
||||||
|
|||||||
+2
-2
@@ -34,6 +34,6 @@
|
|||||||
## Faz 2 Adımları
|
## 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
|
- ✅ 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ı
|
- ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
|
||||||
- 🔄 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
||||||
- ⏳ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
- 🔄 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
||||||
- ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
- ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
||||||
|
|||||||
Generated
+33
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@tailwindcss/vite": "^4.3.5",
|
"@tailwindcss/vite": "^4.3.5",
|
||||||
|
"@tanstack/react-query": "^5.107.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.2",
|
"cn": "^0.5.2",
|
||||||
"lucide-react": "^1.72.0",
|
"lucide-react": "^1.72.0",
|
||||||
@@ -3356,6 +3357,38 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@tailwindcss/vite": "^4.3.5",
|
"@tailwindcss/vite": "^4.3.5",
|
||||||
|
"@tanstack/react-query": "^5.107.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.2",
|
"cn": "^0.5.2",
|
||||||
"lucide-react": "^1.72.0",
|
"lucide-react": "^1.72.0",
|
||||||
|
|||||||
+73
-17
@@ -1,27 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { LogIn, LogOut, Plus } from 'lucide-react'
|
||||||
import { Logo } from '@/components/logo'
|
import { Logo } from '@/components/logo'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format'
|
import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useLogin, useLogout, useMe } from '@/lib/auth'
|
||||||
import { GROUPS } from '@/lib/groups'
|
import { GROUPS } from '@/lib/groups'
|
||||||
|
import { queryKeys } from '@/lib/query'
|
||||||
type Health = { status: string; db: string; version: string }
|
import type { HealthResponse } from '@/types/HealthResponse'
|
||||||
|
|
||||||
// Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi.
|
// Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi.
|
||||||
// Adım 4'te gerçek layout ve sayfalarla değiştirilecek.
|
// Adım 4'te gerçek layout ve sayfalarla değiştirilecek.
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [health, setHealth] = useState<Health | null>(null)
|
const health = useQuery({
|
||||||
const [error, setError] = useState(false)
|
queryKey: queryKeys.health,
|
||||||
|
queryFn: () => api.get<HealthResponse>('/health'),
|
||||||
|
})
|
||||||
const [today] = useState(() => toIsoDate(new Date()))
|
const [today] = useState(() => toIsoDate(new Date()))
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/health')
|
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
|
||||||
.then(setHealth)
|
|
||||||
.catch(() => setError(true))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8">
|
<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">
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
@@ -35,15 +33,17 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{error && <span className="text-expense">● Backend'e ulaşılamadı</span>}
|
{health.isError && <span className="text-expense">● {health.error.message}</span>}
|
||||||
{!error && !health && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
|
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
|
||||||
{health && (
|
{health.data && (
|
||||||
<span className="text-income">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SessionPanel />
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-xl font-semibold">Ana gruplar</h2>
|
<h2 className="text-xl font-semibold">Ana gruplar</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
@@ -102,3 +102,59 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adım 3 geçici paneli: oturum akışını uçtan uca dener. Adım 5'te gerçek giriş ekranı gelir. */
|
||||||
|
function SessionPanel() {
|
||||||
|
const me = useMe()
|
||||||
|
const login = useLogin()
|
||||||
|
const logout = useLogout()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
login.mutate({ email, password, remember_me: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3 rounded-xl border bg-card p-4">
|
||||||
|
<h2 className="text-xl font-semibold">Oturum (geçici test paneli)</h2>
|
||||||
|
{me.isPending && <p className="text-muted-foreground">Oturum kontrol ediliyor…</p>}
|
||||||
|
{me.data && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p>
|
||||||
|
👋 <strong>{me.data.user.name}</strong> · {me.data.household.name} ·{' '}
|
||||||
|
<span className="text-muted-foreground">{me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'}</span>
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||||
|
<LogOut /> Çıkış
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{me.data === null && (
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input
|
||||||
|
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||||
|
type="email"
|
||||||
|
placeholder="E-posta"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||||
|
type="password"
|
||||||
|
placeholder="Şifre"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={login.isPending}>
|
||||||
|
<LogIn /> Giriş
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,60 @@
|
|||||||
|
/**
|
||||||
|
* Oturum durumu ve giriş / kayıt / çıkış işlemleri.
|
||||||
|
*
|
||||||
|
* "Kim giriş yapmış?" bilgisinin tek kaynağı `useMe()`:
|
||||||
|
* - `undefined` → henüz kontrol ediliyor
|
||||||
|
* - `null` → oturum yok
|
||||||
|
* - `MeResponse`→ giriş yapılmış
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { LoginRequest } from '@/types/LoginRequest'
|
||||||
|
import type { MeResponse } from '@/types/MeResponse'
|
||||||
|
import type { RegisterRequest } from '@/types/RegisterRequest'
|
||||||
|
import { api, ApiError } from './api'
|
||||||
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
|
async function fetchMe(): Promise<MeResponse | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<MeResponse>('/auth/me')
|
||||||
|
} catch (e) {
|
||||||
|
// Oturum yoksa bu bir hata değil, bilinen bir durum
|
||||||
|
if (e instanceof ApiError && e.isUnauthorized) return null
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.me,
|
||||||
|
queryFn: fetchMe,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: LoginRequest) => api.post<MeResponse>('/auth/login', body),
|
||||||
|
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
|
||||||
|
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<void>('/auth/logout'),
|
||||||
|
onSettled: () => {
|
||||||
|
// Başka kullanıcının verisi ekranda/önbellekte kalmasın
|
||||||
|
qc.clear()
|
||||||
|
qc.setQueryData(queryKeys.me, null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
* Renk asla tek başına anlam taşımaz: her zaman emoji/ikon + etiketle birlikte kullanılır.
|
* 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 { 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 = {
|
export type GroupMeta = {
|
||||||
code: GroupCode
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { queryClient } from '@/lib/query'
|
||||||
import { ThemeProvider } from '@/lib/theme'
|
import { ThemeProvider } from '@/lib/theme'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
</ThemeProvider>
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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ı.')\"",
|
"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",
|
"test": "cd backend && cargo test && cd .. && npm --prefix frontend test",
|
||||||
"lint": "cd backend && cargo clippy && cd .. && npm --prefix frontend run lint",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.7"
|
"concurrently": "^10.0.7"
|
||||||
|
|||||||
Reference in New Issue
Block a user