feat: basic onboarding flow\
This commit is contained in:
84
apps/web/src/components/onboarding/ConfirmationStep.tsx
Normal file
84
apps/web/src/components/onboarding/ConfirmationStep.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { ArrowRight, CheckCircle2, CreditCard } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { Badge, Button } from '../ui'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from './constants'
|
||||
|
||||
type ConfirmationStepProps = {
|
||||
group: OnboardingGroupDto
|
||||
providerName?: string
|
||||
}
|
||||
|
||||
export function ConfirmationStep({ group, providerName }: ConfirmationStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { refresh } = useAuth()
|
||||
|
||||
const goToDashboard = () => {
|
||||
// Revalida la caché (estado del onboarding, sesión, grupos) antes de redirigir.
|
||||
void queryClient.invalidateQueries()
|
||||
void refresh()
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-success-soft">
|
||||
<CheckCircle2 className="size-7 text-success" />
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Tu grupo se creó y ya podés empezar a cobrar a tus alumnos.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||
<p className="mt-0.5 text-sm text-foreground/60">
|
||||
{formatSchedule(group.days ?? [], group.time)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success">Activo</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 divide-y divide-border text-sm">
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Precio</dt>
|
||||
<dd className="font-semibold text-primary">
|
||||
{formatPrice(group.price)}
|
||||
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Cupo</dt>
|
||||
<dd className="font-semibold text-primary">{group.capacity ?? '—'} alumnos</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Vencimiento</dt>
|
||||
<dd className="font-semibold text-primary">Día {group.dueDay ?? '—'} de cada mes</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{providerName ? (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary-soft px-4 py-3">
|
||||
<CreditCard className="size-4 shrink-0 text-accent" />
|
||||
<p className="text-xs text-primary">
|
||||
Vas a cobrar con <span className="font-semibold">{providerName}</span> en modo
|
||||
prueba.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button variant="primary" className="w-full" onClick={goToDashboard}>
|
||||
Ir a mi Panel
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
236
apps/web/src/components/onboarding/FirstGroupStep.tsx
Normal file
236
apps/web/src/components/onboarding/FirstGroupStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
apps/web/src/components/onboarding/PaymentStep.tsx
Normal file
171
apps/web/src/components/onboarding/PaymentStep.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ConnectPaymentResult, PaymentProvider } from '@gruperly/shared'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { connectPayment } from '../../lib/api'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Badge, Button } from '../ui'
|
||||
import { PAYMENT_PROVIDERS } from './constants'
|
||||
|
||||
type PaymentStepProps = {
|
||||
initialResult: ConnectPaymentResult | null
|
||||
onConnected: (result: ConnectPaymentResult) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepProps) {
|
||||
const [selected, setSelected] = useState<PaymentProvider>('MERCADO_PAGO')
|
||||
const [connected, setConnected] = useState<ConnectPaymentResult | null>(initialResult)
|
||||
|
||||
const connect = useMutation({
|
||||
mutationFn: () => connectPayment({ provider: selected, sandbox: true }),
|
||||
onSuccess: (result) => {
|
||||
setConnected(result)
|
||||
onConnected(result)
|
||||
},
|
||||
})
|
||||
|
||||
if (connected) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">Tu cobro está conectado</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Ya podés dejar listo tu primer grupo para cobrar con{' '}
|
||||
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-white">
|
||||
<CheckCircle2 className="size-6 text-success" />
|
||||
</span>
|
||||
<p className="mt-3 text-sm font-semibold text-success">Cuenta conectada</p>
|
||||
<div className="mt-1 flex items-center justify-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name}
|
||||
</span>
|
||||
<Badge variant="success">Modo prueba</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setConnected(null)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Cambiar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => onConnected(connected)}
|
||||
>
|
||||
Continuar
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">Elegí tu procesador de cobro</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Los pagos de tus alumnos van a llegar por esta plataforma.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div role="radiogroup" aria-label="Procesador de pago" className="space-y-3">
|
||||
{PAYMENT_PROVIDERS.map((provider) => {
|
||||
const isSelected = selected === provider.value
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => setSelected(provider.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl border bg-white p-4 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-accent ring-2 ring-accent/20'
|
||||
: 'border-border hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-lg p-2',
|
||||
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<CreditCard
|
||||
className={cn('size-5', isSelected ? 'text-accent' : 'text-primary/40')}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-primary">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="block text-xs text-foreground/60">
|
||||
{provider.description}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
isSelected ? 'border-accent bg-accent text-white' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{isSelected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-xl bg-warning-soft px-4 py-3">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<p className="text-xs text-warning">
|
||||
Por ahora la conexión se hace en modo prueba (sandbox). Más adelante vas a poder
|
||||
vincular tu cuenta real de {selected === 'STRIPE' ? 'Stripe' : 'Mercado Pago'}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connect.isError ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos conectar la cuenta. Intentalo de nuevo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={connect.isPending}
|
||||
onClick={() => connect.mutate()}
|
||||
>
|
||||
{connect.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck className="size-4" />
|
||||
)}
|
||||
{connect.isPending ? 'Conectando…' : 'Conectar cuenta'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
59
apps/web/src/components/onboarding/Stepper.tsx
Normal file
59
apps/web/src/components/onboarding/Stepper.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export type StepperProps = {
|
||||
steps: readonly { label: string }[]
|
||||
current: number
|
||||
}
|
||||
|
||||
export function Stepper({ steps, current }: StepperProps) {
|
||||
return (
|
||||
<ol aria-label="Progreso del alta" className="flex items-center">
|
||||
{steps.map((step, index) => {
|
||||
const isDone = index < current
|
||||
const isActive = index === current
|
||||
|
||||
return (
|
||||
<li
|
||||
key={step.label}
|
||||
className={cn('flex items-center', index < steps.length - 1 ? 'flex-1' : '')}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
|
||||
isDone && 'border-accent bg-accent text-white',
|
||||
isActive && 'border-accent text-accent ring-4 ring-accent/15',
|
||||
!isDone && !isActive && 'border-border text-foreground/40',
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<Check className="size-4" strokeWidth={3} />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs font-medium sm:block',
|
||||
isActive ? 'text-accent' : 'text-foreground/50',
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 ? (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-2 mb-0 h-px flex-1 rounded-full transition-colors sm:mb-4',
|
||||
isDone ? 'bg-accent' : 'bg-border',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
63
apps/web/src/components/onboarding/WelcomeStep.tsx
Normal file
63
apps/web/src/components/onboarding/WelcomeStep.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react'
|
||||
import { Button } from '../ui'
|
||||
|
||||
type WelcomeStepProps = {
|
||||
name: string
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const STEPS_TO_SETUP = [
|
||||
{
|
||||
icon: Wallet,
|
||||
title: 'Conectá tu cuenta de cobro',
|
||||
description: 'Mercado Pago o Stripe, en modo prueba por ahora.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Creá tu primer grupo',
|
||||
description: 'Días, horario, precio y cupo de tu clase.',
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'Empezá a cobrar',
|
||||
description: 'Todo listo para sumar alumnos y cobrar al instante.',
|
||||
},
|
||||
]
|
||||
|
||||
export function WelcomeStep({ name, onNext }: WelcomeStepProps) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||
<Rocket className="size-7 text-accent" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-primary">¡Hola, {name}!</h1>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-white p-4"
|
||||
>
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Icon className="size-5 text-accent" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">{title}</p>
|
||||
<p className="text-xs text-foreground/60">{description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Button variant="primary" size="md" className="w-full" onClick={onNext}>
|
||||
Comenzar
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/components/onboarding/constants.ts
Normal file
44
apps/web/src/components/onboarding/constants.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { BillingType, PaymentProvider, WeekDay } from '@gruperly/shared'
|
||||
export { BILLING_LABELS, WEEK_DAY_FULL_LABELS, formatPrice, formatSchedule } from '../../lib/format'
|
||||
|
||||
export const WEEK_DAYS: readonly WeekDay[] = [
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
'SUNDAY',
|
||||
]
|
||||
|
||||
export const WEEK_DAY_CHIPS: readonly { value: WeekDay; label: string }[] = [
|
||||
{ value: 'MONDAY', label: 'Lun' },
|
||||
{ value: 'TUESDAY', label: 'Mar' },
|
||||
{ value: 'WEDNESDAY', label: 'Mié' },
|
||||
{ value: 'THURSDAY', label: 'Jue' },
|
||||
{ value: 'FRIDAY', label: 'Vie' },
|
||||
{ value: 'SATURDAY', label: 'Sáb' },
|
||||
{ value: 'SUNDAY', label: 'Dom' },
|
||||
]
|
||||
|
||||
export const PAYMENT_PROVIDERS: readonly {
|
||||
value: PaymentProvider
|
||||
name: string
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'MERCADO_PAGO',
|
||||
name: 'Mercado Pago',
|
||||
description: 'El procesador más usado en Latinoamérica',
|
||||
},
|
||||
{
|
||||
value: 'STRIPE',
|
||||
name: 'Stripe',
|
||||
description: 'Cobrá con tarjetas e internacionalmente',
|
||||
},
|
||||
]
|
||||
|
||||
export const BILLING_TYPES: readonly { value: BillingType; label: string; hint: string }[] = [
|
||||
{ value: 'MONTHLY', label: 'Mensual', hint: 'Un cobro por mes' },
|
||||
{ value: 'PER_CLASS', label: 'Por clase', hint: 'Cada clase que asista' },
|
||||
]
|
||||
5
apps/web/src/components/onboarding/index.ts
Normal file
5
apps/web/src/components/onboarding/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Stepper, type StepperProps } from './Stepper'
|
||||
export { WelcomeStep } from './WelcomeStep'
|
||||
export { PaymentStep } from './PaymentStep'
|
||||
export { FirstGroupStep } from './FirstGroupStep'
|
||||
export { ConfirmationStep } from './ConfirmationStep'
|
||||
@@ -22,15 +22,23 @@
|
||||
/* Border radius por defecto */
|
||||
--radius-xl: 0.75rem;
|
||||
|
||||
/* Animaciones */
|
||||
/* Animaciones */
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-step-enter: step-enter 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes step-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
apps/web/src/lib/api.ts
Normal file
65
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ConnectPayment,
|
||||
ConnectPaymentResult,
|
||||
CreateFirstGroup,
|
||||
CreateFirstGroupResult,
|
||||
GroupList,
|
||||
OnboardingStatusDto,
|
||||
ProblemDetails,
|
||||
} from '@gruperly/shared'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly problem: ProblemDetails | null,
|
||||
) {
|
||||
super(problem?.title ?? `Error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
type JsonBody = Record<string, unknown> | unknown[]
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let problem: ProblemDetails | null = null
|
||||
try {
|
||||
const body = (await res.json()) as Partial<ProblemDetails>
|
||||
if (body && typeof body === 'object' && typeof body.title === 'string') {
|
||||
problem = body as ProblemDetails
|
||||
}
|
||||
} catch {
|
||||
// Sin cuerpo JSON: usamos el error genérico.
|
||||
}
|
||||
throw new ApiError(res.status, problem)
|
||||
}
|
||||
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const getOnboardingStatus = () => apiFetch<OnboardingStatusDto>('/api/v1/onboarding/status')
|
||||
|
||||
export const connectPayment = (payload: ConnectPayment) =>
|
||||
apiFetch<ConnectPaymentResult>('/api/v1/onboarding/payment-setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const createFirstGroup = (payload: CreateFirstGroup) =>
|
||||
apiFetch<CreateFirstGroupResult>('/api/v1/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||
26
apps/web/src/lib/format.ts
Normal file
26
apps/web/src/lib/format.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BillingType, WeekDay } from '@gruperly/shared'
|
||||
|
||||
export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = {
|
||||
MONDAY: 'Lunes',
|
||||
TUESDAY: 'Martes',
|
||||
WEDNESDAY: 'Miércoles',
|
||||
THURSDAY: 'Jueves',
|
||||
FRIDAY: 'Viernes',
|
||||
SATURDAY: 'Sábado',
|
||||
SUNDAY: 'Domingo',
|
||||
}
|
||||
|
||||
export const BILLING_LABELS: Record<BillingType, string> = {
|
||||
MONTHLY: 'Mensual',
|
||||
PER_CLASS: 'Por clase',
|
||||
}
|
||||
|
||||
export function formatSchedule(days: readonly WeekDay[], time: string | null): string {
|
||||
const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day])
|
||||
return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ')
|
||||
}
|
||||
|
||||
export function formatPrice(price: number | null): string {
|
||||
if (price == null) return '—'
|
||||
return price.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' })
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import { AuthProvider } from './context/AuthProvider'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -10,6 +10,7 @@ import { OrganizationsPage } from './routes/organizations'
|
||||
import { LoginPage } from './routes/auth/login'
|
||||
import { SignupPage } from './routes/auth/signup'
|
||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||
import { OnboardingView } from './routes/onboarding'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
@@ -33,6 +34,12 @@ const verifyEmailRoute = createRoute({
|
||||
component: VerifyEmailPage,
|
||||
})
|
||||
|
||||
const onboardingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/onboarding',
|
||||
component: OnboardingView,
|
||||
})
|
||||
|
||||
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -86,6 +93,7 @@ const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
signupRoute,
|
||||
verifyEmailRoute,
|
||||
onboardingRoute,
|
||||
appLayoutRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
|
||||
@@ -1,8 +1,109 @@
|
||||
export function GroupsView() {
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CalendarClock, Loader2, Plus, Users } from 'lucide-react'
|
||||
import type { GroupDto } from '@gruperly/shared'
|
||||
import { Badge, Button } from '../components/ui'
|
||||
import { getGroups } from '../lib/api'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'
|
||||
|
||||
function GroupCard({ group }: { group: GroupDto }) {
|
||||
const hasSchedule = (group.days?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</section>
|
||||
<article className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||
{group.description ? (
|
||||
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="success">Activo</Badge>
|
||||
</div>
|
||||
|
||||
{hasSchedule ? (
|
||||
<dl className="mt-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<Users className="size-4 shrink-0 text-accent" />
|
||||
<span>
|
||||
Cupo {group.capacity ?? '—'} alumnos
|
||||
{group.price != null
|
||||
? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}`
|
||||
: ''}
|
||||
{group.dueDay != null ? ` · vence el día ${group.dueDay}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-foreground/50">Aún sin plan de cobro configurado.</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function GroupsView() {
|
||||
const navigate = useNavigate()
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
|
||||
<Plus className="size-4" />
|
||||
Crear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groupsQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isError ? (
|
||||
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar tus grupos.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void groupsQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-white px-4 py-12 text-center">
|
||||
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Crea tu primer grupo para empezar a cobrar.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/onboarding' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
|
||||
<div className="mt-6 grid gap-4">
|
||||
{groupsQuery.data.data.map((group) => (
|
||||
<GroupCard key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
131
apps/web/src/routes/onboarding.tsx
Normal file
131
apps/web/src/routes/onboarding.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { ConnectPaymentResult, OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { Logo, LogoIcon } from '../components/brand'
|
||||
import {
|
||||
ConfirmationStep,
|
||||
FirstGroupStep,
|
||||
PaymentStep,
|
||||
Stepper,
|
||||
WelcomeStep,
|
||||
} from '../components/onboarding'
|
||||
import { PAYMENT_PROVIDERS } from '../components/onboarding/constants'
|
||||
import { Button } from '../components/ui'
|
||||
import { useAuth } from '../context/AuthProvider'
|
||||
import { getOnboardingStatus } from '../lib/api'
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Bienvenida' },
|
||||
{ label: 'Cobros' },
|
||||
{ label: 'Tu grupo' },
|
||||
{ label: 'Listo' },
|
||||
]
|
||||
|
||||
export function OnboardingView() {
|
||||
const { user, isPending: authPending } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
const [paymentResult, setPaymentResult] = useState<ConnectPaymentResult | null>(null)
|
||||
const [createdGroup, setCreatedGroup] = useState<OnboardingGroupDto | null>(null)
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['onboarding', 'status'],
|
||||
queryFn: getOnboardingStatus,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const status = statusQuery.data
|
||||
if (!status) return
|
||||
if (status.completed) {
|
||||
void navigate({ to: '/' })
|
||||
return
|
||||
}
|
||||
// Si interrumpió después de conectar el cobro, retomamos en el paso del grupo.
|
||||
if (status.step === 'PAYMENT_CONNECTED') {
|
||||
setStep((current) => (current === 0 ? 2 : current))
|
||||
}
|
||||
}, [statusQuery.data, navigate])
|
||||
|
||||
if (authPending) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-4 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||
<LogoIcon className="size-8 text-accent" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-primary">Tu cuenta, lista</h1>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Iniciá sesión para configurar tus cobros y crear tu primer grupo.
|
||||
</p>
|
||||
<Button variant="primary" className="w-full" onClick={() => void navigate({ to: '/login' })}>
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusQuery.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a'
|
||||
const providerName = paymentResult
|
||||
? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-md flex-col px-4 pb-12 pt-8 sm:pt-12">
|
||||
<header className="mb-6 flex items-center justify-center gap-2">
|
||||
<LogoIcon className="size-7" />
|
||||
<Logo className="text-xl" />
|
||||
</header>
|
||||
|
||||
<Stepper steps={STEPS} current={step} />
|
||||
|
||||
<div key={step} className="mt-8 animate-step-enter">
|
||||
{step === 0 ? (
|
||||
<WelcomeStep name={firstName} onNext={() => setStep(1)} />
|
||||
) : null}
|
||||
{step === 1 ? (
|
||||
<PaymentStep
|
||||
initialResult={paymentResult}
|
||||
onConnected={(result) => {
|
||||
setPaymentResult(result)
|
||||
setStep(2)
|
||||
}}
|
||||
onBack={() => setStep(0)}
|
||||
/>
|
||||
) : null}
|
||||
{step === 2 ? (
|
||||
<FirstGroupStep
|
||||
onBack={() => setStep(1)}
|
||||
onCompleted={(group) => {
|
||||
setCreatedGroup(group)
|
||||
setStep(3)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === 3 && createdGroup ? (
|
||||
<ConfirmationStep group={createdGroup} providerName={providerName} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user