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:
@@ -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/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 @@
|
||||
export { cn } from "cn"
|
||||
export { cn } from 'cn'
|
||||
|
||||
@@ -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, };
|
||||
Reference in New Issue
Block a user