Files
kakeibo/frontend/src/features/auth/register-page.tsx
T
suphicakir e188922c0f 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>
2026-11-12 01:04:48 +03:00

215 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
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'
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 { 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
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') ?? ''
// 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),
mode: 'onTouched',
defaultValues: {
name: '',
email: '',
password: '',
mode: invite ? 'join' : 'new',
household_name: '',
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()
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 (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 })
}
})
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>
{!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>
) : (
<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>
)
}