Migrate to file based routing on frontendf
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo } from '../brand'
|
||||
|
||||
export type AuthShellProps = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthShell({ title, subtitle, children }: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<Logo className="h-10" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-xl font-bold text-primary">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AuthShell } from './AuthShell'
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared'
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button, Input, Label } from '../ui'
|
||||
import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants'
|
||||
|
||||
const defaultValues: CreateFirstGroup = {
|
||||
name: '',
|
||||
days: [],
|
||||
time: '09:00',
|
||||
capacity: 1,
|
||||
price: 0,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 1,
|
||||
}
|
||||
|
||||
type GroupFormProps = {
|
||||
heading: string
|
||||
description: string
|
||||
submitLabel: string
|
||||
isSubmitting: boolean
|
||||
errorMessage?: string | null
|
||||
onSubmit: (values: CreateFirstGroup) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function GroupForm({
|
||||
heading,
|
||||
description,
|
||||
submitLabel,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: GroupFormProps) {
|
||||
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 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(onSubmit)} noValidate className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">{heading}</h2>
|
||||
<p className="text-sm text-foreground/60">{description}</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-on-accent'
|
||||
: 'border-border bg-surface 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-surface 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>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{onBack ? (
|
||||
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
{isSubmitting ? 'Creando…' : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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 miembros.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface 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 ?? '—'} miembros</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>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { createFirstGroup } from '../../lib/api'
|
||||
import { GroupForm } from '../groups/GroupForm'
|
||||
|
||||
type FirstGroupStepProps = {
|
||||
onBack: () => void
|
||||
onCompleted: (group: OnboardingGroupDto) => void
|
||||
}
|
||||
|
||||
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
|
||||
onSuccess: (result) => onCompleted(result.group),
|
||||
})
|
||||
|
||||
return (
|
||||
<GroupForm
|
||||
heading="Tu primer grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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-surface">
|
||||
<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 miembros 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-surface 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-on-accent' : '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>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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-on-accent',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 miembros 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-surface 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>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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' },
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Stepper, type StepperProps } from './Stepper'
|
||||
export { WelcomeStep } from './WelcomeStep'
|
||||
export { PaymentStep } from './PaymentStep'
|
||||
export { FirstGroupStep } from './FirstGroupStep'
|
||||
export { ConfirmationStep } from './ConfirmationStep'
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import { CalendarX2, CheckCircle2, Loader2, Moon, Sun } from 'lucide-react'
|
||||
import { Badge, Button, useToast } from '../components/ui'
|
||||
import { Logo } from '../components/brand'
|
||||
import { useTheme } from '../context/ThemeProvider'
|
||||
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../lib/api'
|
||||
|
||||
export function AbsenceNotifyView() {
|
||||
const { token } = useParams({ strict: false }) as { token: string }
|
||||
const { setTheme, isDark } = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
const [locallyNotified, setLocallyNotified] = useState<string[]>([])
|
||||
|
||||
const detailsQuery = useQuery({
|
||||
queryKey: ['absence-details', token],
|
||||
queryFn: () => getAbsenceDetails(token),
|
||||
enabled: Boolean(token),
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const notifyMutation = useMutation({
|
||||
mutationFn: (sessionId: string) =>
|
||||
publicNotifyAbsence({ token, classSessionId: sessionId }),
|
||||
onSuccess: (_data, sessionId) => {
|
||||
setLocallyNotified((prev) => [...prev, sessionId])
|
||||
toast.success(
|
||||
'Tu profesor ya tiene registrado el aviso. Gracias por avisar.',
|
||||
'Ausencia avisada',
|
||||
)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError && error.problem?.status === 404) {
|
||||
toast.error('Este enlace ya no es válido. Consultá a tu profesor.')
|
||||
return
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'No pudimos registrar el aviso.',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const details = detailsQuery.data
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur-md sm:px-8">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo className="h-7 w-auto" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
className="rounded-xl p-2 text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
title={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{detailsQuery.isPending ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
<p className="text-sm text-foreground/60">Cargando tus clases...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailsQuery.isError || !details ? (
|
||||
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
|
||||
<h2 className="text-xl font-bold text-danger">Enlace no válido</h2>
|
||||
<p className="mx-auto max-w-sm text-sm leading-relaxed text-foreground/70">
|
||||
No pudimos identificarte con este enlace. Consultá con tu profesor para
|
||||
solicitar uno nuevo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{details ? (
|
||||
<div className="animate-step-enter space-y-5 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
|
||||
<div className="border-b border-border pb-5 text-center">
|
||||
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-accent-soft text-accent">
|
||||
<CalendarX2 className="size-7" />
|
||||
</div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Aviso de ausencia
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Hola, {details.fullName}</h1>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/80">
|
||||
Grupo: <span className="text-primary">{details.groupName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{details.sessions.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border bg-primary-soft/40 px-4 py-8 text-center text-sm text-foreground/60">
|
||||
No tenés clases programadas para hoy. Si necesitás avisar de todas formas,
|
||||
contactá a tu profesor.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{details.sessions.map((session) => {
|
||||
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
const notified =
|
||||
session.notified || locallyNotified.includes(session.sessionId)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={session.sessionId}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-border bg-primary-soft/30 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-primary">
|
||||
Hoy · {time} hs
|
||||
</p>
|
||||
<p className="truncate text-xs text-foreground/60">
|
||||
{session.groupName}
|
||||
</p>
|
||||
</div>
|
||||
{notified ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Ya avisaste
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={notifyMutation.isPending}
|
||||
onClick={() => notifyMutation.mutate(session.sessionId)}
|
||||
>
|
||||
{notifyMutation.isPending &&
|
||||
notifyMutation.variables === session.sessionId ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Avisar ausencia
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-foreground/50">
|
||||
Si avisás con anticipación, tu cupo se libera para una clase de recuperación.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border px-4 py-4 text-center text-xs text-foreground/40">
|
||||
Gruperly — Gestión sencilla de cobros y grupos
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import type { AttendeeStatus, StudentAtRiskDto } from '@gruperly/shared';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarCheck,
|
||||
DoorOpen,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
MoreVertical,
|
||||
Pause,
|
||||
Percent,
|
||||
UserMinus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Avatar, Badge, Button, Modal, useToast } from '../components/ui';
|
||||
import {
|
||||
getAttendeeHistory,
|
||||
getGroupAnalytics,
|
||||
getStudentsAtRisk,
|
||||
updateAttendeeStatus,
|
||||
} from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
type StatusAction = { student: StudentAtRiskDto; status: AttendeeStatus } | null;
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">{label}</p>
|
||||
<p className="truncate text-2xl font-bold text-primary">{value}</p>
|
||||
{hint ? <p className="text-xs text-foreground/50">{hint}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'Presente';
|
||||
case 'ABSENT':
|
||||
return 'Ausente';
|
||||
case 'EXCUSED':
|
||||
return 'Avisó ausencia';
|
||||
default:
|
||||
return 'Sin registrar';
|
||||
}
|
||||
}
|
||||
|
||||
function dotClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'bg-success';
|
||||
case 'ABSENT':
|
||||
return 'bg-danger';
|
||||
case 'EXCUSED':
|
||||
return 'bg-warning';
|
||||
default:
|
||||
return 'bg-border';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('es-MX', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalyticsView() {
|
||||
const { groupId } = useParams({ strict: false }) as { groupId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const [historyStudent, setHistoryStudent] = useState<StudentAtRiskDto | null>(null);
|
||||
const [statusAction, setStatusAction] = useState<StatusAction>(null);
|
||||
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
|
||||
|
||||
const analyticsQuery = useQuery({
|
||||
queryKey: ['group-analytics', groupId],
|
||||
queryFn: () => getGroupAnalytics(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
});
|
||||
|
||||
const riskQuery = useQuery({
|
||||
queryKey: ['students-at-risk', groupId],
|
||||
queryFn: () => getStudentsAtRisk(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['attendee-history', groupId, historyStudent?.attendeeId],
|
||||
queryFn: () => getAttendeeHistory(groupId, historyStudent!.attendeeId!),
|
||||
enabled: Boolean(groupId && historyStudent),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (action: Exclude<StatusAction, null>) =>
|
||||
updateAttendeeStatus(action.student.attendeeId, action.status),
|
||||
onSuccess: (_result, action) => {
|
||||
const name = action.student.fullName.split(' ')[0];
|
||||
toast.success(
|
||||
action.status === 'DROPPED'
|
||||
? `${name} fue dado de baja del grupo. Su vacante quedó disponible.`
|
||||
: `La vacante de ${name} quedó pausada.`,
|
||||
);
|
||||
setStatusAction(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['students-at-risk', groupId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['group', groupId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['home-summary'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['groups-risk-overview'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'No pudimos actualizar el estado del alumno.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleReengage = async (student: StudentAtRiskDto, groupName: string) => {
|
||||
if (!student.phone) return;
|
||||
const firstName = student.fullName.split(' ')[0] ?? student.fullName;
|
||||
const message =
|
||||
`¡Hola ${firstName}! 👋 Te extrañamos en ${groupName}. ` +
|
||||
'Notamos que no asististe a las últimas clases y queremos que vuelvas. ' +
|
||||
'Si tenés algún problema con tus horarios o tu membresía, escribinos y lo resolvemos juntos.';
|
||||
const waUrl = `https://wa.me/${student.phone.replace(/\D/g, '')}?text=${encodeURIComponent(message)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(message);
|
||||
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.');
|
||||
} catch {
|
||||
// Si clipboard falla, igual abrimos WhatsApp con el mensaje.
|
||||
}
|
||||
window.open(waUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const groupName = riskQuery.data?.groupName;
|
||||
|
||||
if (analyticsQuery.isPending || riskQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) {
|
||||
return (
|
||||
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
|
||||
<h2 className="text-lg font-semibold">No pudimos cargar las estadísticas</h2>
|
||||
<p className="mt-2 text-sm">Verificá tu conexión e intentá de nuevo.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
void analyticsQuery.refetch();
|
||||
void riskQuery.refetch();
|
||||
}}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const analytics = analyticsQuery.data;
|
||||
const atRisk = riskQuery.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md lg:max-w-6xl">
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId }}
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver a {groupName ?? 'el grupo'}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Estadísticas</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-xl bg-primary-soft px-3 py-1.5 text-xs font-semibold text-foreground/70">
|
||||
Últimos 30 días
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
icon={<Percent className="size-5" />}
|
||||
label="Asistencia promedio"
|
||||
value={`${analytics.attendanceRate}%`}
|
||||
hint="últimos 30 días"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarCheck className="size-5" />}
|
||||
label="Asistencias del mes"
|
||||
value={analytics.totalPresent.toLocaleString('es-MX')}
|
||||
hint={`${analytics.totalClasses} clases dictadas`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<DoorOpen className="size-5" />}
|
||||
label="Cupos recuperados"
|
||||
value={analytics.recoveredSlots.toLocaleString('es-MX')}
|
||||
hint="por ausencias avisadas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alumnos en riesgo */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-primary">Alumnos en riesgo</h2>
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant={atRisk.some((s) => s.riskLevel === 'HIGH') ? 'danger' : 'warning'}>
|
||||
{atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Alumnos con ausencias consecutivas o baja asistencia mensual.
|
||||
</p>
|
||||
|
||||
{atRisk.length === 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="font-medium text-primary">¡Sin alumnos en riesgo!</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
No hay ausencias consecutivas ni asistencia por debajo del 50%.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{atRisk.map((student) => (
|
||||
<li
|
||||
key={student.attendeeId}
|
||||
className="rounded-xl border border-border bg-surface p-4 transition-colors hover:border-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryStudent(student)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{student.fullName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-foreground/50">
|
||||
{student.phone ?? 'Sin teléfono'} · {student.monthlyAttendanceRate}% de
|
||||
asistencia mensual
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{student.riskLevel === 'HIGH' ? (
|
||||
<Badge variant="danger">
|
||||
{student.consecutiveAbsences} faltas seguidas
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">Baja asistencia</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!student.phone}
|
||||
onClick={() => void handleReengage(student, groupName ?? 'tu clase')}
|
||||
className="flex-1 gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] disabled:bg-foreground/10 disabled:text-foreground/40"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Reenganchar por WhatsApp
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Gestión de la vacante"
|
||||
onClick={() =>
|
||||
setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId)
|
||||
}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpenFor === student.attendeeId ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setMenuOpenFor(null)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 z-30 mt-1 w-44 rounded-xl border border-border bg-surface p-1.5 shadow-xl animate-fade-in">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusAction({ student, status: 'PAUSED' });
|
||||
setMenuOpenFor(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-primary transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<Pause className="size-4" />
|
||||
Pausar vacante
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusAction({ student, status: 'DROPPED' });
|
||||
setMenuOpenFor(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger-soft"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Dar de baja
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal: historial del alumno */}
|
||||
<Modal
|
||||
isOpen={historyStudent !== null}
|
||||
onClose={() => setHistoryStudent(null)}
|
||||
title={historyStudent?.fullName ?? 'Historial'}
|
||||
description={historyStudent ? 'Historial de presentismo de las últimas clases.' : undefined}
|
||||
maxWidth="md"
|
||||
>
|
||||
{historyStudent ? (
|
||||
<div>
|
||||
{historyQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : historyQuery.isError || !historyQuery.data ? (
|
||||
<div className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos cargar el historial de este alumno.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-primary-soft/50 p-3">
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{historyQuery.data.attendanceRate}%
|
||||
</span>
|
||||
<span className="text-xs font-medium uppercase text-foreground/60">
|
||||
Presentismo general ({historyQuery.data.sessions.length} clases)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{historyQuery.data.sessions.map((session) => (
|
||||
<li
|
||||
key={session.classSessionId}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2.5 shrink-0 rounded-full',
|
||||
dotClass(session.status ?? null),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-primary">
|
||||
{formatSessionDate(session.startsAt)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
session.status === 'PRESENT'
|
||||
? 'text-success'
|
||||
: session.status === 'ABSENT'
|
||||
? 'text-danger'
|
||||
: session.status === 'EXCUSED'
|
||||
? 'text-warning'
|
||||
: 'text-foreground/50',
|
||||
)}
|
||||
>
|
||||
{statusLabel(session.status)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* Modal: confirmar cambio de estado */}
|
||||
<Modal
|
||||
isOpen={statusAction !== null}
|
||||
onClose={() => setStatusAction(null)}
|
||||
title={statusAction?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}
|
||||
maxWidth="md"
|
||||
>
|
||||
{statusAction ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{statusAction.student.fullName}{' '}
|
||||
{statusAction.status === 'DROPPED' ? (
|
||||
<>
|
||||
dejará de asistir al grupo y <strong className="text-primary">{groupName}</strong>. Su
|
||||
vacante quedará <strong className="text-primary">disponible</strong> para otro alumno, y
|
||||
su historial de asistencia se conservará.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
será <strong className="text-primary">pausado</strong> en{' '}
|
||||
<strong className="text-primary">{groupName}</strong>. Su vacante quedará{' '}
|
||||
<strong className="text-primary">libre</strong> y podés reactivarla en cualquier
|
||||
momento.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setStatusAction(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={cn(
|
||||
statusAction.status === 'DROPPED' &&
|
||||
'border border-danger/20 bg-danger text-white hover:bg-danger/90',
|
||||
)}
|
||||
disabled={statusMutation.isPending}
|
||||
onClick={() => statusMutation.mutate(statusAction)}
|
||||
>
|
||||
{statusMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : statusAction.status === 'DROPPED' ? (
|
||||
<UserMinus className="size-4" />
|
||||
) : (
|
||||
<Pause className="size-4" />
|
||||
)}
|
||||
<span>{statusAction.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Fingerprint, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email, password }) => {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo iniciar sesión' })
|
||||
return
|
||||
}
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
const handlePasskey = async () => {
|
||||
const { error, data } = await authClient.signIn.passkey()
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo autenticar con passkey' })
|
||||
return
|
||||
}
|
||||
if (data) navigate({ to: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Iniciar sesión" subtitle="Accedé a tus cobros grupales">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={handlePasskey}>
|
||||
<Fingerprint className="size-4" />
|
||||
Entrar con passkey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿No tenés cuenta?{' '}
|
||||
<Link to="/signup" className="font-medium text-accent hover:underline">
|
||||
Registrate
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(2, 'Ingresá tu nombre'),
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((v) => v.password === v.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ name, email, password }) => {
|
||||
const { error, data } = await authClient.signUp.email(
|
||||
{ name, email, password },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await authClient.getSession()
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo registrar' })
|
||||
return
|
||||
}
|
||||
if (data?.token) {
|
||||
// Con email verification activo, la sesión no se crea hasta verificar.
|
||||
navigate({ to: '/verify-email', search: { email } })
|
||||
}
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Crear cuenta" subtitle="Empezá a cobrar en grupo en minutos">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="name"
|
||||
placeholder="Nombre completo"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Repetí la contraseña</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.confirmPassword}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.confirmPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Creando…' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<Link to="/login" className="font-medium text-accent hover:underline">
|
||||
Iniciá sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
type SearchParams = {
|
||||
token?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'verifying' | 'success' | 'error'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const { token, email } = useSearch({ strict: false }) as SearchParams
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<Status>(token ? 'verifying' : 'idle')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || status !== 'verifying') return
|
||||
let cancelled = false
|
||||
authClient
|
||||
.verifyEmail({ query: { token } })
|
||||
.then(async ({ error }) => {
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
await authClient.getSession()
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token, status, navigate])
|
||||
|
||||
const title =
|
||||
status === 'success'
|
||||
? 'Email verificado'
|
||||
: status === 'error'
|
||||
? 'Vínculo inválido'
|
||||
: 'Verificá tu email'
|
||||
|
||||
return (
|
||||
<AuthShell title={title}>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
{status === 'verifying' ? (
|
||||
<Loader2 className="size-10 animate-spin text-accent" />
|
||||
) : status === 'success' ? (
|
||||
<MailCheck className="size-10 text-success" />
|
||||
) : (
|
||||
<MailWarning className="size-10 text-warning" />
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Tu cuenta quedó verificada. Te estamos llevando a Gruperly…
|
||||
</p>
|
||||
) : status === 'error' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
El vínculo de verificación no es válido o expiró.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Te enviamos un correo a <span className="font-medium text-primary">{email}</span> para
|
||||
confirmar tu cuenta.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-on-accent hover:bg-accent-strong"
|
||||
>
|
||||
Ir a Gruperly
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{status === 'error' ? (
|
||||
<Link to="/login" className="text-sm font-medium text-accent hover:underline">
|
||||
Intentar iniciar sesión
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import type { SessionStudentDto } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2, X } from 'lucide-react'
|
||||
import { Avatar, Badge, Button, useToast } from '../components/ui'
|
||||
import { getSessionStudents, markAttendance } from '../lib/api'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
type AttendanceChoice = 'PRESENT' | 'ABSENT'
|
||||
|
||||
function isLocked(student: SessionStudentDto): boolean {
|
||||
return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED'
|
||||
}
|
||||
|
||||
function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) {
|
||||
return status === 'UP_TO_DATE' ? (
|
||||
<Badge variant="success">Al día</Badge>
|
||||
) : (
|
||||
<Badge variant="danger">Pendiente</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function AttendanceToggle({
|
||||
choice,
|
||||
disabled,
|
||||
onToggle,
|
||||
fullName,
|
||||
}: {
|
||||
choice: AttendanceChoice
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
fullName: string
|
||||
}) {
|
||||
const isPresent = choice === 'PRESENT'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isPresent}
|
||||
aria-label={`${isPresent ? 'Presente' : 'Ausente'}: ${fullName}`}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'relative inline-flex h-11 w-14 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isPresent ? 'bg-success' : 'bg-danger',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-9 items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-150',
|
||||
isPresent ? 'translate-x-3' : 'translate-x-1',
|
||||
)}
|
||||
>
|
||||
{isPresent ? (
|
||||
<Check className="size-5 text-success" aria-hidden />
|
||||
) : (
|
||||
<X className="size-5 text-danger" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentRow({
|
||||
student,
|
||||
choice,
|
||||
onToggle,
|
||||
}: {
|
||||
student: SessionStudentDto
|
||||
choice: AttendanceChoice
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const locked = isLocked(student)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border bg-surface px-4 py-3',
|
||||
locked ? 'border-warning/30 bg-warning-soft/30' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<PaymentBadge status={student.paymentStatus} />
|
||||
{locked ? <Badge variant="warning">Avisó ausencia</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<AttendanceToggle
|
||||
choice={choice}
|
||||
disabled={locked}
|
||||
onToggle={onToggle}
|
||||
fullName={student.fullName}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassAttendanceView() {
|
||||
const { sessionId } = useParams({ strict: false }) as { sessionId: string }
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const studentsQuery = useQuery({
|
||||
queryKey: ['class-students', sessionId],
|
||||
queryFn: () => getSessionStudents(sessionId),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const students = studentsQuery.data?.students
|
||||
|
||||
// Estado local de la toma: todos parten PRESENT (1-tap para marcar ausentes).
|
||||
// Alumnas con ausencia avisada quedan bloqueadas en EXCUSED.
|
||||
const [choices, setChoices] = useState<Record<string, AttendanceChoice>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!students) return
|
||||
const initial: Record<string, AttendanceChoice> = {}
|
||||
for (const student of students) {
|
||||
if (isLocked(student)) continue
|
||||
initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT'
|
||||
}
|
||||
setChoices(initial)
|
||||
}, [students])
|
||||
|
||||
const presentCount = useMemo(
|
||||
() =>
|
||||
(students ?? []).filter(
|
||||
(student) => !isLocked(student) && choices[student.attendeeId] === 'PRESENT',
|
||||
).length,
|
||||
[students, choices],
|
||||
)
|
||||
|
||||
const toggleStudent = (attendeeId: string) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT',
|
||||
}))
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const records = (students ?? [])
|
||||
.filter((student) => !isLocked(student))
|
||||
.map((student) => ({
|
||||
attendeeId: student.attendeeId,
|
||||
status: choices[student.attendeeId] ?? 'PRESENT',
|
||||
}))
|
||||
return markAttendance(sessionId, { classSessionId: sessionId, records })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Asistencia guardada correctamente.', 'Listo')
|
||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['class-students', sessionId] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'No pudimos guardar la asistencia.')
|
||||
},
|
||||
})
|
||||
|
||||
const sessionInfo = studentsQuery.data
|
||||
const startTime = sessionInfo
|
||||
? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver al inicio</span>
|
||||
</Link>
|
||||
|
||||
{studentsQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{studentsQuery.isError ? (
|
||||
<div className="mt-4 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar la lista de alumnos.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void studentsQuery.refetch()}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sessionInfo ? (
|
||||
<>
|
||||
<header className="sticky top-16 z-30 -mx-4 border-b border-border bg-background/95 px-4 py-3 backdrop-blur lg:mx-0 lg:rounded-xl lg:border lg:px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold text-primary">
|
||||
{sessionInfo.groupName}
|
||||
</h1>
|
||||
<p className="text-xs text-foreground/60">
|
||||
{startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="shrink-0 text-sm">
|
||||
{presentCount}/{sessionInfo.students.length} presentes
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sessionInfo.students.length === 0 ? (
|
||||
<div className="mt-6 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-primary">Este grupo no tiene alumnos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá miembros desde la ficha del grupo para tomar asistencia.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3 pb-2">
|
||||
{sessionInfo.students.map((student) => (
|
||||
<StudentRow
|
||||
key={student.attendeeId}
|
||||
student={student}
|
||||
choice={choices[student.attendeeId] ?? 'PRESENT'}
|
||||
onToggle={() => toggleStudent(student.attendeeId)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sessionInfo.students.length > 0 ? (
|
||||
<div className="sticky bottom-20 z-30 mt-6 lg:bottom-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 w-full text-base font-semibold shadow-lg"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Guardar Asistencia
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, Link } from '@tanstack/react-router'
|
||||
import type { CreateFirstGroup } from '@gruperly/shared'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { GroupForm } from '../components/groups/GroupForm'
|
||||
import { createGroup } from '../lib/api'
|
||||
|
||||
export function CreateGroupView() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createGroup(values),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||
void navigate({ to: '/groups/$groupId', params: { groupId: result.group.id } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md">
|
||||
<Link to="/groups" className="hidden mb-6 items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex">
|
||||
<ChevronLeft className="size-4" />
|
||||
Grupos
|
||||
</Link>
|
||||
|
||||
<GroupForm
|
||||
heading="Nuevo grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CalendarClock, Loader2, Plus, Users } from 'lucide-react'
|
||||
import type { GroupDto, GroupRiskLevel } from '@gruperly/shared'
|
||||
import { Badge, Button } from '../components/ui'
|
||||
import { getGroups, getGroupsRiskOverview } from '../lib/api'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const RISK_LABELS: Record<Exclude<GroupRiskLevel, 'NONE'>, string> = {
|
||||
HIGH: 'Riesgo alto',
|
||||
MEDIUM: 'Riesgo moderado',
|
||||
}
|
||||
|
||||
function GroupCard({
|
||||
group,
|
||||
riskLevel,
|
||||
}: {
|
||||
group: GroupDto
|
||||
riskLevel: GroupRiskLevel
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const hasSchedule = (group.days?.length ?? 0) > 0
|
||||
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/40">
|
||||
<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>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{hasRisk ? (
|
||||
<Badge variant={riskLevel === 'HIGH' ? 'danger' : 'warning'} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
riskLevel === 'HIGH' ? 'bg-danger' : 'bg-warning',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{RISK_LABELS[riskLevel]}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="success">Activo</Badge>
|
||||
</div>
|
||||
</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 ?? '—'} miembros
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${group.id}` })}
|
||||
>
|
||||
Gestionar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function GroupsView() {
|
||||
const navigate = useNavigate()
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
const riskQuery = useQuery({
|
||||
queryKey: ['groups-risk-overview'],
|
||||
queryFn: getGroupsRiskOverview,
|
||||
})
|
||||
|
||||
const riskByGroup = new Map(
|
||||
(riskQuery.data?.items ?? []).map((item) => [item.groupId, item.riskLevel]),
|
||||
)
|
||||
|
||||
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: '/groups/new' })}>
|
||||
<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-surface 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: '/groups/new' })}
|
||||
>
|
||||
<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}
|
||||
riskLevel={riskByGroup.get(group.id) ?? 'NONE'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
CalendarClock,
|
||||
ClipboardCheck,
|
||||
Loader2,
|
||||
Plus,
|
||||
UserCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
ClassTodayDto,
|
||||
HomeSummaryDto,
|
||||
NextClassDto,
|
||||
PaymentDto,
|
||||
UpcomingPaymentDto,
|
||||
} from '@gruperly/shared'
|
||||
import { Badge, Button } from '../components/ui'
|
||||
import { getClassesToday, getHomeSummary } from '../lib/api'
|
||||
import {
|
||||
formatPrice,
|
||||
formatRelativeDateTime,
|
||||
formatSchedule,
|
||||
PAYMENT_STATUS_LABELS,
|
||||
} from '../lib/format'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const PAYMENT_BADGE_VARIANT: Record<
|
||||
PaymentDto['status'],
|
||||
'success' | 'warning' | 'danger' | 'neutral'
|
||||
> = {
|
||||
PENDING: 'warning',
|
||||
OVERDUE: 'danger',
|
||||
PAID: 'success',
|
||||
CANCELLED: 'neutral',
|
||||
}
|
||||
|
||||
function TodayClassCard({ item }: { item: ClassTodayDto }) {
|
||||
const navigate = useNavigate()
|
||||
const time = new Date(item.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
|
||||
Clase de hoy
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-xl font-bold text-primary">{item.groupName}</h2>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>Hoy · {time} hs</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-foreground/60">
|
||||
{item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'}
|
||||
{item.availableSlots != null ? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={item.hasAttendance ? 'success' : 'neutral'}>
|
||||
{item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${item.groupId}` })}
|
||||
>
|
||||
Ver grupo
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/class/${item.sessionId}/attendance` })}
|
||||
>
|
||||
<ClipboardCheck className="size-4" />
|
||||
Tomar Asistencia
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
|
||||
const navigate = useNavigate()
|
||||
const hasSchedule = (nextClass.days?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
|
||||
{nextClass.isNow ? 'Clase en curso' : 'Próxima clase'}
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-xl font-bold text-primary">{nextClass.name}</h2>
|
||||
{hasSchedule ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(nextClass.days ?? [], nextClass.time)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={nextClass.isNow ? 'success' : 'neutral'}>
|
||||
{nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${nextClass.groupId}` })}
|
||||
>
|
||||
Ver grupo
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) {
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">{payment.attendeeName}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-foreground/60">
|
||||
{payment.groupName} · {formatRelativeDateTime(payment.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{formatPrice(payment.amount)}</span>
|
||||
<Badge variant={PAYMENT_BADGE_VARIANT[payment.status]}>
|
||||
{PAYMENT_STATUS_LABELS[payment.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStats({ summary }: { summary: HomeSummaryDto }) {
|
||||
const navigate = useNavigate()
|
||||
const stats: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
icon: LucideIcon
|
||||
to: '/groups' | '/payments'
|
||||
}[] = [
|
||||
{
|
||||
label: 'Grupos activos',
|
||||
value: String(summary.stats.groups),
|
||||
icon: Users,
|
||||
to: '/groups',
|
||||
},
|
||||
{
|
||||
label: 'Cobros pendientes',
|
||||
value: summary.stats.pendingAmount > 0 ? formatPrice(summary.stats.pendingAmount) : '0',
|
||||
sub: `${summary.stats.pendingPayments} por cobrar`,
|
||||
icon: Wallet,
|
||||
to: '/payments',
|
||||
},
|
||||
{
|
||||
label: 'Asistentes',
|
||||
value: String(summary.stats.attendees),
|
||||
icon: UserCheck,
|
||||
to: '/groups',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<button
|
||||
key={stat.label}
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: stat.to })}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border border-border bg-surface p-4 text-left transition-all',
|
||||
'hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xl font-bold leading-tight text-primary">{stat.value}</span>
|
||||
<span className="block truncate text-xs text-foreground/60">
|
||||
{stat.sub ?? stat.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeView() {
|
||||
const navigate = useNavigate()
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['home-summary'],
|
||||
queryFn: getHomeSummary,
|
||||
})
|
||||
const classesTodayQuery = useQuery({
|
||||
queryKey: ['classes-today'],
|
||||
queryFn: getClassesToday,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const todayClasses = classesTodayQuery.data?.data ?? []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Inicio</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tu actividad de cobros de un vistazo.</p>
|
||||
</div>
|
||||
|
||||
{summaryQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.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 tu resumen.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void summaryQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface 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: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{todayClasses.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{todayClasses.map((classItem) => (
|
||||
<TodayClassCard key={classItem.sessionId} item={classItem} />
|
||||
))}
|
||||
</div>
|
||||
) : summaryQuery.data.nextClass ? (
|
||||
<NextClassHero nextClass={summaryQuery.data.nextClass} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-primary">Sin clases programadas</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá días y horario a tus grupos para ver tu próxima clase acá.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryStats summary={summaryQuery.data} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-primary">Próximos cobros</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: '/payments' })}
|
||||
>
|
||||
Ver todos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summaryQuery.data.upcomingPayments.length > 0 ? (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{summaryQuery.data.upcomingPayments.map((payment) => (
|
||||
<UpcomingPaymentRow key={payment.id} payment={payment} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-border bg-surface px-4 py-6 text-center text-sm text-foreground/60">
|
||||
Sin cobros pendientes por ahora.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useParams, Link } from '@tanstack/react-router';
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Badge, Button, Input, Label } from '../components/ui';
|
||||
import { Logo } from '../components/brand';
|
||||
import { useTheme } from '../context/ThemeProvider';
|
||||
import { getInviteInfo, joinViaInvite, ApiError } from '../lib/api';
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format';
|
||||
|
||||
export function JoinGroupView() {
|
||||
const { token } = useParams({ strict: false }) as { token: string };
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Success state
|
||||
const [registeredAttendee, setRegisteredAttendee] = useState<{
|
||||
fullName: string;
|
||||
phone: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Waitlist state (group is full)
|
||||
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
||||
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: ['public-invite', token],
|
||||
queryFn: () => getInviteInfo(token),
|
||||
enabled: Boolean(token),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const joinMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
joinViaInvite(token, {
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setErrorMessage(null);
|
||||
if (data.status === 'waitlisted') {
|
||||
setRegisteredAttendee(null);
|
||||
setWaitlistInfo({ message: data.message });
|
||||
return;
|
||||
}
|
||||
setWaitlistInfo(null);
|
||||
setRegisteredAttendee({
|
||||
fullName: data.attendee!.fullName,
|
||||
phone: data.attendee!.phone,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.problem?.code === 'attendee_already_registered') {
|
||||
setErrorMessage(
|
||||
'Ya te encuentras registrado en este grupo con ese número de teléfono. El profesor ya tiene tus datos.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(error.message || 'Error al procesar la inscripción.');
|
||||
} else if (error instanceof Error) {
|
||||
setErrorMessage(error.message);
|
||||
} else {
|
||||
setErrorMessage('Ocurrió un error inesperado. Por favor, intenta de nuevo.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!firstName.trim() || !lastName.trim() || !phone.trim()) {
|
||||
setErrorMessage('Por favor completa los campos requeridos (Nombre, Apellido y Teléfono).');
|
||||
return;
|
||||
}
|
||||
|
||||
joinMutation.mutate();
|
||||
};
|
||||
|
||||
const group = inviteQuery.data;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-primary flex flex-col justify-between selection:bg-accent selection:text-white">
|
||||
{/* Public Header */}
|
||||
<header className="border-b border-border bg-surface/80 backdrop-blur-md sticky top-0 z-10 px-4 py-3 sm:px-8 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo className="h-7 w-auto" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
className="p-2 rounded-xl text-foreground/70 hover:text-primary hover:bg-primary-soft transition-colors"
|
||||
title={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-4 sm:p-6 md:p-10">
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{inviteQuery.isPending ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
<p className="text-sm text-foreground/60">Cargando información del grupo...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inviteQuery.isError || !group ? (
|
||||
<div className="rounded-2xl border border-danger/20 bg-danger-soft p-6 sm:p-8 text-center space-y-3">
|
||||
<h2 className="text-xl font-bold text-danger">Enlace no válido o expirado</h2>
|
||||
<p className="text-sm text-foreground/70 max-w-sm mx-auto">
|
||||
No pudimos encontrar este grupo. Por favor consulta con tu profesor para solicitar un nuevo enlace de invitación.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Registration Success Confirmation */}
|
||||
{registeredAttendee && group ? (
|
||||
<div className="rounded-2xl border border-success/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
||||
<div className="size-16 rounded-full bg-success-soft text-success flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="success" className="mb-2">
|
||||
Registro Confirmado
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">¡Inscripción Exitosa!</h1>
|
||||
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
||||
Tus datos han sido registrados con éxito en el grupo{' '}
|
||||
<strong className="text-primary font-semibold">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-primary-soft/50 p-4 text-left text-xs space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Profesor:</span>
|
||||
<span className="font-semibold text-primary">{group.teacherName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Alumno:</span>
|
||||
<span className="font-semibold text-primary">{registeredAttendee.fullName}</span>
|
||||
</div>
|
||||
{registeredAttendee.phone ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Teléfono registrado:</span>
|
||||
<span className="font-semibold text-primary">{registeredAttendee.phone}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo a la brevedad por WhatsApp para darte la bienvenida y coordinar los detalles de la clase.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Waitlist Confirmation (group full) */}
|
||||
{waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
||||
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||
<Clock className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Lista de Espera
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
||||
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
||||
{waitlistInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
||||
<strong className="text-primary font-semibold">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Inscription Form */}
|
||||
{!registeredAttendee && !waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
||||
{/* Group Info Header */}
|
||||
<div className="border-b border-border pb-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="neutral" className="gap-1 text-[11px]">
|
||||
<GraduationCap className="size-3.5 text-accent" />
|
||||
<span>Invitación Oficial</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/80">
|
||||
Profesor: <span className="text-primary font-semibold">{group.teacherName}</span>
|
||||
</p>
|
||||
{group.description ? (
|
||||
<p className="mt-2 text-sm text-foreground/60">{group.description}</p>
|
||||
) : null}
|
||||
|
||||
{/* Schedule & Price Details */}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
{(group.days?.length ?? 0) > 0 ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
||||
<CalendarClock className="size-3.5 text-accent" />
|
||||
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{group.price != null ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80 font-medium">
|
||||
<span>{formatPrice(group.price)}</span>
|
||||
{group.billingType ? <span>· {BILLING_LABELS[group.billingType]}</span> : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{group.capacity ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
||||
<Users className="size-3.5 text-accent" />
|
||||
<span>Cupo {group.capacity}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Header */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-primary">Completa tus datos</h2>
|
||||
<p className="text-xs text-foreground/60 mt-0.5">
|
||||
Ingresa tus datos para registrarte en la lista de alumnos de este grupo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{errorMessage ? (
|
||||
<div className="rounded-xl border border-danger/30 bg-danger-soft p-3 text-xs text-danger font-medium animate-fade-in">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="student-first-name" className="mb-1 block text-xs">
|
||||
Nombre <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-first-name"
|
||||
placeholder="Ej. Sofía"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-last-name" className="mb-1 block text-xs">
|
||||
Apellido <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-last-name"
|
||||
placeholder="Ej. Fernández"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-phone" className="mb-1 block text-xs">
|
||||
Teléfono / WhatsApp <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-phone"
|
||||
placeholder="Ej. +54 9 11 2345-6789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-foreground/50">
|
||||
El profesor se comunicará contigo por WhatsApp a este número.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-email" className="mb-1 block text-xs">
|
||||
Email (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="student-email"
|
||||
type="email"
|
||||
placeholder="alumno@ejemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={joinMutation.isPending}
|
||||
className="w-full py-2.5 text-sm font-semibold gap-2"
|
||||
>
|
||||
{joinMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
<span>Completar Inscripción</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border py-4 px-4 text-center text-xs text-foreground/40">
|
||||
Gruperly — Gestión sencilla de cobros y grupos
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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" />
|
||||
</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">
|
||||
<Logo className="h-6" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export function PaymentsView() {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Cobros</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Sigue los pagos de tus grupos.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useAuth } from '../context/AuthProvider'
|
||||
import { Avatar } from '../components/ui'
|
||||
|
||||
export function ProfileView() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Mi perfil</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold text-primary">
|
||||
{user?.name ?? 'Usuario'}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/50">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Fingerprint, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { authClient } from '../lib/auth-client'
|
||||
import { Button, Input, Label } from '../components/ui'
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Ingresá tu contraseña actual'),
|
||||
newPassword: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
.refine((v) => v.currentPassword !== v.newPassword, {
|
||||
message: 'La nueva contraseña debe ser distinta',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
|
||||
type ChangePasswordValues = z.infer<typeof changePasswordSchema>
|
||||
|
||||
function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
|
||||
const { data, isPending } = authClient.useListPasskeys()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id)
|
||||
const { error } = await authClient.$fetch('/passkey/delete-passkey', {
|
||||
method: 'POST',
|
||||
body: { id },
|
||||
})
|
||||
setDeletingId(null)
|
||||
if (!error) onRefresh()
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
||||
}
|
||||
|
||||
const passkeys = data ?? []
|
||||
if (passkeys.length === 0) {
|
||||
return <p className="text-sm text-foreground/60">Todavía no registraste ninguna passkey.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">
|
||||
{passkey.name ?? 'Passkey'}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
{passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '}
|
||||
{new Date(passkey.createdAt).toLocaleDateString('es-AR')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Eliminar passkey"
|
||||
disabled={deletingId === passkey.id}
|
||||
onClick={() => handleDelete(passkey.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-danger" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecurityPage() {
|
||||
const [passkeyKeys, setPasskeyKeys] = useState(0)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ChangePasswordValues>({ resolver: zodResolver(changePasswordSchema) })
|
||||
|
||||
const refreshPasskeys = () => {
|
||||
setPasskeyKeys((k) => k + 1)
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.passkey.addPasskey()
|
||||
if (error) setFeedback(`No se pudo agregar: ${error.message}`)
|
||||
else {
|
||||
setFeedback('Passkey registrada correctamente.')
|
||||
refreshPasskeys()
|
||||
}
|
||||
}
|
||||
|
||||
const onChangePassword = handleSubmit(async ({ currentPassword, newPassword }) => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
if (error) {
|
||||
setError('currentPassword', { message: error.message ?? 'No se pudo cambiar la contraseña' })
|
||||
return
|
||||
}
|
||||
reset({ currentPassword: '', newPassword: '' })
|
||||
setFeedback('Contraseña actualizada.')
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Seguridad</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Gestioná tu contraseña y tus llaves de acceso (passkeys).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
|
||||
</div>
|
||||
<form onSubmit={onChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="currentPassword">Contraseña actual</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
invalid={!!errors.currentPassword}
|
||||
{...register('currentPassword')}
|
||||
/>
|
||||
{errors.currentPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.currentPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
invalid={!!errors.newPassword}
|
||||
{...register('newPassword')}
|
||||
/>
|
||||
{errors.newPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.newPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Actualizar contraseña
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Passkeys</h2>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddPasskey}>
|
||||
<Plus className="size-4" />
|
||||
Registrar
|
||||
</Button>
|
||||
</div>
|
||||
<PasskeyList key={passkeyKeys} onRefresh={refreshPasskeys} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { signOut } from '../lib/auth-client'
|
||||
import { Button } from '../components/ui'
|
||||
import { useTheme, type Theme } from '../context/ThemeProvider'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const THEME_OPTIONS: { value: Theme; label: string; description: string }[] = [
|
||||
{ value: 'light', label: 'Claro', description: 'Interfaz en tonos claros' },
|
||||
{ value: 'dark', label: 'Oscuro', description: 'Interfaz en tonos oscuros' },
|
||||
{ value: 'system', label: 'Sistema', description: 'Sigue la preferencia de tu dispositivo' },
|
||||
]
|
||||
|
||||
const THEME_ICONS: Record<Theme, typeof Sun> = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
}
|
||||
|
||||
function AppearanceCard() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-primary">Apariencia</p>
|
||||
<p className="text-xs text-foreground/50">Tema claro u oscuro</p>
|
||||
</div>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = THEME_ICONS[option.value]
|
||||
const isSelected = theme === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-lg p-2',
|
||||
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('size-4', isSelected ? 'text-accent-fg' : 'text-foreground/70')}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-sm font-medium', isSelected ? 'text-accent-fg' : 'text-primary')}>
|
||||
{option.label}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">{option.description}</p>
|
||||
</div>
|
||||
{isSelected ? <Check className="size-4 text-accent-fg" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const handleSignOut = async () => {
|
||||
await signOut({
|
||||
fetchOptions: { headers: { 'Cache-Control': 'no-cache' } },
|
||||
})
|
||||
window.location.assign('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent-fg" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Seguridad</p>
|
||||
<p className="text-xs text-foreground/50">Contraseña y passkeys</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AppearanceCard />
|
||||
|
||||
<Button variant="outline" onClick={handleSignOut}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user