feat: basic onboarding flow\

This commit is contained in:
Jose Selesan
2026-09-15 15:41:45 -03:00
parent 874b6e0cff
commit a92e9e77c3
37 changed files with 1775 additions and 42 deletions

View File

@@ -0,0 +1,236 @@
import { useMutation } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared'
import { CreateFirstGroupSchema } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
import { createFirstGroup } from '../../lib/api'
import { cn } from '../../lib/utils'
import { Button, Input, Label } from '../ui'
import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants'
const defaultValues: CreateFirstGroup = {
name: '',
days: [],
time: '09:00',
capacity: 1,
price: 0,
billingType: 'MONTHLY',
dueDay: 1,
}
type FirstGroupStepProps = {
onBack: () => void
onCompleted: (group: OnboardingGroupDto) => void
}
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateFirstGroup>({
resolver: zodResolver(CreateFirstGroupSchema),
defaultValues,
mode: 'onTouched',
})
const days = watch('days')
const billingType = watch('billingType')
const dueDay = watch('dueDay')
const create = useMutation({
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
onSuccess: (result) => onCompleted(result.group),
})
const toggleDay = (day: WeekDay) => {
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
setValue('days', next, { shouldValidate: true })
}
const selectBillingType = (type: BillingType) => {
setValue('billingType', type, { shouldValidate: true })
}
return (
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
<header className="space-y-1">
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
<p className="text-sm text-foreground/60">
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
</p>
</header>
<div>
<Label htmlFor="name">Nombre del grupo</Label>
<Input
id="name"
placeholder="Ej: Yoga Vinyasa · Nivel 1"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label>Días de clase</Label>
<div className="flex flex-wrap gap-2">
{WEEK_DAY_CHIPS.map(({ value, label }) => {
const isActive = days.includes(value)
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => toggleDay(value)}
className={cn(
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
isActive
? 'border-accent bg-accent text-white'
: 'border-border bg-white text-primary hover:bg-primary-soft',
)}
>
{label}
</button>
)
})}
</div>
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
</div>
<div>
<Label htmlFor="time">Horario</Label>
<Input
id="time"
type="time"
invalid={!!errors.time}
{...register('time')}
/>
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="capacity">Cupo máximo</Label>
<Input
id="capacity"
type="number"
inputMode="numeric"
min={1}
step={1}
invalid={!!errors.capacity}
{...register('capacity', { valueAsNumber: true })}
/>
{errors.capacity ? (
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
) : null}
</div>
<div>
<Label htmlFor="price">Precio</Label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
$
</span>
<Input
id="price"
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="0.00"
className="pl-7"
invalid={!!errors.price}
{...register('price', { valueAsNumber: true })}
/>
</div>
{errors.price ? (
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
) : null}
</div>
</div>
<div>
<Label>Tipo de cobro</Label>
<div className="grid grid-cols-2 gap-2">
{BILLING_TYPES.map(({ value, label, hint }) => {
const isActive = billingType === value
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => selectBillingType(value)}
className={cn(
'rounded-xl border px-3 py-2.5 text-left transition-colors',
isActive
? 'border-accent bg-accent-soft'
: 'border-border bg-white hover:bg-primary-soft',
)}
>
<span
className={cn(
'block text-sm font-semibold',
isActive ? 'text-accent' : 'text-primary',
)}
>
{label}
</span>
<span className="block text-xs text-foreground/50">{hint}</span>
</button>
)
})}
</div>
</div>
<div>
<Label htmlFor="dueDay">Día de vencimiento</Label>
<Input
id="dueDay"
type="number"
inputMode="numeric"
min={1}
max={28}
step={1}
invalid={!!errors.dueDay}
{...register('dueDay', { valueAsNumber: true })}
/>
<p className="mt-1 text-xs text-foreground/50">
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
</p>
{errors.dueDay ? (
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
) : null}
</div>
{create.isError ? (
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
No pudimos crear el grupo. Revisá los datos e intentá de nuevo.
</p>
) : null}
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onBack} disabled={create.isPending}>
<ArrowLeft className="size-4" />
Volver
</Button>
<Button
type="submit"
variant="primary"
className="flex-1"
disabled={create.isPending}
>
{create.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
{create.isPending ? 'Creando…' : 'Crear grupo'}
</Button>
</div>
</form>
)
}