feat(giriş): giriş ve kayıt ekranları

- Giriş: satır içi doğrulama, şifre göster/gizle, beni hatırla, hoş geldin bildirimi
- Kayıt: yeni hane kur / davetle katıl, /kayit?davet=KOD bağlantısı
- Sunucu hataları ilgili alanda gösterilir (kayıtlı e-posta, geçersiz davet)
- react-hook-form + zod, sonner bildirimleri, shadcn input/label/checkbox/card
- Ortak form bileşenleri (FormField, PasswordInput, FormError)
- 8 form kuralı testi (toplam 47); api.http demo hesaplara güncellendi
- Faz 2 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 00:52:13 +03:00
parent c6d4c30461
commit b269411823
20 changed files with 886 additions and 62 deletions
+75
View File
@@ -0,0 +1,75 @@
import { useId, useState, type ComponentProps, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { FieldError } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
/**
* Etiket + giriş alanı + (varsa) hata/ipucu metni.
* Erişilebilirlik: hata `aria-invalid` ve `aria-describedby` ile alana bağlanır,
* ekran okuyucu alanı odaklayınca hatayı okur.
*/
export function FormField({
label,
error,
hint,
className,
children,
}: {
label: string
error?: FieldError
hint?: string
className?: string
/** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */
children: (field: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode
}) {
const id = useId()
const messageId = `${id}-message`
const message = error?.message ?? hint
return (
<div className={cn('space-y-2', className)}>
<Label htmlFor={id}>{label}</Label>
{children({
id,
'aria-invalid': !!error,
'aria-describedby': message ? messageId : undefined,
})}
{message && (
<p id={messageId} className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}>
{message}
</p>
)}
</div>
)
}
/** Şifre alanı: göster/gizle düğmeli. */
export function PasswordInput(props: ComponentProps<typeof Input>) {
const [visible, setVisible] = useState(false)
return (
<div className="relative">
<Input {...props} type={visible ? 'text' : 'password'} className={cn('pr-12', props.className)} />
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-muted-foreground hover:text-foreground"
aria-label={visible ? 'Şifreyi gizle' : 'Şifreyi göster'}
aria-pressed={visible}
>
{visible ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
)
}
/** Sunucudan gelen, belirli bir alana ait olmayan hata (ör. "E-posta veya şifre hatalı"). */
export function FormError({ message }: { message?: string }) {
if (!message) return null
return (
<p role="alert" className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense">
{message}
</p>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Toaster as Sonner } from 'sonner'
import { useTheme } from '@/lib/theme'
/**
* Bildirimler (toast). Kullanım: `import { toast } from 'sonner'` → `toast.success('Kaydedildi')`
* Mobilde alt menüyle çakışmasın diye üstte gösterilir.
*/
export function Toaster() {
const { theme } = useTheme()
return (
<Sonner
theme={theme}
position="top-center"
richColors
closeButton
toastOptions={{ className: 'font-sans' }}
/>
)
}
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-5 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-4"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
// Kakeibo: h-11 (44 px dokunma alanı); mobilde text-base (16 px) → iOS odaklanınca yakınlaştırmaz
className={cn(
"h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { Logo } from '@/components/logo'
import { ThemeToggle } from '@/components/theme-toggle'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useDocumentTitle } from '@/lib/use-document-title'
/** Giriş / kayıt ekranlarının ortak çerçevesi: ortada kart, üstte logo. */
export function AuthShell({
title,
description,
footer,
children,
}: {
title: string
description: string
footer: ReactNode
children: ReactNode
}) {
useDocumentTitle(title)
return (
<div className="flex min-h-dvh flex-col bg-muted/40">
<div className="flex justify-end p-3">
<ThemeToggle compact />
</div>
<main className="flex flex-1 flex-col items-center px-4 pb-10 sm:justify-center">
<Logo size={52} withText className="mb-6" />
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-xl">
<h1>{title}</h1>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
<p className="mt-6 text-sm text-muted-foreground">{footer}</p>
</main>
</div>
)
}
+93 -53
View File
@@ -1,67 +1,107 @@
import { useState, type FormEvent } from 'react'
import { LogIn } from 'lucide-react'
import { Link } from 'react-router'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2, LogIn } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { Link, useSearchParams } from 'react-router'
import { toast } from 'sonner'
import { FormError, FormField, PasswordInput } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { Logo } from '@/components/logo'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api'
import { useLogin } from '@/lib/auth'
import { useDocumentTitle } from '@/lib/use-document-title'
import { t } from '@/lib/strings'
import { AuthShell } from './auth-shell'
import { loginSchema, type LoginForm } from './schemas'
const s = t.auth.login
// GEÇİCİ: Faz 2 – Adım 5'te react-hook-form + zod ile gerçek giriş ekranı gelecek.
export function LoginPage() {
useDocumentTitle('Giriş')
const login = useLogin()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [params] = useSearchParams()
const {
register,
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onTouched', // alan terk edilince doğrula, sonra yazarken güncelle
defaultValues: { email: '', password: '', remember_me: true },
})
const submit = (e: FormEvent) => {
e.preventDefault()
// Başarılı olunca RequireGuest, ?donus= adresine yönlendirir
login.mutate({ email, password, remember_me: true })
}
const onSubmit = handleSubmit(async (values) => {
try {
const me = await login.mutateAsync(values)
toast.success(s.welcome(me.user.name))
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
// Kayıt sayfasına geçerken dönüş adresi kaybolmasın
const donus = params.get('donus')
const registerLink = donus ? `${paths.register}?donus=${encodeURIComponent(donus)}` : paths.register
return (
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4">
<Logo size={56} withText />
<form onSubmit={submit} className="flex flex-col gap-3">
<input
className="h-11 rounded-md border bg-background px-3"
type="email"
placeholder="E-posta"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
<AuthShell
title={s.title}
description={s.description}
footer={
<>
{s.noAccount}{' '}
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
{s.toRegister}
</Link>
</>
}
>
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
autoFocus
/>
)}
</FormField>
<FormField label={s.password} error={errors.password}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
</FormField>
<Controller
control={control}
name="remember_me"
render={({ field }) => (
<label className="flex min-h-11 cursor-pointer items-start gap-3">
<Checkbox
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
className="mt-0.5"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium">{s.remember}</span>
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
</span>
</label>
)}
/>
<input
className="h-11 rounded-md border bg-background px-3"
type="password"
placeholder="Şifre"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
<Button type="submit" size="lg" disabled={login.isPending}>
<LogIn /> Giriş
<FormError message={errors.root?.message} />
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
<Link to={paths.register} className="text-center text-sm text-muted-foreground underline">
Hesabınız yok mu? Kayıt olun
</Link>
</main>
)
}
// GEÇİCİ: Adım 5'te kayıt formu (davet kodu dahil).
export function RegisterPage() {
useDocumentTitle('Kayıt')
return (
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4 text-center">
<Logo size={56} withText className="justify-center" />
<p className="text-muted-foreground">Kayıt ekranı Faz 2 – Adım 5'te geliştirilecek.</p>
<Link to={paths.login} className="text-sm underline">
Girişe dön
</Link>
</main>
</AuthShell>
)
}
@@ -0,0 +1,168 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Home, Loader2, UserPlus, Users } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { Link, useSearchParams } from 'react-router'
import { toast } from 'sonner'
import { FormError, FormField, PasswordInput } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api'
import { useRegister } from '@/lib/auth'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import { AuthShell } from './auth-shell'
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
const s = t.auth.register
export function RegisterPage() {
const registerAccount = useRegister()
const [params] = useSearchParams()
// Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu
const invite = params.get('davet') ?? ''
const {
register,
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
mode: 'onTouched',
defaultValues: {
name: '',
email: '',
password: '',
mode: invite ? 'join' : 'new',
household_name: '',
invite_code: invite,
},
})
const mode = useWatch({ control, name: 'mode' })
const name = useWatch({ control, name: 'name' }).trim()
const onSubmit = handleSubmit(async (v) => {
try {
const me = await registerAccount.mutateAsync({
name: v.name,
email: v.email,
password: v.password,
...(v.mode === 'join'
? { invite_code: normalizeInviteCode(v.invite_code) }
: { household_name: v.household_name || undefined }),
})
toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name))
} catch (e) {
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
// Sunucu hatasını ilgili alana bağla
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
else if (v.mode === 'join' && e.code === 'bad_request' && e.message.includes('Davet'))
setError('invite_code', { message: e.message }, { shouldFocus: true })
else setError('root', { message: e.message })
}
})
const donus = params.get('donus')
const loginLink = donus ? `${paths.login}?donus=${encodeURIComponent(donus)}` : paths.login
return (
<AuthShell
title={s.title}
description={s.description}
footer={
<>
{s.hasAccount}{' '}
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
{s.toLogin}
</Link>
</>
}
>
<form onSubmit={onSubmit} noValidate className="space-y-5">
<FormField label={s.name} error={errors.name}>
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
</FormField>
<FormField label={s.email} error={errors.email}>
{(f) => (
<Input
{...f}
{...register('email')}
type="email"
inputMode="email"
autoComplete="email"
autoCapitalize="none"
/>
)}
</FormField>
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
</FormField>
{/* Yeni hane mi, davetle katılma mı? */}
<fieldset className="space-y-3">
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
<Controller
control={control}
name="mode"
render={({ field }) => (
<div role="radiogroup" className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1">
{(
[
['new', s.modeNew, Home],
['join', s.modeJoin, Users],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={field.value === value}
onClick={() => field.onChange(value)}
className={cn(
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
field.value === value && 'bg-background text-foreground shadow-sm',
)}
>
<Icon className="size-4" aria-hidden />
{label}
</button>
))}
</div>
)}
/>
{mode === 'new' ? (
<FormField label={s.householdName} error={errors.household_name} hint={s.householdHint(name)}>
{(f) => <Input {...f} {...register('household_name')} placeholder={name ? `${name} Hanesi` : ''} />}
</FormField>
) : (
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
{(f) => (
<Input
{...f}
{...register('invite_code')}
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
maxLength={12}
className="font-mono tracking-widest uppercase"
/>
)}
</FormField>
)}
</fieldset>
<FormError message={errors.root?.message} />
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
{isSubmitting ? s.submitting : s.submit}
</Button>
</form>
</AuthShell>
)
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { loginSchema, normalizeInviteCode, registerSchema } from './schemas'
const base = { name: 'Suphi', email: 'a@b.co', password: '12345678', mode: 'new' as const, household_name: '', invite_code: '' }
const issues = (r: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }) =>
Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message]))
describe('loginSchema', () => {
it('boş alanlarda Türkçe mesaj', () => {
expect(issues(loginSchema.safeParse({ email: '', password: '', remember_me: true }))).toEqual({
email: 'E-posta gerekli',
password: 'Şifre gerekli',
})
})
it('geçersiz e-posta', () => {
expect(issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email).toBe(
'Geçerli bir e-posta adresi girin',
)
})
it('e-postadaki boşlukları kırpar', () => {
const r = loginSchema.safeParse({ email: ' a@b.co ', password: 'x', remember_me: true })
expect(r.success && r.data.email).toBe('a@b.co')
})
})
describe('registerSchema', () => {
it('geçerli yeni hane kaydı', () => {
expect(registerSchema.safeParse(base).success).toBe(true)
})
it('kısa şifre ve boş ad', () => {
expect(issues(registerSchema.safeParse({ ...base, name: ' ', password: '123' }))).toEqual({
name: 'Adını yaz',
password: 'Şifre en az 8 karakter olmalı',
})
})
it('davet modunda kod zorunlu ve 8 karakter', () => {
expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe('Davet kodunu yaz')
expect(issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code).toBe(
'Davet kodu 8 karakter olmalı',
)
expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true)
})
it('yeni hane modunda davet kodu yok sayılır', () => {
expect(registerSchema.safeParse({ ...base, invite_code: 'x' }).success).toBe(true)
})
})
it('normalizeInviteCode', () => {
expect(normalizeInviteCode(' abcd-efgh ')).toBe('ABCDEFGH')
})
+42
View File
@@ -0,0 +1,42 @@
/**
* Giriş / kayıt form kuralları. Backend'deki doğrulamayla aynı sınırlar
* (`backend/src/routes/auth.rs`) — kullanıcı hatayı göndermeden görür, backend yine de kontrol eder.
*/
import { z } from 'zod'
import { t } from '@/lib/strings'
const m = t.auth.validation
const email = z.string().trim().min(1, { error: m.emailRequired }).pipe(z.email({ error: m.emailInvalid }))
export const loginSchema = z.object({
email,
password: z.string().min(1, { error: m.passwordRequired }),
remember_me: z.boolean(),
})
export type LoginForm = z.infer<typeof loginSchema>
export const registerSchema = z
.object({
name: z.string().trim().min(1, { error: m.nameRequired }).max(80, { error: m.nameTooLong }),
email,
password: z
.string()
.min(8, { error: m.passwordTooShort })
.max(128, { error: m.passwordTooLong }),
mode: z.enum(['new', 'join']),
household_name: z.string().trim().max(80, { error: m.householdTooLong }),
invite_code: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.mode !== 'join') return
const code = normalizeInviteCode(v.invite_code)
if (!code) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteRequired })
else if (code.length !== 8) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
})
export type RegisterForm = z.infer<typeof registerSchema>
/** Backend ile aynı: boşluk/tire atılır, büyük harf. "abcd-efgh" → "ABCDEFGH" */
export function normalizeInviteCode(code: string): string {
return code.replace(/[^a-z0-9]/gi, '').toUpperCase()
}
+49
View File
@@ -45,6 +45,55 @@ export const t = {
theme: 'Tema',
},
auth: {
login: {
title: 'Tekrar hoş geldin',
description: 'Defterine devam etmek için giriş yap.',
email: 'E-posta',
password: 'Şifre',
remember: 'Beni hatırla',
rememberHint: '30 gün boyunca bu cihazda oturum açık kalır.',
submit: 'Giriş yap',
submitting: 'Giriş yapılıyor…',
noAccount: 'Hesabın yok mu?',
toRegister: 'Kayıt ol',
welcome: (name: string) => `Hoş geldin, ${name} 👋`,
},
register: {
title: 'Hesap oluştur',
description: 'Hane bütçeni Kakeibo yöntemiyle tutmaya başla.',
name: 'Adın',
email: 'E-posta',
password: 'Şifre',
passwordHint: 'En az 8 karakter.',
modeLabel: 'Hane',
modeNew: 'Yeni hane kur',
modeJoin: 'Davetle katıl',
householdName: 'Hane adı',
householdHint: (name: string) => (name ? `Boş bırakırsan: "${name} Hanesi"` : 'İsteğe bağlı'),
inviteCode: 'Davet kodu',
inviteHint: 'Hane sahibinden aldığın 8 karakterlik kod (ör. ABCD-EFGH).',
submit: 'Hesabı oluştur',
submitting: 'Oluşturuluyor…',
hasAccount: 'Zaten hesabın var mı?',
toLogin: 'Giriş yap',
welcome: (household: string) => `Hesabın hazır! ${household} defterin açıldı 📒`,
joined: (household: string) => `${household} hanesine katıldın 🎉`,
},
validation: {
emailRequired: 'E-posta gerekli',
emailInvalid: 'Geçerli bir e-posta adresi girin',
passwordRequired: 'Şifre gerekli',
passwordTooShort: 'Şifre en az 8 karakter olmalı',
passwordTooLong: 'Şifre en fazla 128 karakter olabilir',
nameRequired: 'Adını yaz',
nameTooLong: 'Ad en fazla 80 karakter olabilir',
householdTooLong: 'Hane adı en fazla 80 karakter olabilir',
inviteRequired: 'Davet kodunu yaz',
inviteInvalid: 'Davet kodu 8 karakter olmalı',
},
},
common: {
loading: 'Yükleniyor…',
},
+2
View File
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router'
import './index.css'
import { queryClient } from '@/lib/query'
import { Toaster } from '@/components/toaster'
import { ThemeProvider } from '@/lib/theme'
import { router } from './router'
@@ -12,6 +13,7 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
<Toaster />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
+2 -1
View File
@@ -2,7 +2,8 @@ import { createBrowserRouter, type RouteObject } from 'react-router'
import { AppLayout } from '@/components/layout/app-layout'
import { paths } from '@/components/layout/nav-items'
import { RequireAuth, RequireGuest } from '@/components/layout/require-auth'
import { LoginPage, RegisterPage } from '@/features/auth/login-page'
import { LoginPage } from '@/features/auth/login-page'
import { RegisterPage } from '@/features/auth/register-page'
import { DashboardPage } from '@/features/dashboard/page'
import { DesignPreview } from '@/features/dev/design-preview'
import { NotFoundPage } from '@/features/errors/not-found-page'