feat(kayıt): yeni hane kaydı yalnızca davetle (REGISTRATION=invite_only)

- REGISTRATION ortam değişkeni: invite_only (varsayılan) | open
- invite_only: ilk hane serbest, sonrasında yalnızca davet koduyla katılım
- Backend'de zorunlu: 403 registration_closed; GET /api/auth/registration
- Kayıt ekranı: kayıt kapalıyken yalnızca davet kodu ve açıklama
- Entegrasyon testleri: kayıt politikası (2)
- Prettier ayarı eklendi (proje stiliyle uyumlu), kaynaklar biçimlendirildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 01:04:48 +03:00
parent 9cbeaaaf76
commit e188922c0f
29 changed files with 547 additions and 230 deletions
+6
View File
@@ -9,6 +9,12 @@ JWT_SECRET=degistir-beni-en-az-32-karakterlik-gizli-anahtar
# Uygulamanın erişildiği adres
APP_URL=http://localhost:5173
# Yeni hane kaydı: invite_only (varsayılan, önerilen) | open
# invite_only → sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi),
# sonra yalnızca davet koduyla katılınır.
# open → herkes /kayit ekranından yeni hane kurabilir.
REGISTRATION=invite_only
# Backend portu
PORT=8080
+2
View File
@@ -73,6 +73,8 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl
- 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ı).
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
+5
View File
@@ -17,6 +17,8 @@ GET {{baseUrl}}/health
### 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.
### REGISTRATION=invite_only (varsayılan): sistemde zaten hane varsa davetsiz kayıt 403 "registration_closed" döner
### → aşağıdaki "Davet koduyla kayıt" örneğini kullanın (kodu hane sahibi oluşturur).
POST {{baseUrl}}/auth/register
Content-Type: application/json
@@ -49,6 +51,9 @@ Content-Type: application/json
"remember_me": true
}
### Kayıt politikası: yeni hane kurulabilir mi? (oturum gerektirmez)
GET {{baseUrl}}/auth/registration
### Ben kimim? (oturum yoksa 401)
GET {{baseUrl}}/auth/me
+23
View File
@@ -8,6 +8,17 @@ pub struct Config {
pub jwt_secret: String,
pub app_url: String,
pub port: u16,
pub registration: RegistrationMode,
}
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistrationMode {
/// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar;
/// sonrasında yalnızca davet koduyla katılınır.
InviteOnly,
/// Herkes yeni hane kurabilir.
Open,
}
#[derive(Debug, thiserror::Error)]
@@ -32,6 +43,17 @@ impl Config {
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into());
let registration = match env::var("REGISTRATION").as_deref() {
Err(_) | Ok("") | Ok("invite_only") => RegistrationMode::InviteOnly,
Ok("open") => RegistrationMode::Open,
Ok(_) => {
return Err(ConfigError::Invalid(
"REGISTRATION",
"\"invite_only\" veya \"open\" olmalı",
));
}
};
let port = match env::var("PORT") {
Ok(p) => p
.parse()
@@ -44,6 +66,7 @@ impl Config {
jwt_secret,
app_url,
port,
registration,
})
}
+3
View File
@@ -37,6 +37,8 @@ pub enum AppError {
InvalidCredentials,
#[error("Bu işlem için yetkiniz yok")]
Forbidden,
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
RegistrationClosed,
#[error("{0}")]
Conflict(String),
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
@@ -55,6 +57,7 @@ impl AppError {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Database(_) | Self::Internal(_) => {
+16
View File
@@ -103,6 +103,7 @@ pub async fn register(
let password_hash = password::hash(req.password).await?;
let created = account::register(
&state.db,
state.config.registration,
NewAccount {
name,
email,
@@ -174,6 +175,21 @@ pub async fn login(
Ok((jar, Json(me)))
}
/// Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez)
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RegistrationInfo {
/// `false` ise yalnızca davet koduyla kayıt yapılabilir
new_household_allowed: bool,
}
pub async fn registration_info(State(state): State<AppState>) -> AppResult<Json<RegistrationInfo>> {
let allowed = account::new_household_allowed(&state.db, state.config.registration).await?;
Ok(Json(RegistrationInfo {
new_household_allowed: allowed,
}))
}
pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) {
(StatusCode::NO_CONTENT, jar.remove(removal_cookie()))
}
+1
View File
@@ -17,6 +17,7 @@ pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
.route("/auth/register", post(auth::register))
.route("/auth/registration", get(auth::registration_info))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
+26 -1
View File
@@ -10,6 +10,7 @@ use mongodb::{
};
use crate::{
config::RegistrationMode,
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
@@ -28,6 +29,21 @@ pub struct NewAccount {
pub invite_code: Option<String>,
}
/// Şu an davetsiz (yeni hane kurarak) kayıt yapılabilir mi?
pub async fn new_household_allowed(db: &Database, mode: RegistrationMode) -> AppResult<bool> {
Ok(match mode {
RegistrationMode::Open => true,
// Hiç hane yoksa ilk kurulum serbest (sunucu sahibi kendi hesabını açar)
RegistrationMode::InviteOnly => {
db.collection::<Document>(col::HOUSEHOLDS)
.count_documents(doc! {})
.limit(1)
.await?
== 0
}
})
}
pub struct CreatedAccount {
pub user_id: Uuid,
pub household_id: Uuid,
@@ -36,7 +52,16 @@ pub struct CreatedAccount {
/// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa
/// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır.
pub async fn register(db: &Database, acc: NewAccount) -> AppResult<CreatedAccount> {
pub async fn register(
db: &Database,
mode: RegistrationMode,
acc: NewAccount,
) -> AppResult<CreatedAccount> {
// Davetsiz kayıt = yeni hane → politika kontrolü (arayüzden bağımsız, API'de zorunlu)
if acc.invite_code.is_none() && !new_household_allowed(db, mode).await? {
return Err(AppError::RegistrationClosed);
}
let user_id = new_id();
let mut session = db.client().start_session().await?;
+11 -1
View File
@@ -11,7 +11,11 @@ use axum::{
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use kakeibo::{config::Config, db, routes, state::AppState};
use kakeibo::{
config::{Config, RegistrationMode},
db, routes,
state::AppState,
};
use mongodb::Database;
use serde_json::{Value, json};
use tower::ServiceExt;
@@ -29,7 +33,12 @@ pub struct Session {
}
impl TestApp {
/// Kayıt açık (birden fazla hane gerektiren testler için).
pub async fn new() -> Self {
Self::with_registration(RegistrationMode::Open).await
}
pub async fn with_registration(registration: RegistrationMode) -> Self {
dotenvy::dotenv().ok();
let uri = std::env::var("MONGODB_URI")
.expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)");
@@ -46,6 +55,7 @@ impl TestApp {
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
app_url: "http://localhost".into(),
port: 0,
registration,
};
let router = routes::router(AppState::new(db.clone(), config));
Self { router, db }
+70
View File
@@ -0,0 +1,70 @@
//! Kayıt politikası: REGISTRATION=invite_only (varsayılan).
mod common;
use axum::http::{Method, StatusCode};
use common::TestApp;
use kakeibo::config::RegistrationMode;
use serde_json::json;
async fn new_household_allowed(app: &TestApp) -> bool {
let (st, body, _) = app
.send(Method::GET, "/auth/registration", None, None)
.await;
assert_eq!(st, StatusCode::OK);
body["new_household_allowed"].as_bool().unwrap()
}
#[tokio::test]
async fn invite_only_allows_first_household_then_only_invites() {
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
// Boş sistem: ilk kurulum serbest
assert!(new_household_allowed(&app).await);
let owner = app.register("Sahip", None).await;
// Artık yeni hane kurulamaz (arayüz bilgisi + API zorunluluğu)
assert!(!new_household_allowed(&app).await);
let (st, body, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(
json!({ "name": "Yabancı", "email": "yabanci@test.com", "password": "sifre12345" }),
),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "registration_closed");
// Davetle katılım serbest
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let member = app
.register("Es", Some(inv["code"].as_str().unwrap()))
.await;
assert_eq!(member.household_id, owner.household_id);
// Geçersiz davet kodu yine 400 (politika hatası değil)
let (st, _, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(json!({ "name": "X", "email": "x@test.com", "password": "sifre12345", "invite_code": "ZZZZZZZZ" })),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn open_mode_allows_many_households() {
let app = TestApp::with_registration(RegistrationMode::Open).await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
assert_ne!(a.household_id, b.household_id);
assert!(new_household_allowed(&app).await);
app.cleanup().await;
}
+6
View File
@@ -31,6 +31,8 @@
- 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).
- **Kayıt politikası (kullanıcı kararı, 2026-11-12): Seçenek 1** → `REGISTRATION=invite_only` varsayılan; sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), sonra yalnızca davetle. `open` isteğe bağlı. Canlıda ilk hesabı kullanıcı `/kayit`'tan açar; demo hesaplar canlıya taşınmaz.
- Prettier ayarı eklendi (önceden yoktu → VS Code kaydedince çift tırnağa çevirebilirdi).
- 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
@@ -62,3 +64,7 @@
- **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ı.**
### 2026-11-12 — Faz 3 öncesi
- Kullanıcı `api.http`'deki kayıt örneğini (yeni@ornek.com) hesap sandı → dosya başlığına hazır hesaplar kutusu ve "bu bir KAYIT isteğidir" uyarısı eklendi.
- Kayıt politikası: `config.rs` `RegistrationMode`, `AppError::RegistrationClosed` (403), `account::new_household_allowed`, `GET /api/auth/registration` (`RegistrationInfo` TS tipi). `tests/registration.rs` (2). Kayıt ekranı kapalıyken yalnızca davet modu + açıklama. `.env`/`.env.example`: `REGISTRATION=invite_only`. Prettier (`.prettierrc.json`, `.prettierignore`, `format` / `format:check`), tüm src biçimlendirildi (yalnızca boşluk).
+5
View File
@@ -0,0 +1,5 @@
# ts-rs tarafından üretilir (npm run types)
src/types/
# shadcn/ui kaynakları (kendi biçimleriyle bırakılır)
src/components/ui/
dist/
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}
+17
View File
@@ -32,6 +32,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.90.0",
"prettier": "^3.10.0",
"typescript": "~7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
@@ -5945,6 +5946,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prettier": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.10.0.tgz",
"integrity": "sha512-mBi4765LgjO1PFjlEs7pUeK0nb1bw71c+N4/ItfeUWNdY5cV2pRKaFpbbFRYmDJMO+5ZwzGxHRqHxFZna7iv5w==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz",
+4 -1
View File
@@ -9,7 +9,9 @@
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -36,6 +38,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.90.0",
"prettier": "^3.10.0",
"typescript": "~7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
+18 -4
View File
@@ -22,7 +22,11 @@ export function FormField({
hint?: string
className?: string
/** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */
children: (field: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode
children: (field: {
id: string
'aria-invalid': boolean
'aria-describedby'?: string
}) => ReactNode
}) {
const id = useId()
const messageId = `${id}-message`
@@ -37,7 +41,10 @@ export function FormField({
'aria-describedby': message ? messageId : undefined,
})}
{message && (
<p id={messageId} className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}>
<p
id={messageId}
className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}
>
{message}
</p>
)}
@@ -50,7 +57,11 @@ 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)} />
<Input
{...props}
type={visible ? 'text' : 'password'}
className={cn('pr-12', props.className)}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
@@ -68,7 +79,10 @@ export function PasswordInput(props: ComponentProps<typeof Input>) {
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">
<p
role="alert"
className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense"
>
{message}
</p>
)
+7 -1
View File
@@ -12,7 +12,13 @@ const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
* 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 }) {
export function ThemeToggle({
className,
compact = false,
}: {
className?: string
compact?: boolean
}) {
const { theme, setTheme } = useTheme()
return (
<div
+10 -3
View File
@@ -43,7 +43,9 @@ export function LoginPage() {
// 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
const registerLink = donus
? `${paths.register}?donus=${encodeURIComponent(donus)}`
: paths.register
return (
<AuthShell
@@ -52,7 +54,10 @@ export function LoginPage() {
footer={
<>
{s.noAccount}{' '}
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
<Link
to={registerLink}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{s.toRegister}
</Link>
</>
@@ -74,7 +79,9 @@ export function LoginPage() {
</FormField>
<FormField label={s.password} error={errors.password}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
{(f) => (
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
)}
</FormField>
<Controller
+81 -35
View File
@@ -1,5 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Home, Loader2, UserPlus, Users } from 'lucide-react'
import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Home, Info, Loader2, UserPlus, Users } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { Link, useSearchParams } from 'react-router'
import { toast } from 'sonner'
@@ -7,11 +9,13 @@ 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 { api, ApiError } from '@/lib/api'
import { useRegister } from '@/lib/auth'
import { queryKeys } from '@/lib/query'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import { AuthShell } from './auth-shell'
import type { RegistrationInfo } from '@/types/RegistrationInfo'
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
const s = t.auth.register
@@ -22,11 +26,20 @@ export function RegisterPage() {
// Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu
const invite = params.get('davet') ?? ''
// Sunucu politikası (REGISTRATION): yeni hane kurulabilir mi?
const qc = useQueryClient()
const info = useQuery({
queryKey: queryKeys.registration,
queryFn: () => api.get<RegistrationInfo>('/auth/registration'),
})
const newAllowed = info.data?.new_household_allowed ?? true
const {
register,
control,
handleSubmit,
setError,
setValue,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
@@ -40,6 +53,11 @@ export function RegisterPage() {
invite_code: invite,
},
})
// Kayıt kapalıysa yalnızca "Davetle katıl"
useEffect(() => {
if (!newAllowed) setValue('mode', 'join')
}, [newAllowed, setValue])
const mode = useWatch({ control, name: 'mode' })
const name = useWatch({ control, name: 'name' }).trim()
@@ -58,7 +76,11 @@ export function RegisterPage() {
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'))
else if (e.code === 'registration_closed') {
// Bu arada başka biri ilk haneyi kurmuş olabilir → bilgiyi tazele, formu davete çevir
await qc.invalidateQueries({ queryKey: queryKeys.registration })
setError('root', { message: e.message })
} 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 })
}
@@ -74,7 +96,10 @@ export function RegisterPage() {
footer={
<>
{s.hasAccount}{' '}
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
<Link
to={loginLink}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{s.toLogin}
</Link>
</>
@@ -105,39 +130,60 @@ export function RegisterPage() {
{/* 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>
)}
/>
{!newAllowed && (
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
{s.closedNote}
</p>
)}
{newAllowed && (
<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
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}>
+23 -10
View File
@@ -1,9 +1,18 @@
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]))
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', () => {
@@ -13,9 +22,9 @@ describe('loginSchema', () => {
})
})
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',
)
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 })
@@ -34,11 +43,15 @@ describe('registerSchema', () => {
})
})
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(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe(
'Davet kodunu yaz',
)
expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true)
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)
+7 -2
View File
@@ -7,7 +7,11 @@ 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 }))
const email = z
.string()
.trim()
.min(1, { error: m.emailRequired })
.pipe(z.email({ error: m.emailInvalid }))
export const loginSchema = z.object({
email,
@@ -32,7 +36,8 @@ export const registerSchema = z
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 })
else if (code.length !== 8)
ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
})
export type RegisterForm = z.infer<typeof registerSchema>
+4 -3
View File
@@ -32,7 +32,9 @@ export function DesignPreview() {
<p className="text-sm">
{health.isError && <span className="text-expense">● {health.error.message}</span>}
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
{health.isPending && (
<span className="text-muted-foreground">● Backend kontrol ediliyor…</span>
)}
{health.data && (
<span className="text-income">
● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
@@ -40,7 +42,6 @@ export function DesignPreview() {
)}
</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">
@@ -65,7 +66,7 @@ export function DesignPreview() {
{[
['Gelir', formatMoney(4_500_000, 'TRY', { signed: true }), 'text-income'],
['Gider', formatMoney(-125_000), 'text-expense'],
['Bütçenin %85\'i kullanıldı', formatMoney(85_000), 'text-warning'],
["Bütçenin %85'i kullanıldı", formatMoney(85_000), 'text-warning'],
['Euro', formatMoney(30_000, 'EUR'), ''],
['Dolar', formatMoney(15_000, 'USD'), ''],
['Gram altın', formatQuantity(12_500_000, 'gr'), ''],
+159 -159
View File
@@ -1,184 +1,184 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/inter';
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', system-ui, sans-serif;
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', system-ui, sans-serif;
/* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */
--color-needs: var(--needs);
--color-needs-fg: var(--needs-fg);
--color-needs-soft: var(--needs-soft);
--color-wants: var(--wants);
--color-wants-fg: var(--wants-fg);
--color-wants-soft: var(--wants-soft);
--color-culture: var(--culture);
--color-culture-fg: var(--culture-fg);
--color-culture-soft: var(--culture-soft);
--color-unexpected: var(--unexpected);
--color-unexpected-fg: var(--unexpected-fg);
--color-unexpected-soft: var(--unexpected-soft);
--color-income: var(--income);
--color-expense: var(--expense);
--color-warning: var(--warning);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */
--color-needs: var(--needs);
--color-needs-fg: var(--needs-fg);
--color-needs-soft: var(--needs-soft);
--color-wants: var(--wants);
--color-wants-fg: var(--wants-fg);
--color-wants-soft: var(--wants-soft);
--color-culture: var(--culture);
--color-culture-fg: var(--culture-fg);
--color-culture-soft: var(--culture-soft);
--color-unexpected: var(--unexpected);
--color-unexpected-fg: var(--unexpected-fg);
--color-unexpected-soft: var(--unexpected-soft);
--color-income: var(--income);
--color-expense: var(--expense);
--color-warning: var(--warning);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.55 0.13 268);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.55 0.13 268);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden.
/* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden.
*: çubuk/dolgu · *-fg: yazı (WCAG AA ≥ 4.5:1) · *-soft: zemin */
--needs: #0072b2;
--needs-fg: #005a8c;
--needs-soft: #e6f1f8;
--wants: #cc79a7;
--wants-fg: #9c3f72;
--wants-soft: #f9eef4;
--culture: #009e73;
--culture-fg: #00714f;
--culture-soft: #e5f5f0;
--unexpected: #d55e00;
--unexpected-fg: #a84a00;
--unexpected-soft: #fbefe5;
--needs: #0072b2;
--needs-fg: #005a8c;
--needs-soft: #e6f1f8;
--wants: #cc79a7;
--wants-fg: #9c3f72;
--wants-soft: #f9eef4;
--culture: #009e73;
--culture-fg: #00714f;
--culture-soft: #e5f5f0;
--unexpected: #d55e00;
--unexpected-fg: #a84a00;
--unexpected-soft: #fbefe5;
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
--income: #15803d;
--expense: #c62828;
--warning: #b45309;
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
--income: #15803d;
--expense: #c62828;
--warning: #b45309;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.72 0.12 268);
--primary-foreground: oklch(0.18 0.03 268);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.62 0.12 268);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.72 0.12 268);
--primary-foreground: oklch(0.18 0.03 268);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.62 0.12 268);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Koyu zeminde daha açık tonlar (kontrast için) */
--needs: #3a9fdb;
--needs-fg: #7cc3f0;
--needs-soft: #10283a;
--wants: #d98fb8;
--wants-fg: #ecb6d4;
--wants-soft: #35202c;
--culture: #1fbf8f;
--culture-fg: #6fdcb9;
--culture-soft: #0c2e25;
--unexpected: #f07a24;
--unexpected-fg: #f8a96b;
--unexpected-soft: #3a2010;
/* Koyu zeminde daha açık tonlar (kontrast için) */
--needs: #3a9fdb;
--needs-fg: #7cc3f0;
--needs-soft: #10283a;
--wants: #d98fb8;
--wants-fg: #ecb6d4;
--wants-soft: #35202c;
--culture: #1fbf8f;
--culture-fg: #6fdcb9;
--culture-soft: #0c2e25;
--unexpected: #f07a24;
--unexpected-fg: #f8a96b;
--unexpected-soft: #3a2010;
--income: #4ade80;
--expense: #f87171;
--warning: #fbbf24;
--income: #4ade80;
--expense: #f87171;
--warning: #fbbf24;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
@apply bg-background text-foreground;
}
}
html {
@apply font-sans;
font-size: 16px;
@@ -206,4 +206,4 @@
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
+4 -1
View File
@@ -43,7 +43,10 @@ export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
onSuccess: (me) => {
qc.setQueryData(queryKeys.me, me)
qc.removeQueries({ queryKey: queryKeys.registration })
},
})
}
+1
View File
@@ -7,6 +7,7 @@ import { ApiError } from './api'
/** Önbellek anahtarları tek yerde (yazım hatası olmasın) */
export const queryKeys = {
me: ['me'] as const,
registration: ['registration'] as const,
health: ['health'] as const,
household: ['household'] as const,
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
+11 -6
View File
@@ -6,10 +6,15 @@ describe('safeReturnPath', () => {
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('/')
},
)
it.each([
null,
'',
'defter',
'//kotu.com',
'/\\kotu.com',
'https://kotu.com',
'javascript:alert(1)',
])('güvensiz → panele: %s', (p) => {
expect(safeReturnPath(p)).toBe('/')
})
})
+10 -2
View File
@@ -23,9 +23,15 @@ export const t = {
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ı.' },
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ışı." },
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' },
@@ -73,6 +79,8 @@ export const t = {
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).',
closedNote:
'Bu sunucuda yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı ya da kodu iste.',
submit: 'Hesabı oluştur',
submitting: 'Oluşturuluyor…',
hasAccount: 'Zaten hesabın var mı?',
+1 -1
View File
@@ -1 +1 @@
export { cn } from "cn"
export { cn } from 'cn'
+10
View File
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez)
*/
export type RegistrationInfo = {
/**
* `false` ise yalnızca davet koduyla kayıt yapılabilir
*/
new_household_allowed: boolean, };