diff --git a/AGENTS.md b/AGENTS.md index 2205d2d..35332c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,8 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push - **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`. - **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio. - **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`). -- **Router NO es file-based** (aunque `stack.md` lo diga): las rutas se declaran manualmente en `apps/web/src/router.tsx` con `createRoute` + `addChildren` y se registran vía module augmentation. **Cada vista nueva debe añadirse ahí.** +- **Router ES file-based** (TanStack Router + `@tanstack/router-plugin`): las rutas viven en `apps/web/src/routes/` y solo contienen definiciones (`createFileRoute` + `component`), sin lógica de UI. `src/router.tsx` solo crea el router con el `routeTree` importado de `src/routeTree.gen.ts` (**archivo generado por el plugin en build/dev, versionado en git**: si agregás/renombrás una ruta, corré `bun --filter @gruperly/web build` para regenerarlo). Las rutas autenticadas cuelgan del layout pathless `_authenticated.tsx` (`AppLayoutGuard` + `RootLayout` con ``). +- **Vistas en `apps/web/src/features//`**: un componente por archivo, nombre de archivo en PascalCase igual al componente, imports relativos (sin alias). El estado compartido padre-hijo vive en un `*Provider.tsx` del feature (contexto + queries/mutations de React Query) que se monta en el route file; los componentes hijos consumen `use()` y las forms locales mantienen su propio estado. - Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista. - **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos. - Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`. diff --git a/apps/web/package.json b/apps/web/package.json index d1620ca..64cac5e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "@gruperly/shared": "workspace:*", "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.62.0", - "@tanstack/react-router": "^1.90.0", + "@tanstack/react-router": "^1.170.38", "better-auth": "1.7.2", "clsx": "^2.1.1", "lucide-react": "^1.34.0", @@ -27,6 +27,7 @@ "devDependencies": { "@gruperly/config": "workspace:*", "@tailwindcss/vite": "^4.3.3", + "@tanstack/router-plugin": "^1.168.40", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/apps/web/src/components/layout/AppLayoutGuard.tsx b/apps/web/src/components/layout/AppLayoutGuard.tsx index 787a58f..79f5718 100644 --- a/apps/web/src/components/layout/AppLayoutGuard.tsx +++ b/apps/web/src/components/layout/AppLayoutGuard.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { Loader2 } from 'lucide-react' @@ -6,7 +7,7 @@ import { useAuth } from '../../context/AuthProvider' import { getOnboardingStatus } from '../../lib/api' import { RootLayout } from './RootLayout' -export function AppLayoutGuard() { +export function AppLayoutGuard({ children }: { children: ReactNode }) { const { user, isPending } = useAuth() const navigate = useNavigate() @@ -30,5 +31,5 @@ export function AppLayoutGuard() { ) } - return + return {children} } \ No newline at end of file diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 163b3d4..c65fe7f 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -1,24 +1,8 @@ -import { Moon, Sun } from 'lucide-react' -import { useTheme } from '../../context/ThemeProvider' import { Logo } from '../brand' import { Breadcrumb } from './Breadcrumb' +import { ThemeToggle } from './ThemeToggle' import { UserMenu } from './UserMenu' -function ThemeToggle() { - const { isDark, setTheme } = useTheme() - - return ( - - ) -} - export function Header() { return (
diff --git a/apps/web/src/components/layout/PublicFooter.tsx b/apps/web/src/components/layout/PublicFooter.tsx new file mode 100644 index 0000000..6703443 --- /dev/null +++ b/apps/web/src/components/layout/PublicFooter.tsx @@ -0,0 +1,7 @@ +export function PublicFooter() { + return ( +
+ Gruperly — Gestión sencilla de cobros y grupos +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/PublicHeader.tsx b/apps/web/src/components/layout/PublicHeader.tsx new file mode 100644 index 0000000..0a1e06d --- /dev/null +++ b/apps/web/src/components/layout/PublicHeader.tsx @@ -0,0 +1,14 @@ +import { Link } from '@tanstack/react-router' +import { Logo } from '../brand' +import { ThemeToggle } from './ThemeToggle' + +export function PublicHeader() { + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/RootLayout.tsx b/apps/web/src/components/layout/RootLayout.tsx index 65ce4d7..bd04f01 100644 --- a/apps/web/src/components/layout/RootLayout.tsx +++ b/apps/web/src/components/layout/RootLayout.tsx @@ -1,9 +1,9 @@ -import { Outlet } from '@tanstack/react-router' +import type { ReactNode } from 'react' import { Header } from './Header' import { Sidebar } from './Sidebar' import { BottomNav } from './BottomNav' -export function RootLayout() { +export function RootLayout({ children }: { children: ReactNode }) { return (
@@ -11,12 +11,10 @@ export function RootLayout() {
-
- -
+
{children}
) -} \ No newline at end of file +} diff --git a/apps/web/src/components/layout/ThemeToggle.tsx b/apps/web/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..5e0b255 --- /dev/null +++ b/apps/web/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,17 @@ +import { Moon, Sun } from 'lucide-react' +import { useTheme } from '../../context/ThemeProvider' + +export function ThemeToggle() { + const { isDark, setTheme } = useTheme() + + return ( + + ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/index.ts b/apps/web/src/components/layout/index.ts index d21ba28..6fe960a 100644 --- a/apps/web/src/components/layout/index.ts +++ b/apps/web/src/components/layout/index.ts @@ -1,5 +1,8 @@ export { RootLayout } from './RootLayout' export { Header } from './Header' +export { ThemeToggle } from './ThemeToggle' +export { PublicHeader } from './PublicHeader' +export { PublicFooter } from './PublicFooter' export { Sidebar } from './Sidebar' export { BottomNav } from './BottomNav' export { NAV_ITEMS, type NavItem } from './nav-items' diff --git a/apps/web/src/features/absence-notify/AbsenceNotifyProvider.tsx b/apps/web/src/features/absence-notify/AbsenceNotifyProvider.tsx new file mode 100644 index 0000000..51e68d1 --- /dev/null +++ b/apps/web/src/features/absence-notify/AbsenceNotifyProvider.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useState } from 'react' +import type { ReactNode } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import type { AbsenceDetails } from '@gruperly/shared' +import { useToast } from '../../components/ui' +import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../../lib/api' + +type AbsenceNotifyContextValue = { + token: string + details: AbsenceDetails | undefined + detailsPending: boolean + detailsFailed: boolean + isNotified: (session: { notified: boolean; sessionId: string }) => boolean + isNotifying: (sessionId: string) => boolean + notify: (sessionId: string) => void +} + +const AbsenceNotifyContext = createContext(null) + +export function AbsenceNotifyProvider({ token, children }: { token: string; children: ReactNode }) { + const toast = useToast() + const [locallyNotified, setLocallyNotified] = useState([]) + + 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 value: AbsenceNotifyContextValue = { + token, + details: detailsQuery.data, + detailsPending: detailsQuery.isPending, + detailsFailed: detailsQuery.isError, + isNotified: (session) => session.notified || locallyNotified.includes(session.sessionId), + isNotifying: (sessionId) => notifyMutation.isPending && notifyMutation.variables === sessionId, + notify: (sessionId) => notifyMutation.mutate(sessionId), + } + + return {children} +} + +export function useAbsenceNotify() { + const context = useContext(AbsenceNotifyContext) + if (!context) { + throw new Error('useAbsenceNotify debe usarse dentro de ') + } + return context +} \ No newline at end of file diff --git a/apps/web/src/features/absence-notify/AbsenceNotifyView.tsx b/apps/web/src/features/absence-notify/AbsenceNotifyView.tsx new file mode 100644 index 0000000..44adcde --- /dev/null +++ b/apps/web/src/features/absence-notify/AbsenceNotifyView.tsx @@ -0,0 +1,71 @@ +import { CalendarX2, Loader2 } from 'lucide-react' +import { PublicFooter, PublicHeader } from '../../components/layout' +import { Badge } from '../../components/ui' +import { AbsenceSessionRow } from './AbsenceSessionRow' +import { useAbsenceNotify } from './AbsenceNotifyProvider' + +export function AbsenceNotifyView() { + const { details, detailsPending, detailsFailed } = useAbsenceNotify() + + return ( +
+ + +
+
+ {detailsPending ? ( +
+ +

Cargando tus clases...

+
+ ) : null} + + {detailsFailed || (!details && !detailsPending) ? ( +
+

Enlace no válido

+

+ No pudimos identificarte con este enlace. Consultá con tu profesor para solicitar uno nuevo. +

+
+ ) : null} + + {details ? ( +
+
+
+ +
+ + Aviso de ausencia + +

Hola, {details.fullName}

+

+ Grupo: {details.groupName} +

+
+ + {details.sessions.length === 0 ? ( +

+ No tenés clases programadas para hoy. Si necesitás avisar de todas formas, contactá a tu + profesor. +

+ ) : ( +
    + {details.sessions.map((session) => ( + + ))} +
+ )} + +

+ Si avisás con anticipación, tu cupo se libera para una clase de recuperación. +

+
+ ) : null} +
+
+ + +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/features/absence-notify/AbsenceSessionRow.tsx b/apps/web/src/features/absence-notify/AbsenceSessionRow.tsx new file mode 100644 index 0000000..5b35429 --- /dev/null +++ b/apps/web/src/features/absence-notify/AbsenceSessionRow.tsx @@ -0,0 +1,44 @@ +import { CheckCircle2, Loader2 } from 'lucide-react' +import { Button } from '../../components/ui' +import { useAbsenceNotify } from './AbsenceNotifyProvider' + +type AbsenceSession = { + sessionId: string + startsAt: string + groupName: string + notified: boolean +} + +export function AbsenceSessionRow({ session }: { session: AbsenceSession }) { + const { isNotified, isNotifying, notify } = useAbsenceNotify() + const time = new Date(session.startsAt).toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + + return ( +
  • +
    +

    Hoy · {time} hs

    +

    {session.groupName}

    +
    + {isNotified(session) ? ( + + + Ya avisaste + + ) : ( + + )} +
  • + ) +} \ No newline at end of file diff --git a/apps/web/src/features/auth/AuthShell.tsx b/apps/web/src/features/auth/AuthShell.tsx new file mode 100644 index 0000000..885b90d --- /dev/null +++ b/apps/web/src/features/auth/AuthShell.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { Logo } from '../../components/brand' + +export type AuthShellProps = { + title: string + subtitle?: string + children: ReactNode +} + +export function AuthShell({ title, subtitle, children }: AuthShellProps) { + return ( +
    +
    +
    + +
    +
    +

    {title}

    + {subtitle ?

    {subtitle}

    : null} +
    {children}
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/auth/LoginPage.tsx b/apps/web/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..1d76073 --- /dev/null +++ b/apps/web/src/features/auth/LoginPage.tsx @@ -0,0 +1,113 @@ +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 './AuthShell' + +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 + +export function LoginPage() { + const navigate = useNavigate() + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ 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 ( + +
    +
    +
    + + + {errors.email ?

    {errors.email.message}

    : null} +
    + +
    + + + {errors.password ? ( +

    {errors.password.message}

    + ) : null} +
    + + {errors.root ?

    {errors.root.message}

    : null} + + +
    + +
    +
    + o +
    +
    + +
    + + +
    + +

    + ¿No tenés cuenta?{' '} + + Registrate + +

    +
    + + ) +} \ No newline at end of file diff --git a/apps/web/src/features/auth/SignupPage.tsx b/apps/web/src/features/auth/SignupPage.tsx new file mode 100644 index 0000000..27c90d6 --- /dev/null +++ b/apps/web/src/features/auth/SignupPage.tsx @@ -0,0 +1,142 @@ +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 './AuthShell' + +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 + +export function SignupPage() { + const navigate = useNavigate() + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ 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 ( + +
    +
    +
    + + + {errors.name ?

    {errors.name.message}

    : null} +
    + +
    + + + {errors.email ?

    {errors.email.message}

    : null} +
    + +
    + + + {errors.password ? ( +

    {errors.password.message}

    + ) : null} +
    + +
    + + + {errors.confirmPassword ? ( +

    {errors.confirmPassword.message}

    + ) : null} +
    + + {errors.root ?

    {errors.root.message}

    : null} + + +
    + +
    +
    + o +
    +
    + + + +

    + ¿Ya tenés cuenta?{' '} + + Iniciá sesión + +

    +
    + + ) +} \ No newline at end of file diff --git a/apps/web/src/features/auth/VerifyEmailPage.tsx b/apps/web/src/features/auth/VerifyEmailPage.tsx new file mode 100644 index 0000000..db72a56 --- /dev/null +++ b/apps/web/src/features/auth/VerifyEmailPage.tsx @@ -0,0 +1,91 @@ +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 './AuthShell' + +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(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 ( + +
    + {status === 'verifying' ? ( + + ) : status === 'success' ? ( + + ) : ( + + )} + + {status === 'success' ? ( +

    + Tu cuenta quedó verificada. Te estamos llevando a Gruperly… +

    + ) : status === 'error' ? ( +

    + El vínculo de verificación no es válido o expiró. +

    + ) : ( +

    + Te enviamos un correo a {email} para + confirmar tu cuenta. +

    + )} + + {status === 'success' ? ( + + Ir a Gruperly + + ) : null} + + {status === 'error' ? ( + + Intentar iniciar sesión + + ) : null} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/class-attendance/AttendanceProvider.tsx b/apps/web/src/features/class-attendance/AttendanceProvider.tsx new file mode 100644 index 0000000..464e3d7 --- /dev/null +++ b/apps/web/src/features/class-attendance/AttendanceProvider.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { SessionStudents } from '@gruperly/shared' +import { useToast } from '../../components/ui' +import { getSessionStudents, markAttendance } from '../../lib/api' +import { isAttendanceLocked } from './utils' +import type { AttendanceChoice } from './utils' + +type AttendanceContextValue = { + sessionId: string + sessionInfo: SessionStudents | undefined + studentsPending: boolean + studentsFailed: boolean + retryStudents: () => void + startTime: string | null + presentCount: number + choices: Record + choiceFor: (attendeeId: string) => AttendanceChoice + toggleStudent: (attendeeId: string) => void + saveAttendance: () => void + isSaving: boolean +} + +const AttendanceContext = createContext(null) + +export function AttendanceProvider({ sessionId, children }: { sessionId: string; children: ReactNode }) { + const queryClient = useQueryClient() + const toast = useToast() + + const studentsQuery = useQuery({ + queryKey: ['class-students', sessionId], + queryFn: () => getSessionStudents(sessionId), + staleTime: 0, + }) + + const sessionInfo = studentsQuery.data + const [choices, setChoices] = useState>({}) + + useEffect(() => { + if (!sessionInfo) return + const initial: Record = {} + for (const student of sessionInfo.students) { + if (isAttendanceLocked(student)) continue + initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT' + } + setChoices(initial) + }, [sessionInfo]) + + const presentCount = useMemo( + () => + (sessionInfo?.students ?? []).filter( + (student) => !isAttendanceLocked(student) && choices[student.attendeeId] === 'PRESENT', + ).length, + [sessionInfo, choices], + ) + + const toggleStudent = (attendeeId: string) => { + setChoices((prev) => ({ + ...prev, + [attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT', + })) + } + + const saveMutation = useMutation({ + mutationFn: () => { + const records = (sessionInfo?.students ?? []) + .filter((student) => !isAttendanceLocked(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 startTime = sessionInfo + ? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : null + + const value: AttendanceContextValue = { + sessionId, + sessionInfo, + studentsPending: studentsQuery.isPending, + studentsFailed: studentsQuery.isError, + retryStudents: () => void studentsQuery.refetch(), + startTime, + presentCount, + choices, + choiceFor: (attendeeId) => choices[attendeeId] ?? 'PRESENT', + toggleStudent, + saveAttendance: () => saveMutation.mutate(), + isSaving: saveMutation.isPending, + } + + return {children} +} + +export function useAttendance() { + const context = useContext(AttendanceContext) + if (!context) { + throw new Error('useAttendance debe usarse dentro de ') + } + return context +} \ No newline at end of file diff --git a/apps/web/src/features/class-attendance/AttendanceToggle.tsx b/apps/web/src/features/class-attendance/AttendanceToggle.tsx new file mode 100644 index 0000000..2a86162 --- /dev/null +++ b/apps/web/src/features/class-attendance/AttendanceToggle.tsx @@ -0,0 +1,45 @@ +import { Check, X } from 'lucide-react' +import { cn } from '../../lib/utils' +import type { AttendanceChoice } from './utils' + +export function AttendanceToggle({ + choice, + disabled, + onToggle, + fullName, +}: { + choice: AttendanceChoice + disabled?: boolean + onToggle: () => void + fullName: string +}) { + const isPresent = choice === 'PRESENT' + + return ( + + ) +} \ No newline at end of file diff --git a/apps/web/src/features/class-attendance/ClassAttendanceView.tsx b/apps/web/src/features/class-attendance/ClassAttendanceView.tsx new file mode 100644 index 0000000..32e69ee --- /dev/null +++ b/apps/web/src/features/class-attendance/ClassAttendanceView.tsx @@ -0,0 +1,90 @@ +import { Link } from '@tanstack/react-router' +import { ArrowLeft, Loader2 } from 'lucide-react' +import { Badge, Button } from '../../components/ui' +import { useAttendance } from './AttendanceProvider' +import { StudentRow } from './StudentRow' + +export function ClassAttendanceView() { + const { + sessionInfo, + studentsPending, + studentsFailed, + retryStudents, + startTime, + presentCount, + saveAttendance, + isSaving, + } = useAttendance() + + return ( +
    + + + Volver al inicio + + + {studentsPending ? ( +
    + +
    + ) : null} + + {studentsFailed ? ( +
    +

    No pudimos cargar la lista de alumnos.

    + +
    + ) : null} + + {sessionInfo ? ( + <> +
    +
    +
    +

    {sessionInfo.groupName}

    +

    {startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'}

    +
    + + {presentCount}/{sessionInfo.students.length} presentes + +
    +
    + + {sessionInfo.students.length === 0 ? ( +
    +

    Este grupo no tiene alumnos

    +

    + Agregá miembros desde la ficha del grupo para tomar asistencia. +

    +
    + ) : ( +
      + {sessionInfo.students.map((student) => ( + + ))} +
    + )} + + {sessionInfo.students.length > 0 ? ( +
    + +
    + ) : null} + + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/class-attendance/StudentRow.tsx b/apps/web/src/features/class-attendance/StudentRow.tsx new file mode 100644 index 0000000..75e3b4d --- /dev/null +++ b/apps/web/src/features/class-attendance/StudentRow.tsx @@ -0,0 +1,37 @@ +import type { SessionStudentDto } from '@gruperly/shared' +import { Avatar, Badge } from '../../components/ui' +import { AttendanceToggle } from './AttendanceToggle' +import { useAttendance } from './AttendanceProvider' +import { isAttendanceLocked } from './utils' + +function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) { + return status === 'UP_TO_DATE' ? Al día : Pendiente +} + +export function StudentRow({ student }: { student: SessionStudentDto }) { + const { choiceFor, toggleStudent } = useAttendance() + const locked = isAttendanceLocked(student) + + return ( +
  • + +
    +

    {student.fullName}

    +
    + + {locked ? Avisó ausencia : null} +
    +
    + toggleStudent(student.attendeeId)} + fullName={student.fullName} + /> +
  • + ) +} \ No newline at end of file diff --git a/apps/web/src/features/class-attendance/utils.ts b/apps/web/src/features/class-attendance/utils.ts new file mode 100644 index 0000000..f397e6e --- /dev/null +++ b/apps/web/src/features/class-attendance/utils.ts @@ -0,0 +1,7 @@ +import type { SessionStudentDto } from '@gruperly/shared' + +export type AttendanceChoice = 'PRESENT' | 'ABSENT' + +export function isAttendanceLocked(student: SessionStudentDto): boolean { + return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED' +} diff --git a/apps/web/src/features/groups/CreateGroupView.tsx b/apps/web/src/features/groups/CreateGroupView.tsx new file mode 100644 index 0000000..ad5fcfa --- /dev/null +++ b/apps/web/src/features/groups/CreateGroupView.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Link, useNavigate } from '@tanstack/react-router' +import type { CreateFirstGroup } from '@gruperly/shared' +import { ChevronLeft } from 'lucide-react' +import { createGroup } from '../../lib/api' +import { GroupForm } from './GroupForm' + +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 ( +
    + + + Grupos + + + create.mutate(values)} + /> +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/GroupCard.tsx b/apps/web/src/features/groups/GroupCard.tsx new file mode 100644 index 0000000..145cecd --- /dev/null +++ b/apps/web/src/features/groups/GroupCard.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from '@tanstack/react-router' +import type { GroupDto, GroupRiskLevel } from '@gruperly/shared' +import { CalendarClock, Users } from 'lucide-react' +import { Badge, Button } from '../../components/ui' +import { BILLING_LABELS, formatPrice, formatSchedule } from '../../lib/format' +import { cn } from '../../lib/utils' +import { RISK_LABELS } from './constants' + +export 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 ( +
    +
    +
    +

    {group.name}

    + {group.description ? ( +

    {group.description}

    + ) : null} +
    +
    + {hasRisk ? ( + + + ) : null} + Activo +
    +
    + + {hasSchedule ? ( +
    +
    + + {formatSchedule(group.days ?? [], group.time)} +
    +
    + + + 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}` : ''} + +
    +
    + ) : ( +

    Aún sin plan de cobro configurado.

    + )} + +
    + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/GroupForm.tsx b/apps/web/src/features/groups/GroupForm.tsx new file mode 100644 index 0000000..a96a9ce --- /dev/null +++ b/apps/web/src/features/groups/GroupForm.tsx @@ -0,0 +1,231 @@ +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 '../../components/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({ + 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 ( +
    +
    +

    {heading}

    +

    {description}

    +
    + +
    + + + {errors.name ?

    {errors.name.message}

    : null} +
    + +
    + +
    + {WEEK_DAY_CHIPS.map(({ value, label }) => { + const isActive = days.includes(value) + return ( + + ) + })} +
    + {errors.days ?

    {errors.days.message}

    : null} +
    + +
    + + + {errors.time ?

    {errors.time.message}

    : null} +
    + +
    +
    + + + {errors.capacity ? ( +

    {errors.capacity.message}

    + ) : null} +
    + +
    + +
    + + $ + + +
    + {errors.price ? ( +

    {errors.price.message}

    + ) : null} +
    +
    + +
    + +
    + {BILLING_TYPES.map(({ value, label, hint }) => { + const isActive = billingType === value + return ( + + ) + })} +
    +
    + +
    + + +

    + Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. +

    + {errors.dueDay ? ( +

    {errors.dueDay.message}

    + ) : null} +
    + + {errorMessage ? ( +

    {errorMessage}

    + ) : null} + +
    + {onBack ? ( + + ) : null} + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/GroupsView.tsx b/apps/web/src/features/groups/GroupsView.tsx new file mode 100644 index 0000000..76d0ab1 --- /dev/null +++ b/apps/web/src/features/groups/GroupsView.tsx @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { Loader2, Plus } from 'lucide-react' +import { Button } from '../../components/ui' +import { getGroups, getGroupsRiskOverview } from '../../lib/api' +import { GroupCard } from './GroupCard' + +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 ( +
    +
    +
    +

    Grupos

    +

    Tus grupos de cobranza.

    +
    + +
    + + {groupsQuery.isPending ? ( +
    + +
    + ) : null} + + {groupsQuery.isError ? ( +
    +

    No pudimos cargar tus grupos.

    + +
    + ) : null} + + {groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? ( +
    +

    Todavía no tenés grupos

    +

    + Crea tu primer grupo para empezar a cobrar. +

    + +
    + ) : null} + + {groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? ( +
    + {groupsQuery.data.data.map((group) => ( + + ))} +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/AnalyticsView.tsx b/apps/web/src/features/groups/analytics/AnalyticsView.tsx new file mode 100644 index 0000000..fd27449 --- /dev/null +++ b/apps/web/src/features/groups/analytics/AnalyticsView.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useParams } from '@tanstack/react-router' +import type { StudentAtRiskDto } from '@gruperly/shared' +import { ArrowLeft, CalendarCheck, DoorOpen, Loader2, Percent, Users } from 'lucide-react' +import { Badge, Button, useToast } from '../../../components/ui' +import { getAttendeeHistory, getGroupAnalytics, getStudentsAtRisk, updateAttendeeStatus } from '../../../lib/api' +import { KpiCard } from './KpiCard' +import { AtRiskStudentCard, type StatusAction } from './AtRiskStudentCard' +import { AttendeeHistoryModal } from './AttendeeHistoryModal' +import { StatusActionModal } from './StatusActionModal' + +export function AnalyticsView() { + const { groupId } = useParams({ strict: false }) as { groupId: string } + const queryClient = useQueryClient() + const toast = useToast() + + const [historyStudent, setHistoryStudent] = useState(null) + const [statusAction, setStatusAction] = useState(null) + const [menuOpenFor, setMenuOpenFor] = useState(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) => + 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 ( +
    + +
    + ) + } + + if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) { + return ( +
    +

    No pudimos cargar las estadísticas

    +

    Verificá tu conexión e intentá de nuevo.

    + +
    + ) + } + + const analytics = analyticsQuery.data + const atRisk = riskQuery.data?.data ?? [] + + return ( +
    + + + Volver a {groupName ?? 'el grupo'} + + +
    +
    +

    Estadísticas

    +

    + Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}. +

    +
    + + Últimos 30 días + +
    + +
    + } + label="Asistencia promedio" + value={`${analytics.attendanceRate}%`} + hint="últimos 30 días" + /> + } + label="Asistencias del mes" + value={analytics.totalPresent.toLocaleString('es-MX')} + hint={`${analytics.totalClasses} clases dictadas`} + /> + } + label="Cupos recuperados" + value={analytics.recoveredSlots.toLocaleString('es-MX')} + hint="por ausencias avisadas" + /> +
    + +
    +
    + +

    Alumnos en riesgo

    + {atRisk.length > 0 ? ( + s.riskLevel === 'HIGH') ? 'danger' : 'warning'}> + {atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'} + + ) : null} +
    +

    + Alumnos con ausencias consecutivas o baja asistencia mensual. +

    + + {atRisk.length === 0 ? ( +
    +

    ¡Sin alumnos en riesgo!

    +

    + No hay ausencias consecutivas ni asistencia por debajo del 50%. +

    +
    + ) : ( +
      + {atRisk.map((student) => ( + + setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId) + } + onViewHistory={() => setHistoryStudent(student)} + onReengage={() => void handleReengage(student, groupName ?? 'tu clase')} + onRequestStatusAction={(action) => { + setStatusAction(action) + setMenuOpenFor(null) + }} + /> + ))} +
    + )} +
    + + setHistoryStudent(null)} + /> + + setStatusAction(null)} + onConfirm={() => { + if (statusAction) statusMutation.mutate(statusAction) + }} + /> +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx b/apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx new file mode 100644 index 0000000..e848394 --- /dev/null +++ b/apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx @@ -0,0 +1,100 @@ +import type { StudentAtRiskDto } from '@gruperly/shared' +import { MessageCircle, MoreVertical, Pause, UserMinus } from 'lucide-react' +import { Avatar, Badge, Button } from '../../../components/ui' +import { cn } from '../../../lib/utils' + +export type StatusAction = { student: StudentAtRiskDto; status: 'PAUSED' | 'DROPPED' } | null + +export function AtRiskStudentCard({ + student, + menuOpen, + onToggleMenu, + onViewHistory, + onReengage, + onRequestStatusAction, +}: { + student: StudentAtRiskDto + menuOpen: boolean + onToggleMenu: () => void + onViewHistory: () => void + onReengage: () => void + onRequestStatusAction: (action: Exclude) => void +}) { + return ( +
  • +
    + + {student.riskLevel === 'HIGH' ? ( + {student.consecutiveAbsences} faltas seguidas + ) : ( + Baja asistencia + )} +
    + +
    + + +
    + + + {menuOpen ? ( + <> + +
    +
  • + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/AttendeeHistoryModal.tsx b/apps/web/src/features/groups/analytics/AttendeeHistoryModal.tsx new file mode 100644 index 0000000..5c7229d --- /dev/null +++ b/apps/web/src/features/groups/analytics/AttendeeHistoryModal.tsx @@ -0,0 +1,80 @@ +import type { StudentAtRiskDto } from '@gruperly/shared' +import { Loader2 } from 'lucide-react' +import { Modal } from '../../../components/ui' +import { cn } from '../../../lib/utils' +import { dotClass, formatSessionDate, sessionStatusTextClass, statusLabel } from './format' + +export function AttendeeHistoryModal({ + student, + sessions, + attendanceRate, + isPending, + isError, + onClose, +}: { + student: StudentAtRiskDto | null + sessions: + | { + classSessionId: string + startsAt: string + status: string | null + }[] + | undefined + attendanceRate: number | undefined + isPending: boolean + isError: boolean + onClose: () => void +}) { + return ( + + {student ? ( +
    + {isPending ? ( +
    + +
    + ) : isError || !sessions ? ( +
    + No pudimos cargar el historial de este alumno. +
    + ) : ( +
    +
    + {attendanceRate}% + + Presentismo general ({sessions.length} clases) + +
    + +
      + {sessions.map((session) => ( +
    • +
    • + ))} +
    +
    + )} +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/KpiCard.tsx b/apps/web/src/features/groups/analytics/KpiCard.tsx new file mode 100644 index 0000000..8b2707a --- /dev/null +++ b/apps/web/src/features/groups/analytics/KpiCard.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' + +export function KpiCard({ + icon, + label, + value, + hint, +}: { + icon: ReactNode + label: string + value: string + hint?: string +}) { + return ( +
    +
    + {icon} +
    +
    +

    {label}

    +

    {value}

    + {hint ?

    {hint}

    : null} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/StatusActionModal.tsx b/apps/web/src/features/groups/analytics/StatusActionModal.tsx new file mode 100644 index 0000000..1d31327 --- /dev/null +++ b/apps/web/src/features/groups/analytics/StatusActionModal.tsx @@ -0,0 +1,72 @@ +import { Loader2, Pause, UserMinus } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { cn } from '../../../lib/utils' +import type { StatusAction } from './AtRiskStudentCard' + +export function StatusActionModal({ + action, + isPending, + groupName, + onCancel, + onConfirm, +}: { + action: StatusAction + isPending: boolean + groupName: string | undefined + onCancel: () => void + onConfirm: () => void +}) { + return ( + + {action ? ( +
    +

    + {action.student.fullName}{' '} + {action.status === 'DROPPED' ? ( + <> + dejará de asistir al grupo y {groupName}. Su + vacante quedará disponible para otro alumno, y + su historial de asistencia se conservará. + + ) : ( + <> + será pausado en{' '} + {groupName}. Su vacante quedará{' '} + libre y podés reactivarla en cualquier + momento. + + )} +

    +
    + + +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/analytics/format.ts b/apps/web/src/features/groups/analytics/format.ts new file mode 100644 index 0000000..e521aae --- /dev/null +++ b/apps/web/src/features/groups/analytics/format.ts @@ -0,0 +1,46 @@ +export 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' + } +} + +export 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' + } +} + +export function sessionStatusTextClass(status: string | null): string { + switch (status) { + case 'PRESENT': + return 'text-success' + case 'ABSENT': + return 'text-danger' + case 'EXCUSED': + return 'text-warning' + default: + return 'text-foreground/50' + } +} + +export function formatSessionDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-MX', { + weekday: 'short', + day: 'numeric', + month: 'short', + }) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/constants.ts b/apps/web/src/features/groups/constants.ts new file mode 100644 index 0000000..2363240 --- /dev/null +++ b/apps/web/src/features/groups/constants.ts @@ -0,0 +1,6 @@ +import type { GroupRiskLevel } from '@gruperly/shared' + +export const RISK_LABELS: Record, string> = { + HIGH: 'Riesgo alto', + MEDIUM: 'Riesgo moderado', +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/AddAttendeeModal.tsx b/apps/web/src/features/groups/detail/AddAttendeeModal.tsx new file mode 100644 index 0000000..ea53a73 --- /dev/null +++ b/apps/web/src/features/groups/detail/AddAttendeeModal.tsx @@ -0,0 +1,46 @@ +import { Modal } from '../../../components/ui' +import { cn } from '../../../lib/utils' +import { BulkImportPanel } from './BulkImportPanel' +import { QuickAddForm } from './QuickAddForm' +import { useGroupDetail } from './GroupDetailProvider' + +export function AddAttendeeModal() { + const { isAddAttendeeModalOpen, closeAddAttendeeModal, activeTab, setActiveTab } = useGroupDetail() + + return ( + +
    +
    + + +
    + + {activeTab === 'quick' ? : } +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/AttendeeDetailModal.tsx b/apps/web/src/features/groups/detail/AttendeeDetailModal.tsx new file mode 100644 index 0000000..d01ab5f --- /dev/null +++ b/apps/web/src/features/groups/detail/AttendeeDetailModal.tsx @@ -0,0 +1,150 @@ +import { Copy, Mail, MessageCircle, Phone, ShieldCheck, StickyNote, UserMinus } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { DetailRow } from './DetailRow' +import { useGroupDetail } from './GroupDetailProvider' + +export function AttendeeDetailModal() { + const { selectedAttendee, setSelectedAttendee, requestRemoveAttendee, copyAbsenceNotifyUrl } = + useGroupDetail() + const absenceNotifyUrl = selectedAttendee?.notifyToken + ? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}` + : null + + return ( + setSelectedAttendee(null)} + title="Detalle del Participante" + maxWidth="sm" + > + {selectedAttendee ? ( +
    +
    +
    + {selectedAttendee.fullName.charAt(0).toUpperCase()} +
    +
    +

    {selectedAttendee.fullName}

    +

    + Incorporado el{' '} + {new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

    +
    +
    + +
    + } label="Teléfono"> + {selectedAttendee.phone ? ( +
    + {selectedAttendee.phone} + + + WhatsApp + +
    + ) : ( + Sin teléfono + )} +
    + + } label="Email"> + {selectedAttendee.email ? ( + + {selectedAttendee.email} + + ) : ( + Sin email + )} + + + {selectedAttendee.guardianName || selectedAttendee.guardianPhone ? ( + <> + } label="Responsable"> + + {selectedAttendee.guardianName || —} + + + {selectedAttendee.guardianPhone ? ( + } label="Tel. Responsable"> +
    + {selectedAttendee.guardianPhone} + + + +
    +
    + ) : null} + + ) : null} + + } label="Notas"> + {selectedAttendee.notes ? ( + {selectedAttendee.notes} + ) : ( + Sin notas + )} + +
    + + {absenceNotifyUrl ? ( +
    +

    Link para avisar ausencias

    +
    + + {absenceNotifyUrl} + + +
    + {selectedAttendee.phone ? ( + + + Enviar por WhatsApp + + ) : null} +
    + ) : null} + +
    + +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/BulkCapacityModal.tsx b/apps/web/src/features/groups/detail/BulkCapacityModal.tsx new file mode 100644 index 0000000..791fc11 --- /dev/null +++ b/apps/web/src/features/groups/detail/BulkCapacityModal.tsx @@ -0,0 +1,41 @@ +import { Check } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { useGroupDetail } from './GroupDetailProvider' + +export function BulkCapacityModal() { + const { + isBulkCapacityModalOpen, + closeBulkCapacityModal, + confirmBulkImportOverCapacity, + validRowsCount, + attendeesTotal, + group, + } = useGroupDetail() + + return ( + +
    +

    + Estás por importar {validRowsCount} miembro(s), pero el + grupo ya tiene {attendeesTotal} inscrito(s) y su cupo es + de {group?.capacity}. ¿Deseas importarlos de todos modos? +

    +
    + + +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/BulkImportPanel.tsx b/apps/web/src/features/groups/detail/BulkImportPanel.tsx new file mode 100644 index 0000000..518dccf --- /dev/null +++ b/apps/web/src/features/groups/detail/BulkImportPanel.tsx @@ -0,0 +1,228 @@ +import { useRef } from 'react' +import { Check, Download, FileSpreadsheet, Loader2, Trash2, UploadCloud } from 'lucide-react' +import { Badge, Button, useToast } from '../../../components/ui' +import { + downloadAttendeeTemplateCsv, + normalizeRowsToContacts, + parseCsvContent, + parseXlsxContent, +} from '../../../lib/file-parser' +import { cn } from '../../../lib/utils' +import { useGroupDetail } from './GroupDetailProvider' + +export function BulkImportPanel() { + const { + parsedContacts, + setParsedContacts, + removeParsedContact, + fileName, + setFileName, + isParsingFile, + setIsParsingFile, + isDragging, + setIsDragging, + validRowsCount, + invalidRowsCount, + isSubmittingBulkImport, + closeAddAttendeeModal, + requestBulkImport, + } = useGroupDetail() + const toast = useToast() + const fileInputRef = useRef(null) + + const processFile = async (file: File) => { + const fileExtension = file.name.split('.').pop()?.toLowerCase() + if (!['csv', 'xlsx', 'xls', 'txt'].includes(fileExtension ?? '')) { + toast.error('Formato no compatible. Sube un archivo .csv o .xlsx') + return + } + + setIsParsingFile(true) + setFileName(file.name) + try { + const rawRows = + fileExtension === 'xlsx' || fileExtension === 'xls' + ? await parseXlsxContent(await file.arrayBuffer()) + : parseCsvContent(await file.text()) + + const contacts = normalizeRowsToContacts(rawRows) + if (contacts.length === 0) { + toast.error('No se detectaron contactos en el archivo.') + } else { + setParsedContacts(contacts) + const validCount = contacts.filter((c) => c.isValid).length + toast.info(`Se detectaron ${contacts.length} filas (${validCount} válidas).`) + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Error al procesar el archivo.' + toast.error(message) + } finally { + setIsParsingFile(false) + } + } + + const handleFileDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) { + void processFile(file) + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + void processFile(file) + } + } + + return ( +
    +
    { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleFileDrop} + onClick={() => fileInputRef.current?.click()} + className={cn( + 'relative flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-all', + isDragging + ? 'border-accent bg-accent/5' + : 'border-border bg-primary-soft/30 hover:border-accent/60', + )} + > + + +

    + Arrastra tu archivo aquí o haz clic para seleccionarlo +

    +

    Formatos soportados: CSV (.csv) o Excel (.xlsx)

    + + {fileName ? ( +
    + + {fileName} +
    + ) : null} +
    + +
    + ¿No tienes el archivo listo? Usa nuestra plantilla. + +
    + + {isParsingFile ? ( +
    + + Leyendo y analizando archivo... +
    + ) : null} + + {parsedContacts.length > 0 ? ( +
    +
    +
    + Vista Previa: + {validRowsCount} listos para importar + {invalidRowsCount > 0 ? {invalidRowsCount} con errores : null} +
    + + +
    + +
    + + + + + + + + + + + {parsedContacts.map((contact) => ( + + + + + + + + ))} + +
    NombreTeléfonoEmailEstado +
    + {contact.fullName || Sin nombre} + + {contact.phone || Sin teléfono} + {contact.email || '—'} + {contact.isValid ? ( + + Válido + + ) : ( + + {contact.error} + + )} + + +
    +
    + +
    + + +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/CapacityModal.tsx b/apps/web/src/features/groups/detail/CapacityModal.tsx new file mode 100644 index 0000000..926f5da --- /dev/null +++ b/apps/web/src/features/groups/detail/CapacityModal.tsx @@ -0,0 +1,61 @@ +import { Clock, Loader2, UserPlus } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { useGroupDetail } from './GroupDetailProvider' + +export function CapacityModal() { + const { + isCapacityModalOpen, + closeCapacityModal, + capacityPayload, + group, + submitForcedAttendee, + submitWaitlistEntry, + isSubmittingForcedAttendee, + isSubmittingWaitlistEntry, + } = useGroupDetail() + const isBusy = isSubmittingForcedAttendee || isSubmittingWaitlistEntry + + return ( + + {capacityPayload ? ( +
    +

    + El grupo {group?.name} ya alcanzó su cupo de{' '} + {group?.capacity} miembros. ¿Qué deseas hacer con{' '} + + {`${capacityPayload.firstName.trim()} ${(capacityPayload.lastName ?? '').trim()}`.trim()} + + ? +

    +
    + + +
    +

    + Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo. +

    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/DetailRow.tsx b/apps/web/src/features/groups/detail/DetailRow.tsx new file mode 100644 index 0000000..b44d7c4 --- /dev/null +++ b/apps/web/src/features/groups/detail/DetailRow.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' + +export function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) { + return ( +
    +
    {icon}
    +
    +

    + {label} +

    +
    {children}
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/GroupDetailProvider.tsx b/apps/web/src/features/groups/detail/GroupDetailProvider.tsx new file mode 100644 index 0000000..322b3b2 --- /dev/null +++ b/apps/web/src/features/groups/detail/GroupDetailProvider.tsx @@ -0,0 +1,537 @@ +import { createContext, useContext, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { + AttendeeDto, + CreateAttendee, + CreateAttendeeResult, + CreateGroupWaitlistEntry, + GroupDto, + GroupWaitlistEntryDto, + GroupWaitlistList, + GroupRiskLevel, +} from '@gruperly/shared' +import { useToast } from '../../../components/ui' +import type { ParsedContactRow } from '../../../lib/file-parser' +import { + addToGroupWaitlist, + ApiError, + bulkCreateAttendees, + createAttendee, + getGroup, + getGroupAttendees, + getGroupsRiskOverview, + getGroupWaitlist, + getInviteToken, + promoteGroupWaitlistEntry, + removeGroupAttendee, + removeGroupWaitlistEntry, +} from '../../../lib/api' + +export type BulkAttendeeRow = { + firstName: string + lastName: string + fullName: string + phone: string + email?: string + notes?: string +} + +export type ListSection = 'members' | 'waitlist' +export type AddAttendeeTab = 'quick' | 'bulk' + +type GroupDetailContextValue = { + groupId: string + group: GroupDto | undefined + groupQueryPending: boolean + riskLevel: GroupRiskLevel + attendees: AttendeeDto[] + attendeesTotal: number + attendeesPending: boolean + waitlistEntries: GroupWaitlistEntryDto[] + waitlistTotal: number + waitlistPending: boolean + firstWaitlistEntry: GroupWaitlistEntryDto | null + hasFreeCapacity: boolean + inviteUrl: string | undefined + invitePending: boolean + whatsappMessage: string + searchFilter: string + setSearchFilter: (value: string) => void + listSection: ListSection + setListSection: (value: ListSection) => void + activeTab: AddAttendeeTab + setActiveTab: (value: AddAttendeeTab) => void + isInviteModalOpen: boolean + openInviteModal: () => void + closeInviteModal: () => void + isAddAttendeeModalOpen: boolean + openAddAttendeeModal: () => void + closeAddAttendeeModal: () => void + selectedAttendee: AttendeeDto | null + setSelectedAttendee: (attendee: AttendeeDto | null) => void + attendeeToRemove: AttendeeDto | null + requestRemoveAttendee: (attendee: AttendeeDto) => void + cancelRemoveAttendee: () => void + confirmRemoveAttendee: (promoteFromWaitlist: boolean) => void + selectedWaitlistEntry: GroupWaitlistEntryDto | null + setSelectedWaitlistEntry: (entry: GroupWaitlistEntryDto | null) => void + isCapacityModalOpen: boolean + capacityPayload: CreateAttendee | null + closeCapacityModal: () => void + isBulkCapacityModalOpen: boolean + closeBulkCapacityModal: () => void + parsedContacts: ParsedContactRow[] + setParsedContacts: (contacts: ParsedContactRow[]) => void + removeParsedContact: (id: string) => void + fileName: string | null + setFileName: (name: string | null) => void + isParsingFile: boolean + setIsParsingFile: (value: boolean) => void + isDragging: boolean + setIsDragging: (value: boolean) => void + validRowsCount: number + invalidRowsCount: number + isSubmittingAttendee: boolean + isSubmittingForcedAttendee: boolean + isSubmittingWaitlistEntry: boolean + isSubmittingBulkImport: boolean + isSubmittingRemoval: boolean + isSubmittingPromotion: boolean + isSubmittingWaitlistRemoval: boolean + isRegeneratingInvite: boolean + submitQuickAttendee: (payload: CreateAttendee) => void + submitForcedAttendee: () => void + submitWaitlistEntry: () => void + requestBulkImport: () => void + confirmBulkImportOverCapacity: () => void + promoteEntry: (entry: GroupWaitlistEntryDto) => void + removeEntry: (entry: GroupWaitlistEntryDto) => void + regenerateInvite: () => void + copyInviteLink: () => Promise + copyWhatsappMessage: () => Promise + openWhatsapp: () => Promise + copyAbsenceNotifyUrl: (url: string) => void +} + +const GroupDetailContext = createContext(null) + +export function GroupDetailProvider({ groupId, children }: { groupId: string; children: ReactNode }) { + const queryClient = useQueryClient() + const toast = useToast() + + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) + const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false) + const [selectedAttendee, setSelectedAttendee] = useState(null) + const [activeTab, setActiveTab] = useState('quick') + const [listSection, setListSection] = useState('members') + const [attendeeToRemove, setAttendeeToRemove] = useState(null) + const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState(null) + const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false) + const [pendingCapacityPayload, setPendingCapacityPayload] = useState(null) + const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false) + const [searchFilter, setSearchFilter] = useState('') + const [parsedContacts, setParsedContacts] = useState([]) + const [fileName, setFileName] = useState(null) + const [isParsingFile, setIsParsingFile] = useState(false) + const [isDragging, setIsDragging] = useState(false) + + const groupQuery = useQuery({ + queryKey: ['group', groupId], + queryFn: () => getGroup(groupId), + enabled: Boolean(groupId), + }) + + const riskOverviewQuery = useQuery({ + queryKey: ['groups-risk-overview'], + queryFn: getGroupsRiskOverview, + enabled: Boolean(groupId), + }) + + const inviteTokenQuery = useQuery({ + queryKey: ['invite-token', groupId], + queryFn: () => getInviteToken(groupId), + enabled: Boolean(groupId) && isInviteModalOpen, + }) + + const attendeesQuery = useQuery({ + queryKey: ['group-attendees', groupId], + queryFn: () => getGroupAttendees(groupId, 1, 100), + enabled: Boolean(groupId), + }) + + const waitlistQuery = useQuery({ + queryKey: ['group-waitlist', groupId], + queryFn: () => getGroupWaitlist(groupId, 1, 100), + enabled: Boolean(groupId), + }) + + const group = groupQuery.data + const attendees = useMemo(() => attendeesQuery.data?.data ?? [], [attendeesQuery.data]) + const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length + const waitlistEntries = useMemo(() => waitlistQuery.data?.data ?? [], [waitlistQuery.data]) + const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length + const firstWaitlistEntry = waitlistEntries[0] ?? null + const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity + + const closeAddAttendeeFlow = () => { + setPendingCapacityPayload(null) + setIsCapacityModalOpen(false) + setIsAddAttendeeModalOpen(false) + } + + const invalidateGroupQueries = () => { + void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }) + void queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }) + void queryClient.invalidateQueries({ queryKey: ['group', groupId] }) + } + + const handleCreateSuccess = (result: CreateAttendeeResult) => { + if (result.outcome === 'created') { + toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`) + } else { + toast.info(result.message) + } + void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }) + closeAddAttendeeFlow() + } + + const regenerateTokenMutation = useMutation({ + mutationFn: () => getInviteToken(groupId, true), + onSuccess: (data) => { + queryClient.setQueryData(['invite-token', groupId], data) + toast.success('Se generó un nuevo enlace de invitación.') + }, + onError: () => { + toast.error('No se pudo regenerar el enlace de invitación.') + }, + }) + + const createAttendeeMutation = useMutation({ + mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload), + onMutate: (payload) => setPendingCapacityPayload(payload), + onSuccess: handleCreateSuccess, + onError: (err: Error) => { + if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') { + setIsCapacityModalOpen(true) + return + } + toast.error(err.message || 'Error al agregar miembro.') + }, + }) + + const createAttendeeForceMutation = useMutation({ + mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }), + onSuccess: handleCreateSuccess, + onError: (err: Error) => { + toast.error(err.message || 'Error al agregar miembro.') + }, + }) + + const addToWaitlistMutation = useMutation({ + mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload), + onMutate: async (payload: CreateGroupWaitlistEntry) => { + await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] }) + const previousWaitlist = queryClient.getQueryData(['group-waitlist', groupId]) + + if (previousWaitlist) { + const optimisticEntry: GroupWaitlistEntryDto = { + id: `temp-${Date.now()}`, + groupId, + fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(), + phone: payload.phone, + email: payload.email || null, + notes: payload.notes ?? null, + status: 'PENDING', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + queryClient.setQueryData(['group-waitlist', groupId], { + data: [optimisticEntry, ...previousWaitlist.data], + pagination: { + ...previousWaitlist.pagination, + total: previousWaitlist.pagination.total + 1, + }, + }) + } + + return { previousWaitlist } + }, + onSuccess: (entry) => { + toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`) + invalidateGroupQueries() + closeAddAttendeeFlow() + }, + onError: (err: Error, _payload, context) => { + if (context?.previousWaitlist) { + queryClient.setQueryData(['group-waitlist', groupId], context.previousWaitlist) + } + if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') { + toast.info('Este número ya está en la lista de espera del grupo.') + } else { + toast.error(err.message || 'Error al agregar a la lista de espera.') + } + setPendingCapacityPayload(null) + setIsCapacityModalOpen(false) + }, + }) + + const bulkImportMutation = useMutation({ + mutationFn: (rows: BulkAttendeeRow[]) => bulkCreateAttendees(groupId, { attendees: rows }), + onSuccess: (res) => { + toast.success(res.message) + setParsedContacts([]) + setFileName(null) + void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }) + setIsAddAttendeeModalOpen(false) + }, + onError: (err: Error) => { + toast.error(err.message || 'Error al importar miembros.') + }, + }) + + const removeAttendeeMutation = useMutation({ + mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) => + removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }), + onSuccess: (result, payload) => { + const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro' + if (result.promoted) { + toast.success( + `${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`, + ) + } else { + toast.success(`${removedName} fue quitado del grupo.`) + } + invalidateGroupQueries() + setSelectedAttendee(null) + setAttendeeToRemove(null) + }, + onError: (err: Error) => { + if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') { + toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.') + } else { + toast.error(err.message || 'Error al quitar al miembro del grupo.') + } + setAttendeeToRemove(null) + }, + }) + + const promoteWaitlistMutation = useMutation({ + mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId), + onSuccess: (result) => { + toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`) + invalidateGroupQueries() + }, + onError: (err: Error) => { + if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') { + toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.') + } else { + toast.error(err.message || 'No se pudo pasar al miembro al grupo.') + } + }, + }) + + const removeWaitlistEntryMutation = useMutation({ + mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id), + onSuccess: (_result, entry) => { + toast.success(`${entry.fullName} fue quitado de la lista de espera.`) + invalidateGroupQueries() + }, + onError: (err: Error) => { + toast.error(err.message || 'No se pudo quitar de la lista de espera.') + }, + }) + + const inviteUrl = inviteTokenQuery.data?.inviteUrl + const whatsappMessage = + group && inviteUrl + ? `¡Hola! 👋 Te invito a unirte al grupo *${group.name}*.\n\nCompleta tus datos de inscripción en el siguiente enlace:\n${inviteUrl}` + : '' + const whatsappShareUrl = whatsappMessage + ? `https://wa.me/?text=${encodeURIComponent(whatsappMessage)}` + : '' + + const copyInviteLink = async () => { + if (!inviteUrl) return + try { + await navigator.clipboard.writeText(inviteUrl) + toast.success('¡Enlace de invitación copiado al portapapeles!') + } catch { + toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.') + } + } + + const copyWhatsappMessage = async () => { + if (!whatsappMessage) return + try { + await navigator.clipboard.writeText(whatsappMessage) + toast.success('¡Mensaje copiado al portapapeles!') + } catch { + toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.') + } + } + + const openWhatsapp = async () => { + if (!whatsappShareUrl) return + try { + if (whatsappMessage) { + await navigator.clipboard.writeText(whatsappMessage) + toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.') + } + } catch { + // Si el portapapeles falla, igual abrimos WhatsApp con el mensaje. + } + window.open(whatsappShareUrl, '_blank', 'noopener,noreferrer') + } + + const copyAbsenceNotifyUrl = (url: string) => { + void navigator.clipboard.writeText(url).then(() => { + toast.success('Link copiado al portapapeles.') + }) + } + + const validRows: BulkAttendeeRow[] = useMemo( + () => + parsedContacts + .filter((contact) => contact.isValid) + .map((contact) => ({ + firstName: contact.firstName, + lastName: contact.lastName, + fullName: contact.fullName, + phone: contact.phone, + email: contact.email || undefined, + notes: contact.notes || undefined, + })), + [parsedContacts], + ) + + const runBulkImport = () => { + if (validRows.length === 0) { + toast.error('No hay miembros válidos para importar.') + return + } + bulkImportMutation.mutate(validRows) + } + + const requestBulkImport = () => { + if (validRows.length === 0) { + toast.error('No hay miembros válidos para importar.') + return + } + if (group?.capacity != null && attendeesTotal + validRows.length > group.capacity) { + setIsBulkCapacityModalOpen(true) + return + } + runBulkImport() + } + + const value: GroupDetailContextValue = { + groupId, + group, + groupQueryPending: groupQuery.isPending, + riskLevel: riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE', + attendees, + attendeesTotal, + attendeesPending: attendeesQuery.isPending, + waitlistEntries, + waitlistTotal, + waitlistPending: waitlistQuery.isPending, + firstWaitlistEntry, + hasFreeCapacity, + inviteUrl, + invitePending: inviteTokenQuery.isPending, + whatsappMessage, + searchFilter, + setSearchFilter, + listSection, + setListSection, + activeTab, + setActiveTab, + isInviteModalOpen, + openInviteModal: () => setIsInviteModalOpen(true), + closeInviteModal: () => setIsInviteModalOpen(false), + isAddAttendeeModalOpen, + openAddAttendeeModal: () => setIsAddAttendeeModalOpen(true), + closeAddAttendeeModal: () => setIsAddAttendeeModalOpen(false), + selectedAttendee, + setSelectedAttendee, + attendeeToRemove, + requestRemoveAttendee: (attendee) => setAttendeeToRemove(attendee), + cancelRemoveAttendee: () => setAttendeeToRemove(null), + confirmRemoveAttendee: (promoteFromWaitlist) => { + if (!attendeeToRemove) return + removeAttendeeMutation.mutate({ + attendeeId: attendeeToRemove.id, + promoteFromWaitlist, + }) + }, + selectedWaitlistEntry, + setSelectedWaitlistEntry, + isCapacityModalOpen, + capacityPayload: pendingCapacityPayload, + closeCapacityModal: () => { + setPendingCapacityPayload(null) + setIsCapacityModalOpen(false) + }, + isBulkCapacityModalOpen, + closeBulkCapacityModal: () => setIsBulkCapacityModalOpen(false), + parsedContacts, + setParsedContacts, + removeParsedContact: (id) => setParsedContacts((prev) => prev.filter((c) => c.id !== id)), + fileName, + setFileName, + isParsingFile, + setIsParsingFile, + isDragging, + setIsDragging, + validRowsCount: validRows.length, + invalidRowsCount: parsedContacts.length - validRows.length, + isSubmittingAttendee: createAttendeeMutation.isPending, + isSubmittingForcedAttendee: createAttendeeForceMutation.isPending, + isSubmittingWaitlistEntry: addToWaitlistMutation.isPending, + isSubmittingBulkImport: bulkImportMutation.isPending, + isSubmittingRemoval: removeAttendeeMutation.isPending, + isSubmittingPromotion: promoteWaitlistMutation.isPending, + isSubmittingWaitlistRemoval: removeWaitlistEntryMutation.isPending, + isRegeneratingInvite: regenerateTokenMutation.isPending, + submitQuickAttendee: (payload) => createAttendeeMutation.mutate(payload), + submitForcedAttendee: () => { + if (pendingCapacityPayload) { + createAttendeeForceMutation.mutate({ ...pendingCapacityPayload }) + } + }, + submitWaitlistEntry: () => { + if (pendingCapacityPayload) { + addToWaitlistMutation.mutate({ ...pendingCapacityPayload }) + } + }, + requestBulkImport, + confirmBulkImportOverCapacity: () => { + setIsBulkCapacityModalOpen(false) + runBulkImport() + }, + promoteEntry: (entry) => { + setSelectedWaitlistEntry(null) + promoteWaitlistMutation.mutate(entry.id) + }, + removeEntry: (entry) => { + setSelectedWaitlistEntry(null) + removeWaitlistEntryMutation.mutate(entry) + }, + regenerateInvite: () => regenerateTokenMutation.mutate(), + copyInviteLink, + copyWhatsappMessage, + openWhatsapp, + copyAbsenceNotifyUrl, + } + + return {children} +} + +export function useGroupDetail() { + const context = useContext(GroupDetailContext) + if (!context) { + throw new Error('useGroupDetail debe usarse dentro de ') + } + return context +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/GroupDetailView.tsx b/apps/web/src/features/groups/detail/GroupDetailView.tsx new file mode 100644 index 0000000..2dcb59f --- /dev/null +++ b/apps/web/src/features/groups/detail/GroupDetailView.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from '@tanstack/react-router' +import { Loader2 } from 'lucide-react' +import { Button } from '../../../components/ui' +import { AddAttendeeModal } from './AddAttendeeModal' +import { AttendeeDetailModal } from './AttendeeDetailModal' +import { BulkCapacityModal } from './BulkCapacityModal' +import { CapacityModal } from './CapacityModal' +import { GroupHeader } from './GroupHeader' +import { GroupInfoCard } from './GroupInfoCard' +import { InviteLinkModal } from './InviteLinkModal' +import { MembersWaitlistSection } from './MembersWaitlistSection' +import { RemoveAttendeeModal } from './RemoveAttendeeModal' +import { useGroupDetail } from './GroupDetailProvider' +import { WaitlistEntryDetailModal } from './WaitlistEntryDetailModal' + +export function GroupDetailView() { + const { group, groupQueryPending } = useGroupDetail() + const navigate = useNavigate() + + if (groupQueryPending) { + return ( +
    + +
    + ) + } + + if (!group) { + return ( +
    +

    Grupo no encontrado

    +

    No se pudo cargar la información de este grupo.

    + +
    + ) + } + + return ( +
    + + + + + + + + + + + +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/GroupHeader.tsx b/apps/web/src/features/groups/detail/GroupHeader.tsx new file mode 100644 index 0000000..ad1f9e3 --- /dev/null +++ b/apps/web/src/features/groups/detail/GroupHeader.tsx @@ -0,0 +1,77 @@ +import { Link, useNavigate } from '@tanstack/react-router' +import { ArrowLeft, BarChart3, Share2, UserPlus } from 'lucide-react' +import { Badge, Button } from '../../../components/ui' +import { RISK_LABELS } from '../constants' +import { useGroupDetail } from './GroupDetailProvider' + +export function GroupHeader() { + const { group, groupId, riskLevel, openInviteModal, openAddAttendeeModal } = useGroupDetail() + const navigate = useNavigate() + const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM' + + return ( +
    + + + Volver a grupos + + +
    +
    +
    +

    {group?.name}

    + Activo + {hasRisk ? ( + + + ) : null} +
    + {group?.description ? ( +

    {group.description}

    + ) : null} +
    + +
    + + + +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/GroupInfoCard.tsx b/apps/web/src/features/groups/detail/GroupInfoCard.tsx new file mode 100644 index 0000000..7367125 --- /dev/null +++ b/apps/web/src/features/groups/detail/GroupInfoCard.tsx @@ -0,0 +1,47 @@ +import { CalendarClock, Users } from 'lucide-react' +import { BILLING_LABELS, formatPrice, formatSchedule } from '../../../lib/format' +import { useGroupDetail } from './GroupDetailProvider' + +export function GroupInfoCard() { + const { group, attendees, waitlistTotal } = useGroupDetail() + if (!group) return null + + return ( +
    +
    + +
    +

    Horario

    +

    + {(group.days?.length ?? 0) > 0 + ? formatSchedule(group.days ?? [], group.time) + : 'Sin horario definido'} +

    +
    +
    + +
    + +
    +

    Miembros & Cupo

    +

    + {attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''} + {group.capacity ? ` · Cupo de ${group.capacity}` : ''} +

    +
    +
    + +
    +
    $
    +
    +

    Cobro

    +

    + {group.price != null ? formatPrice(group.price) : 'Sin precio'} + {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} + {group.dueDay ? ` · Vence día ${group.dueDay}` : ''} +

    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/InviteLinkModal.tsx b/apps/web/src/features/groups/detail/InviteLinkModal.tsx new file mode 100644 index 0000000..2a29b80 --- /dev/null +++ b/apps/web/src/features/groups/detail/InviteLinkModal.tsx @@ -0,0 +1,117 @@ +import { Copy, Loader2, MessageCircle, RefreshCw } from 'lucide-react' +import { Button, Input, Label, Modal } from '../../../components/ui' +import { cn } from '../../../lib/utils' +import { useGroupDetail } from './GroupDetailProvider' + +export function InviteLinkModal() { + const { + isInviteModalOpen, + closeInviteModal, + group, + inviteUrl, + invitePending, + isRegeneratingInvite, + regenerateInvite, + copyInviteLink, + copyWhatsappMessage, + openWhatsapp, + } = useGroupDetail() + + return ( + +
    + {invitePending ? ( +
    + +
    + ) : ( + <> +
    + +
    + + +
    +
    + +
    +
    +

    Vista previa del mensaje para WhatsApp:

    + +
    +
    + {`¡Hola! 👋 Te invito a unirte al grupo `} + {group?.name}. + {'\n\n'} + Completa tus datos de inscripción en el siguiente enlace: + {'\n'} + {inviteUrl} +
    +
    + +
    + + + +
    + +

    + Al hacer clic en Abrir en WhatsApp, el mensaje completo se copia + automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, podés pegarlo + directamente con Ctrl+V{' '} + o Cmd+V. +

    + + )} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/MembersTable.tsx b/apps/web/src/features/groups/detail/MembersTable.tsx new file mode 100644 index 0000000..b3b154a --- /dev/null +++ b/apps/web/src/features/groups/detail/MembersTable.tsx @@ -0,0 +1,45 @@ +import type { AttendeeDto } from '@gruperly/shared' +import { ChevronRight } from 'lucide-react' +import { useGroupDetail } from './GroupDetailProvider' + +export function MembersTable({ attendees }: { attendees: AttendeeDto[] }) { + const { setSelectedAttendee } = useGroupDetail() + + return ( +
    + + + + + + + + + {attendees.map((attendee) => ( + setSelectedAttendee(attendee)} + > + + + + + ))} + +
    NombreTeléfono +
    +
    +
    + {attendee.fullName.charAt(0).toUpperCase()} +
    + {attendee.fullName} +
    +
    + {attendee.phone || —} + + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/MembersWaitlistSection.tsx b/apps/web/src/features/groups/detail/MembersWaitlistSection.tsx new file mode 100644 index 0000000..301dc08 --- /dev/null +++ b/apps/web/src/features/groups/detail/MembersWaitlistSection.tsx @@ -0,0 +1,146 @@ +import { Clock, Loader2, Plus, Search, Share2, Users } from 'lucide-react' +import { Button, Input } from '../../../components/ui' +import { cn } from '../../../lib/utils' +import { useGroupDetail } from './GroupDetailProvider' +import { MembersTable } from './MembersTable' +import { WaitlistTable } from './WaitlistTable' + +export function MembersWaitlistSection() { + const { + attendees, + attendeesTotal, + attendeesPending, + waitlistEntries, + waitlistTotal, + waitlistPending, + listSection, + setListSection, + searchFilter, + setSearchFilter, + openInviteModal, + openAddAttendeeModal, + } = useGroupDetail() + + const query = searchFilter.toLowerCase() + const filteredAttendees = attendees.filter( + (a) => + a.fullName.toLowerCase().includes(query) || + (a.phone && a.phone.includes(query)) || + (a.email && a.email.toLowerCase().includes(query)), + ) + + return ( +
    +
    +
    +
    + + +
    + + {listSection === 'members' ? ( +
    + + setSearchFilter(e.target.value)} + className="h-9 pl-9 text-xs" + /> +
    + ) : null} +
    + +

    + {listSection === 'members' + ? 'Listado de todos los miembros incorporados al grupo.' + : 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'} +

    +
    + + {listSection === 'members' ? ( + <> + {attendeesPending ? ( +
    + +
    + ) : null} + + {!attendeesPending && attendees.length === 0 ? ( +
    + +

    Todavía no hay miembros en este grupo

    +

    + Puedes compartir el enlace de invitación único o agregar miembros de forma manual o + masiva. +

    +
    + + +
    +
    + ) : null} + + {attendees.length > 0 && filteredAttendees.length === 0 ? ( +
    + No se encontraron miembros que coincidan con la búsqueda. +
    + ) : null} + + {filteredAttendees.length > 0 ? : null} + + ) : ( +
    + {waitlistPending ? ( +
    + +
    + ) : null} + + {!waitlistPending && waitlistEntries.length === 0 ? ( +
    + +

    No hay nadie en la lista de espera

    +

    + Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás + pasarlas al grupo desde aquí. +

    +
    + ) : null} + + {waitlistEntries.length > 0 ? : null} +
    + )} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/QuickAddForm.tsx b/apps/web/src/features/groups/detail/QuickAddForm.tsx new file mode 100644 index 0000000..6d2b94b --- /dev/null +++ b/apps/web/src/features/groups/detail/QuickAddForm.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { Check, Loader2 } from 'lucide-react' +import { Button, Input, Label, useToast } from '../../../components/ui' +import { useGroupDetail } from './GroupDetailProvider' + +export function QuickAddForm() { + const { submitQuickAttendee, isSubmittingAttendee, closeAddAttendeeModal } = useGroupDetail() + const toast = useToast() + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [phone, setPhone] = useState('') + const [email, setEmail] = useState('') + const [notes, setNotes] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!firstName.trim() || !phone.trim()) { + toast.error('Nombre y teléfono son obligatorios.') + return + } + submitQuickAttendee({ + firstName: firstName.trim(), + lastName: lastName.trim(), + phone: phone.trim(), + email: email.trim() || undefined, + notes: notes.trim() || undefined, + }) + } + + const resetForm = () => { + setFirstName('') + setLastName('') + setPhone('') + setEmail('') + setNotes('') + } + + return ( +
    +
    +
    + + setFirstName(e.target.value)} + required + /> +
    +
    + + setLastName(e.target.value)} + /> +
    +
    + +
    + + setPhone(e.target.value)} + required + /> +

    + Usado para contacto y validación de duplicados. +

    +
    + +
    + + setEmail(e.target.value)} + /> +
    + +
    + + setNotes(e.target.value)} + /> +
    + +
    + + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx b/apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx new file mode 100644 index 0000000..445e545 --- /dev/null +++ b/apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx @@ -0,0 +1,99 @@ +import { Clock, Loader2, Trash2, UserCheck, UserMinus } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { useGroupDetail } from './GroupDetailProvider' + +export function RemoveAttendeeModal() { + const { + attendeeToRemove, + cancelRemoveAttendee, + confirmRemoveAttendee, + group, + firstWaitlistEntry, + waitlistTotal, + isSubmittingRemoval, + } = useGroupDetail() + + return ( + + {attendeeToRemove ? ( +
    +

    + {attendeeToRemove.fullName} dejará de ser miembro del grupo{' '} + {group?.name} + {firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'} + {attendeeToRemove.phone ? ( + Teléfono: {attendeeToRemove.phone} + ) : null} +

    + + {firstWaitlistEntry ? ( +
    +
    + + + Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo + +
    +

    + ¿Quieres pasar a {firstWaitlistEntry.fullName}, el + primero de la lista de espera, al grupo? +

    +
    + + +
    +
    + ) : ( +
    + + +
    + )} +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/WaitlistEntryDetailModal.tsx b/apps/web/src/features/groups/detail/WaitlistEntryDetailModal.tsx new file mode 100644 index 0000000..2c651f1 --- /dev/null +++ b/apps/web/src/features/groups/detail/WaitlistEntryDetailModal.tsx @@ -0,0 +1,118 @@ +import { Loader2, Mail, MessageCircle, Phone, StickyNote, Trash2, UserCheck } from 'lucide-react' +import { Button, Modal } from '../../../components/ui' +import { DetailRow } from './DetailRow' +import { useGroupDetail } from './GroupDetailProvider' + +export function WaitlistEntryDetailModal() { + const { + selectedWaitlistEntry, + setSelectedWaitlistEntry, + hasFreeCapacity, + promoteEntry, + removeEntry, + isSubmittingPromotion, + isSubmittingWaitlistRemoval, + } = useGroupDetail() + + return ( + setSelectedWaitlistEntry(null)} + title="Detalle de la lista de espera" + maxWidth="sm" + > + {selectedWaitlistEntry ? ( +
    +
    +
    + {selectedWaitlistEntry.fullName.charAt(0).toUpperCase()} +
    +
    +

    {selectedWaitlistEntry.fullName}

    +

    + En espera desde el{' '} + {new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

    +
    +
    + +
    + } label="Teléfono"> + {selectedWaitlistEntry.phone ? ( +
    + {selectedWaitlistEntry.phone} + + + WhatsApp + +
    + ) : ( + Sin teléfono + )} +
    + + {selectedWaitlistEntry.email ? ( + } label="Email"> + + {selectedWaitlistEntry.email} + + + ) : null} + + } label="Notas"> + {selectedWaitlistEntry.notes ? ( + {selectedWaitlistEntry.notes} + ) : ( + Sin notas + )} + +
    + + {!hasFreeCapacity ? ( +

    + El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar a esta + persona al grupo. +

    + ) : null} + +
    + + +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/groups/detail/WaitlistTable.tsx b/apps/web/src/features/groups/detail/WaitlistTable.tsx new file mode 100644 index 0000000..fe51b85 --- /dev/null +++ b/apps/web/src/features/groups/detail/WaitlistTable.tsx @@ -0,0 +1,48 @@ +import type { GroupWaitlistEntryDto } from '@gruperly/shared' +import { ChevronRight } from 'lucide-react' +import { useGroupDetail } from './GroupDetailProvider' + +export function WaitlistTable({ entries }: { entries: GroupWaitlistEntryDto[] }) { + const { setSelectedWaitlistEntry } = useGroupDetail() + + return ( +
    + + + + + + + + + {entries.map((entry) => ( + setSelectedWaitlistEntry(entry)} + > + + + + + ))} + +
    NombreTeléfono +
    +
    +
    + {entry.fullName.charAt(0).toUpperCase()} +
    +
    +

    {entry.fullName}

    + {entry.notes ? ( +

    {entry.notes}

    + ) : null} +
    +
    +
    {entry.phone} + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/HomeView.tsx b/apps/web/src/features/home/HomeView.tsx new file mode 100644 index 0000000..bc58b66 --- /dev/null +++ b/apps/web/src/features/home/HomeView.tsx @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { Loader2, Plus } from 'lucide-react' +import { Button } from '../../components/ui' +import { getClassesToday, getHomeSummary } from '../../lib/api' +import { SummaryStats } from './SummaryStats' +import { TodayClassCard } from './TodayClassCard' +import { NextClassHero } from './NextClassHero' +import { UpcomingPaymentRow } from './UpcomingPaymentRow' + +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 ( +
    +
    +

    Inicio

    +

    Tu actividad de cobros de un vistazo.

    +
    + + {summaryQuery.isPending ? ( +
    + +
    + ) : null} + + {summaryQuery.isError ? ( +
    +

    No pudimos cargar tu resumen.

    + +
    + ) : null} + + {summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? ( +
    +

    Todavía no tenés grupos

    +

    + Crea tu primer grupo para empezar a cobrar. +

    + +
    + ) : null} + + {summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? ( +
    + {todayClasses.length > 0 ? ( +
    + {todayClasses.map((classItem) => ( + + ))} +
    + ) : summaryQuery.data.nextClass ? ( + + ) : ( +
    +

    Sin clases programadas

    +

    + Agregá días y horario a tus grupos para ver tu próxima clase acá. +

    +
    + )} + + + +
    +
    +

    Próximos cobros

    + +
    + + {summaryQuery.data.upcomingPayments.length > 0 ? ( +
      + {summaryQuery.data.upcomingPayments.map((payment) => ( + + ))} +
    + ) : ( +

    + Sin cobros pendientes por ahora. +

    + )} +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/NextClassHero.tsx b/apps/web/src/features/home/NextClassHero.tsx new file mode 100644 index 0000000..fab5834 --- /dev/null +++ b/apps/web/src/features/home/NextClassHero.tsx @@ -0,0 +1,43 @@ +import { useNavigate } from '@tanstack/react-router' +import type { NextClassDto } from '@gruperly/shared' +import { CalendarClock } from 'lucide-react' +import { Badge, Button } from '../../components/ui' +import { formatRelativeDateTime, formatSchedule } from '../../lib/format' + +export function NextClassHero({ nextClass }: { nextClass: NextClassDto }) { + const navigate = useNavigate() + const hasSchedule = (nextClass.days?.length ?? 0) > 0 + + return ( +
    +
    +
    +

    + {nextClass.isNow ? 'Clase en curso' : 'Próxima clase'} +

    +

    {nextClass.name}

    + {hasSchedule ? ( +
    + + {formatSchedule(nextClass.days ?? [], nextClass.time)} +
    + ) : null} +
    +
    + + {nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)} + +
    +
    +
    + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/SummaryStats.tsx b/apps/web/src/features/home/SummaryStats.tsx new file mode 100644 index 0000000..4dfe454 --- /dev/null +++ b/apps/web/src/features/home/SummaryStats.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from '@tanstack/react-router' +import type { HomeSummaryDto } from '@gruperly/shared' +import { UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react' +import { cn } from '../../lib/utils' +import { formatPrice } from '../../lib/format' + +type StatTarget = '/groups' | '/payments' + +export function SummaryStats({ summary }: { summary: HomeSummaryDto }) { + const navigate = useNavigate() + const stats: { + label: string + value: string + sub?: string + icon: LucideIcon + to: StatTarget + }[] = [ + { + 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 ( +
    + {stats.map((stat) => { + const Icon = stat.icon + return ( + + ) + })} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/TodayClassCard.tsx b/apps/web/src/features/home/TodayClassCard.tsx new file mode 100644 index 0000000..f5099b2 --- /dev/null +++ b/apps/web/src/features/home/TodayClassCard.tsx @@ -0,0 +1,58 @@ +import { useNavigate } from '@tanstack/react-router' +import type { ClassTodayDto } from '@gruperly/shared' +import { CalendarClock, ClipboardCheck } from 'lucide-react' +import { Badge, Button } from '../../components/ui' + +export 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 ( +
    +
    +
    +

    + Clase de hoy +

    +

    {item.groupName}

    +
    + + Hoy · {time} hs +
    +

    + {item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'} + {item.availableSlots != null + ? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}` + : ''} +

    +
    +
    + + {item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'} + +
    +
    +
    + + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/UpcomingPaymentRow.tsx b/apps/web/src/features/home/UpcomingPaymentRow.tsx new file mode 100644 index 0000000..7571ad9 --- /dev/null +++ b/apps/web/src/features/home/UpcomingPaymentRow.tsx @@ -0,0 +1,23 @@ +import type { UpcomingPaymentDto } from '@gruperly/shared' +import { Badge } from '../../components/ui' +import { formatPrice, formatRelativeDateTime, PAYMENT_STATUS_LABELS } from '../../lib/format' +import { PAYMENT_BADGE_VARIANT } from './constants' + +export function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) { + return ( +
  • +
    +

    {payment.attendeeName}

    +

    + {payment.groupName} · {formatRelativeDateTime(payment.dueDate)} +

    +
    +
    + {formatPrice(payment.amount)} + + {PAYMENT_STATUS_LABELS[payment.status]} + +
    +
  • + ) +} \ No newline at end of file diff --git a/apps/web/src/features/home/constants.ts b/apps/web/src/features/home/constants.ts new file mode 100644 index 0000000..18cb8c0 --- /dev/null +++ b/apps/web/src/features/home/constants.ts @@ -0,0 +1,10 @@ +import type { PaymentDto } from '@gruperly/shared' + +export type PaymentBadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' + +export const PAYMENT_BADGE_VARIANT: Record = { + PENDING: 'warning', + OVERDUE: 'danger', + PAID: 'success', + CANCELLED: 'neutral', +} \ No newline at end of file diff --git a/apps/web/src/features/join/GroupInviteSummary.tsx b/apps/web/src/features/join/GroupInviteSummary.tsx new file mode 100644 index 0000000..35a9c33 --- /dev/null +++ b/apps/web/src/features/join/GroupInviteSummary.tsx @@ -0,0 +1,48 @@ +import { CalendarClock, GraduationCap, Users } from 'lucide-react' +import { Badge } from '../../components/ui' +import { BILLING_LABELS, formatPrice, formatSchedule } from '../../lib/format' +import { useJoinGroup } from './JoinGroupProvider' + +export function GroupInviteSummary() { + const { group } = useJoinGroup() + if (!group) return null + + return ( +
    +
    + + + Invitación Oficial + +
    +

    {group.name}

    +

    + Profesor: {group.teacherName} +

    + {group.description ?

    {group.description}

    : null} + +
    + {(group.days?.length ?? 0) > 0 ? ( +
    + + {formatSchedule(group.days ?? [], group.time)} +
    + ) : null} + + {group.price != null ? ( +
    + {formatPrice(group.price)} + {group.billingType ? · {BILLING_LABELS[group.billingType]} : ''} +
    + ) : null} + + {group.capacity ? ( +
    + + Cupo {group.capacity} +
    + ) : null} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/join/InvalidInviteCard.tsx b/apps/web/src/features/join/InvalidInviteCard.tsx new file mode 100644 index 0000000..adcfe8e --- /dev/null +++ b/apps/web/src/features/join/InvalidInviteCard.tsx @@ -0,0 +1,11 @@ +export function InvalidInviteCard() { + return ( +
    +

    Enlace no válido o expirado

    +

    + No pudimos encontrar este grupo. Por favor consulta con tu profesor para solicitar un nuevo enlace de + invitación. +

    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/join/JoinForm.tsx b/apps/web/src/features/join/JoinForm.tsx new file mode 100644 index 0000000..f37c5c3 --- /dev/null +++ b/apps/web/src/features/join/JoinForm.tsx @@ -0,0 +1,113 @@ +import { Loader2 } from 'lucide-react' +import { Button, Input, Label } from '../../components/ui' +import { useJoinGroup } from './JoinGroupProvider' + +export function JoinForm() { + const { + firstName, + setFirstName, + lastName, + setLastName, + phone, + setPhone, + email, + setEmail, + errorMessage, + submit, + isSubmitting, + } = useJoinGroup() + + return ( +
    +
    +

    Completa tus datos

    +

    + Ingresa tus datos para registrarte en la lista de alumnos de este grupo. +

    +
    + + {errorMessage ? ( +
    + {errorMessage} +
    + ) : null} + +
    { + e.preventDefault() + submit() + }} + className="space-y-4" + > +
    +
    + + setFirstName(e.target.value)} + required + /> +
    + +
    + + setLastName(e.target.value)} + required + /> +
    +
    + +
    + + setPhone(e.target.value)} + required + /> +

    + El profesor se comunicará contigo por WhatsApp a este número. +

    +
    + +
    + + setEmail(e.target.value)} + /> +
    + +
    + +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/join/JoinGroupProvider.tsx b/apps/web/src/features/join/JoinGroupProvider.tsx new file mode 100644 index 0000000..33cdb2b --- /dev/null +++ b/apps/web/src/features/join/JoinGroupProvider.tsx @@ -0,0 +1,128 @@ +import { createContext, useContext, useState } from 'react' +import type { ReactNode } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import type { GroupInviteInfoDto } from '@gruperly/shared' +import { ApiError, getInviteInfo, joinViaInvite } from '../../lib/api' + +type RegisteredAttendee = { + fullName: string + phone: string | null +} + +type JoinGroupContextValue = { + token: string + group: GroupInviteInfoDto | undefined + invitePending: boolean + inviteFailed: boolean + firstName: string + setFirstName: (value: string) => void + lastName: string + setLastName: (value: string) => void + phone: string + setPhone: (value: string) => void + email: string + setEmail: (value: string) => void + errorMessage: string | null + registeredAttendee: RegisteredAttendee | null + waitlistMessage: string | null + submit: () => void + isSubmitting: boolean +} + +const JoinGroupContext = createContext(null) + +export function JoinGroupProvider({ token, children }: { token: string; children: ReactNode }) { + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [phone, setPhone] = useState('') + const [email, setEmail] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [registeredAttendee, setRegisteredAttendee] = useState(null) + const [waitlistMessage, setWaitlistMessage] = useState(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) + setWaitlistMessage(data.message) + return + } + setWaitlistMessage(null) + setRegisteredAttendee({ + fullName: data.attendee?.fullName ?? `${firstName.trim()} ${lastName.trim()}`.trim(), + phone: data.attendee?.phone ?? null, + }) + }, + 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.') + return + } + if (error instanceof Error) { + setErrorMessage(error.message) + return + } + setErrorMessage('Ocurrió un error inesperado. Por favor, intenta de nuevo.') + }, + }) + + const submit = () => { + 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 value: JoinGroupContextValue = { + token, + group: inviteQuery.data, + invitePending: inviteQuery.isPending, + inviteFailed: inviteQuery.isError, + firstName, + setFirstName, + lastName, + setLastName, + phone, + setPhone, + email, + setEmail, + errorMessage, + registeredAttendee, + waitlistMessage, + submit, + isSubmitting: joinMutation.isPending, + } + + return {children} +} + +export function useJoinGroup() { + const context = useContext(JoinGroupContext) + if (!context) { + throw new Error('useJoinGroup debe usarse dentro de ') + } + return context +} \ No newline at end of file diff --git a/apps/web/src/features/join/JoinGroupView.tsx b/apps/web/src/features/join/JoinGroupView.tsx new file mode 100644 index 0000000..0ab1c6c --- /dev/null +++ b/apps/web/src/features/join/JoinGroupView.tsx @@ -0,0 +1,43 @@ +import { Loader2 } from 'lucide-react' +import { PublicFooter, PublicHeader } from '../../components/layout' +import { GroupInviteSummary } from './GroupInviteSummary' +import { InvalidInviteCard } from './InvalidInviteCard' +import { JoinForm } from './JoinForm' +import { JoinSuccessCard } from './JoinSuccessCard' +import { useJoinGroup } from './JoinGroupProvider' +import { WaitlistNoticeCard } from './WaitlistNoticeCard' + +export function JoinGroupView() { + const { group, invitePending, inviteFailed, registeredAttendee, waitlistMessage } = useJoinGroup() + + return ( +
    + + +
    +
    + {invitePending ? ( +
    + +

    Cargando información del grupo...

    +
    + ) : null} + + {inviteFailed || (!group && !invitePending) ? : null} + + {registeredAttendee ? : null} + {waitlistMessage ? : null} + + {!registeredAttendee && !waitlistMessage && group ? ( +
    + + +
    + ) : null} +
    +
    + + +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/join/JoinSuccessCard.tsx b/apps/web/src/features/join/JoinSuccessCard.tsx new file mode 100644 index 0000000..ccb451a --- /dev/null +++ b/apps/web/src/features/join/JoinSuccessCard.tsx @@ -0,0 +1,49 @@ +import { CheckCircle2 } from 'lucide-react' +import { Badge } from '../../components/ui' +import { useJoinGroup } from './JoinGroupProvider' + +export function JoinSuccessCard() { + const { group, registeredAttendee } = useJoinGroup() + if (!group || !registeredAttendee) return null + + return ( +
    +
    + +
    + +
    + + Registro Confirmado + +

    ¡Inscripción Exitosa!

    +

    + Tus datos han sido registrados con éxito en el grupo{' '} + {group.name}. +

    +
    + +
    +
    + Profesor: + {group.teacherName} +
    +
    + Alumno: + {registeredAttendee.fullName} +
    + {registeredAttendee.phone ? ( +
    + Teléfono registrado: + {registeredAttendee.phone} +
    + ) : null} +
    + +

    + El profesor se pondrá en contacto contigo a la brevedad por WhatsApp para darte la bienvenida y + coordinar los detalles de la clase. +

    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/join/WaitlistNoticeCard.tsx b/apps/web/src/features/join/WaitlistNoticeCard.tsx new file mode 100644 index 0000000..b84435a --- /dev/null +++ b/apps/web/src/features/join/WaitlistNoticeCard.tsx @@ -0,0 +1,29 @@ +import { Clock } from 'lucide-react' +import { Badge } from '../../components/ui' +import { useJoinGroup } from './JoinGroupProvider' + +export function WaitlistNoticeCard() { + const { group, waitlistMessage } = useJoinGroup() + if (!group || !waitlistMessage) return null + + return ( +
    +
    + +
    + +
    + + Lista de Espera + +

    Cupo completo

    +

    {waitlistMessage}

    +
    + +

    + El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '} + {group.name}. +

    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/ConfirmationStep.tsx b/apps/web/src/features/onboarding/ConfirmationStep.tsx new file mode 100644 index 0000000..3dfc98c --- /dev/null +++ b/apps/web/src/features/onboarding/ConfirmationStep.tsx @@ -0,0 +1,84 @@ +import { useNavigate } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' +import type { OnboardingGroupDto } from '@gruperly/shared' +import { ArrowRight, CheckCircle2, CreditCard } from 'lucide-react' +import { useAuth } from '../../context/AuthProvider' +import { Badge, Button } from '../../components/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 ( +
    +
    + + + +

    ¡Todo listo!

    +

    + Tu grupo se creó y ya podés empezar a cobrar a tus miembros. +

    +
    + +
    +
    +
    +

    {group.name}

    +

    + {formatSchedule(group.days ?? [], group.time)} +

    +
    + Activo +
    + +
    +
    +
    Precio
    +
    + {formatPrice(group.price)} + {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} +
    +
    +
    +
    Cupo
    +
    {group.capacity ?? '—'} miembros
    +
    +
    +
    Vencimiento
    +
    Día {group.dueDay ?? '—'} de cada mes
    +
    +
    +
    + + {providerName ? ( +
    + +

    + Vas a cobrar con {providerName} en modo + prueba. +

    +
    + ) : null} + + +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/FirstGroupStep.tsx b/apps/web/src/features/onboarding/FirstGroupStep.tsx new file mode 100644 index 0000000..32da7bd --- /dev/null +++ b/apps/web/src/features/onboarding/FirstGroupStep.tsx @@ -0,0 +1,30 @@ +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 ( + create.mutate(values)} + onBack={onBack} + /> + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/OnboardingProvider.tsx b/apps/web/src/features/onboarding/OnboardingProvider.tsx new file mode 100644 index 0000000..8294b4a --- /dev/null +++ b/apps/web/src/features/onboarding/OnboardingProvider.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { ConnectPaymentResult, OnboardingGroupDto, OnboardingStatusDto } from '@gruperly/shared' +import type { UseQueryResult } from '@tanstack/react-query' +import { useAuth } from '../../context/AuthProvider' +import { getOnboardingStatus } from '../../lib/api' +import { PAYMENT_PROVIDERS } from './constants' + +type OnboardingContextValue = { + step: number + setStep: (step: number) => void + paymentResult: ConnectPaymentResult | null + setPaymentResult: (result: ConnectPaymentResult) => void + createdGroup: OnboardingGroupDto | null + setCreatedGroup: (group: OnboardingGroupDto) => void + providerName: string | undefined + statusQuery: UseQueryResult +} + +const OnboardingContext = createContext(null) + +export function OnboardingProvider({ children }: { children: ReactNode }) { + const { user } = useAuth() + const [step, setStep] = useState(0) + const [paymentResult, setPaymentResult] = useState(null) + const [createdGroup, setCreatedGroup] = useState(null) + + const statusQuery = useQuery({ + queryKey: ['onboarding', 'status'], + queryFn: getOnboardingStatus, + enabled: !!user, + }) + + useEffect(() => { + const status = statusQuery.data + if (!status) 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]) + + const providerName = paymentResult + ? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name + : undefined + + return ( + + {children} + + ) +} + +export function useOnboarding() { + const context = useContext(OnboardingContext) + if (!context) { + throw new Error('useOnboarding debe usarse dentro de ') + } + return context +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/OnboardingView.tsx b/apps/web/src/features/onboarding/OnboardingView.tsx new file mode 100644 index 0000000..bfd40e4 --- /dev/null +++ b/apps/web/src/features/onboarding/OnboardingView.tsx @@ -0,0 +1,117 @@ +import { useEffect } from 'react' +import { Loader2 } from 'lucide-react' +import { useNavigate } from '@tanstack/react-router' +import { Logo, LogoIcon } from '../../components/brand' +import { Button } from '../../components/ui' +import { useAuth } from '../../context/AuthProvider' +import { useOnboarding } from './OnboardingProvider' +import { Stepper } from './Stepper' +import { WelcomeStep } from './WelcomeStep' +import { PaymentStep } from './PaymentStep' +import { FirstGroupStep } from './FirstGroupStep' +import { ConfirmationStep } from './ConfirmationStep' + +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, + paymentResult, + setPaymentResult, + createdGroup, + setCreatedGroup, + providerName, + statusQuery, + } = useOnboarding() + + useEffect(() => { + if (statusQuery.data?.completed) { + void navigate({ to: '/' }) + } + }, [statusQuery.data, navigate]) + + if (authPending) { + return ( +
    + +
    + ) + } + + if (!user) { + return ( +
    +
    + + + +

    Tu cuenta, lista

    +

    + Iniciá sesión para configurar tus cobros y crear tu primer grupo. +

    + +
    +
    + ) + } + + if (statusQuery.isPending) { + return ( +
    + +
    + ) + } + + const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a' + + return ( +
    +
    + +
    + + + +
    + {step === 0 ? setStep(1)} /> : null} + {step === 1 ? ( + { + setPaymentResult(result) + setStep(2) + }} + onBack={() => setStep(0)} + /> + ) : null} + {step === 2 ? ( + setStep(1)} + onCompleted={(group) => { + setCreatedGroup(group) + setStep(3) + }} + /> + ) : null} + {step === 3 && createdGroup ? ( + + ) : null} +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/PaymentStep.tsx b/apps/web/src/features/onboarding/PaymentStep.tsx new file mode 100644 index 0000000..0b7a51f --- /dev/null +++ b/apps/web/src/features/onboarding/PaymentStep.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import type { ConnectPaymentResult, PaymentProvider } from '@gruperly/shared' +import { + ArrowLeft, + ArrowRight, + BadgeCheck, + Check, + CheckCircle2, + CreditCard, + Loader2, + ShieldCheck, +} from 'lucide-react' +import { connectPayment } from '../../lib/api' +import { cn } from '../../lib/utils' +import { Badge, Button } from '../../components/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('MERCADO_PAGO') + const [connected, setConnected] = useState(initialResult) + + const connect = useMutation({ + mutationFn: () => connectPayment({ provider: selected, sandbox: true }), + onSuccess: (result) => { + setConnected(result) + onConnected(result) + }, + }) + + if (connected) { + return ( +
    +
    +

    Tu cobro está conectado

    +

    + Ya podés dejar listo tu primer grupo para cobrar con{' '} + {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}. +

    +
    + +
    + + + +

    Cuenta conectada

    +
    + + {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name} + + Modo prueba +
    +
    + +
    + + +
    +
    + ) + } + + return ( +
    +
    +

    Elegí tu procesador de cobro

    +

    + Los pagos de tus miembros van a llegar por esta plataforma. +

    +
    + +
    + {PAYMENT_PROVIDERS.map((provider) => { + const isSelected = selected === provider.value + return ( + + ) + })} +
    + +
    + +

    + 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'}. +

    +
    + + {connect.isError ? ( +

    + No pudimos conectar la cuenta. Intentalo de nuevo. +

    + ) : null} + +
    + + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/Stepper.tsx b/apps/web/src/features/onboarding/Stepper.tsx new file mode 100644 index 0000000..491d4d3 --- /dev/null +++ b/apps/web/src/features/onboarding/Stepper.tsx @@ -0,0 +1,59 @@ +import { Check } from 'lucide-react' +import { cn } from '../../lib/utils' + +export type StepperProps = { + steps: readonly { label: string }[] + current: number +} + +export function Stepper({ steps, current }: StepperProps) { + return ( +
      + {steps.map((step, index) => { + const isDone = index < current + const isActive = index === current + + return ( +
    1. +
      + + {isDone ? ( + + ) : ( + {index + 1} + )} + + + {step.label} + +
      + + {index < steps.length - 1 ? ( + + ) : null} +
    2. + ) + })} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/WelcomeStep.tsx b/apps/web/src/features/onboarding/WelcomeStep.tsx new file mode 100644 index 0000000..74021ac --- /dev/null +++ b/apps/web/src/features/onboarding/WelcomeStep.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react' +import { Button } from '../../components/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 ( +
    +
    + + + +

    ¡Hola, {name}!

    +

    + Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos. +

    +
    + +
      + {STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => ( +
    1. + + + +
      +

      {title}

      +

      {description}

      +
      +
    2. + ))} +
    + + +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/onboarding/constants.ts b/apps/web/src/features/onboarding/constants.ts new file mode 100644 index 0000000..308199a --- /dev/null +++ b/apps/web/src/features/onboarding/constants.ts @@ -0,0 +1,44 @@ +import type { BillingType, PaymentProvider, WeekDay } from '@gruperly/shared' +export { BILLING_LABELS, WEEK_DAY_FULL_LABELS, formatPrice, formatSchedule } from '../../lib/format' + +export const WEEK_DAYS: readonly WeekDay[] = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +] + +export const WEEK_DAY_CHIPS: readonly { value: WeekDay; label: string }[] = [ + { value: 'MONDAY', label: 'Lun' }, + { value: 'TUESDAY', label: 'Mar' }, + { value: 'WEDNESDAY', label: 'Mié' }, + { value: 'THURSDAY', label: 'Jue' }, + { value: 'FRIDAY', label: 'Vie' }, + { value: 'SATURDAY', label: 'Sáb' }, + { value: 'SUNDAY', label: 'Dom' }, +] + +export const PAYMENT_PROVIDERS: readonly { + value: PaymentProvider + name: string + description: string +}[] = [ + { + value: 'MERCADO_PAGO', + name: 'Mercado Pago', + description: 'El procesador más usado en Latinoamérica', + }, + { + value: 'STRIPE', + name: 'Stripe', + description: 'Cobrá con tarjetas e internacionalmente', + }, +] + +export const BILLING_TYPES: readonly { value: BillingType; label: string; hint: string }[] = [ + { value: 'MONTHLY', label: 'Mensual', hint: 'Un cobro por mes' }, + { value: 'PER_CLASS', label: 'Por clase', hint: 'Cada clase que asista' }, +] \ No newline at end of file diff --git a/apps/web/src/features/payments/PaymentsView.tsx b/apps/web/src/features/payments/PaymentsView.tsx new file mode 100644 index 0000000..4c66b40 --- /dev/null +++ b/apps/web/src/features/payments/PaymentsView.tsx @@ -0,0 +1,8 @@ +export function PaymentsView() { + return ( +
    +

    Cobros

    +

    Sigue los pagos de tus grupos.

    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/profile/ProfileView.tsx b/apps/web/src/features/profile/ProfileView.tsx new file mode 100644 index 0000000..f8d7f59 --- /dev/null +++ b/apps/web/src/features/profile/ProfileView.tsx @@ -0,0 +1,27 @@ +import { useAuth } from '../../context/AuthProvider' +import { Avatar } from '../../components/ui' + +export function ProfileView() { + const { user } = useAuth() + + return ( +
    +
    +

    Mi perfil

    +

    Tus datos personales.

    +
    + +
    +
    + +
    +

    + {user?.name ?? 'Usuario'} +

    +

    {user?.email}

    +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/security/PasskeyList.tsx b/apps/web/src/features/security/PasskeyList.tsx new file mode 100644 index 0000000..d3e9067 --- /dev/null +++ b/apps/web/src/features/security/PasskeyList.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import { Fingerprint, Loader2, Trash2 } from 'lucide-react' +import { authClient } from '../../lib/auth-client' +import { Button } from '../../components/ui' + +export function PasskeyList({ onRefresh }: { onRefresh: () => void }) { + const { data, isPending } = authClient.useListPasskeys() + const [deletingId, setDeletingId] = useState(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 + } + + const passkeys = data ?? [] + if (passkeys.length === 0) { + return

    Todavía no registraste ninguna passkey.

    + } + + return ( +
      + {passkeys.map((passkey) => ( +
    • + + + +
      +

      {passkey.name ?? 'Passkey'}

      +

      + {passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '} + {new Date(passkey.createdAt).toLocaleDateString('es-AR')} +

      +
      + +
    • + ))} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/security/SecurityPage.tsx b/apps/web/src/features/security/SecurityPage.tsx new file mode 100644 index 0000000..58b4d2f --- /dev/null +++ b/apps/web/src/features/security/SecurityPage.tsx @@ -0,0 +1,123 @@ +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 } from 'lucide-react' +import { authClient } from '../../lib/auth-client' +import { Button, Input, Label } from '../../components/ui' +import { PasskeyList } from './PasskeyList' +import { changePasswordSchema } from './schema' +import type { ChangePasswordValues } from './schema' + +export function SecurityPage() { + const [passkeyKeys, setPasskeyKeys] = useState(0) + const [feedback, setFeedback] = useState(null) + + const { + register, + handleSubmit, + reset, + setError, + formState: { errors, isSubmitting }, + } = useForm({ 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 la passkey: ${error.message ?? 'error desconocido'}`) + } 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 ( +
    +
    +

    Seguridad

    +

    + Gestioná tu contraseña y tus llaves de acceso (passkeys). +

    +
    + + {feedback ? ( +

    {feedback}

    + ) : null} + +
    +
    + +

    Cambiar contraseña

    +
    +
    +
    + + + {errors.currentPassword ? ( +

    {errors.currentPassword.message}

    + ) : null} +
    +
    + + + {errors.newPassword ? ( +

    {errors.newPassword.message}

    + ) : null} +
    + +
    +
    + +
    +
    +
    + +

    Passkeys

    +
    + +
    + +
    +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/security/schema.ts b/apps/web/src/features/security/schema.ts new file mode 100644 index 0000000..3da25fd --- /dev/null +++ b/apps/web/src/features/security/schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export 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'], + }) + +export type ChangePasswordValues = z.infer \ No newline at end of file diff --git a/apps/web/src/features/settings/AppearanceCard.tsx b/apps/web/src/features/settings/AppearanceCard.tsx new file mode 100644 index 0000000..df88949 --- /dev/null +++ b/apps/web/src/features/settings/AppearanceCard.tsx @@ -0,0 +1,42 @@ +import { Check } from 'lucide-react' +import { cn } from '../../lib/utils' +import { useTheme } from '../../context/ThemeProvider' +import { THEME_ICONS, THEME_OPTIONS } from './constants' + +export function AppearanceCard() { + const { theme, setTheme } = useTheme() + + return ( +
    +
    +

    Apariencia

    +

    Tema claro u oscuro

    +
    + {THEME_OPTIONS.map((option) => { + const Icon = THEME_ICONS[option.value] + const isSelected = theme === option.value + return ( + + ) + })} +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/settings/SettingsView.tsx b/apps/web/src/features/settings/SettingsView.tsx new file mode 100644 index 0000000..dbdbaf8 --- /dev/null +++ b/apps/web/src/features/settings/SettingsView.tsx @@ -0,0 +1,41 @@ +import { Link } from '@tanstack/react-router' +import { Fingerprint } from 'lucide-react' +import { signOut } from '../../lib/auth-client' +import { Button } from '../../components/ui' +import { AppearanceCard } from './AppearanceCard' + +export function SettingsView() { + const handleSignOut = async () => { + await signOut({ + fetchOptions: { headers: { 'Cache-Control': 'no-cache' } }, + }) + window.location.assign('/login') + } + + return ( +
    +
    +

    Ajustes

    +

    Configurá tu cuenta y tus grupos.

    +
    + +
    + + + + +
    +

    Seguridad

    +

    Contraseña y passkeys

    +
    + +
    + + + + +
    + ) +} \ No newline at end of file diff --git a/apps/web/src/features/settings/constants.ts b/apps/web/src/features/settings/constants.ts new file mode 100644 index 0000000..e20dd69 --- /dev/null +++ b/apps/web/src/features/settings/constants.ts @@ -0,0 +1,14 @@ +import { Monitor, Moon, Sun } from 'lucide-react' +import type { Theme } from '../../context/ThemeProvider' + +export 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' }, +] + +export const THEME_ICONS: Record = { + light: Sun, + dark: Moon, + system: Monitor, +} \ No newline at end of file diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts new file mode 100644 index 0000000..632981b --- /dev/null +++ b/apps/web/src/routeTree.gen.ts @@ -0,0 +1,410 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AuthenticatedRouteImport } from './routes/_authenticated' +import { Route as LoginRouteImport } from './routes/login' +import { Route as OnboardingRouteImport } from './routes/onboarding' +import { Route as SignupRouteImport } from './routes/signup' +import { Route as VerifyEmailRouteImport } from './routes/verify-email' +import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' +import { Route as AuthenticatedPaymentsRouteImport } from './routes/_authenticated/payments' +import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' +import { Route as AuthenticatedSeguridadRouteImport } from './routes/_authenticated/seguridad' +import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings' +import { Route as AvisarAusenciaTokenRouteImport } from './routes/avisar-ausencia.$token' +import { Route as JoinTokenRouteImport } from './routes/join.$token' +import { Route as AuthenticatedGroupsIndexRouteImport } from './routes/_authenticated/groups/index' +import { Route as AuthenticatedGroupsNewRouteImport } from './routes/_authenticated/groups/new' +import { Route as AuthenticatedClassSessionIdAttendanceRouteImport } from './routes/_authenticated/class/$sessionId/attendance' +import { Route as AuthenticatedGroupsGroupIdIndexRouteImport } from './routes/_authenticated/groups/$groupId/index' +import { Route as AuthenticatedGroupsGroupIdAnalyticsRouteImport } from './routes/_authenticated/groups/$groupId/analytics' + +const AuthenticatedRoute = AuthenticatedRouteImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const OnboardingRoute = OnboardingRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => rootRouteImport, +} as any) +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', + getParentRoute: () => rootRouteImport, +} as any) +const VerifyEmailRoute = VerifyEmailRouteImport.update({ + id: '/verify-email', + path: '/verify-email', + getParentRoute: () => rootRouteImport, +} as any) +const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedPaymentsRoute = AuthenticatedPaymentsRouteImport.update({ + id: '/payments', + path: '/payments', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedSeguridadRoute = AuthenticatedSeguridadRouteImport.update({ + id: '/seguridad', + path: '/seguridad', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AvisarAusenciaTokenRoute = AvisarAusenciaTokenRouteImport.update({ + id: '/avisar-ausencia/$token', + path: '/avisar-ausencia/$token', + getParentRoute: () => rootRouteImport, +} as any) +const JoinTokenRoute = JoinTokenRouteImport.update({ + id: '/join/$token', + path: '/join/$token', + getParentRoute: () => rootRouteImport, +} as any) +const AuthenticatedGroupsIndexRoute = + AuthenticatedGroupsIndexRouteImport.update({ + id: '/groups/', + path: '/groups/', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedGroupsNewRoute = AuthenticatedGroupsNewRouteImport.update({ + id: '/groups/new', + path: '/groups/new', + getParentRoute: () => AuthenticatedRoute, +} as any) +const AuthenticatedClassSessionIdAttendanceRoute = + AuthenticatedClassSessionIdAttendanceRouteImport.update({ + id: '/class/$sessionId/attendance', + path: '/class/$sessionId/attendance', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedGroupsGroupIdIndexRoute = + AuthenticatedGroupsGroupIdIndexRouteImport.update({ + id: '/groups/$groupId/', + path: '/groups/$groupId/', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedGroupsGroupIdAnalyticsRoute = + AuthenticatedGroupsGroupIdAnalyticsRouteImport.update({ + id: '/groups/$groupId/analytics', + path: '/groups/$groupId/analytics', + getParentRoute: () => AuthenticatedRoute, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof AuthenticatedIndexRoute + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/signup': typeof SignupRoute + '/verify-email': typeof VerifyEmailRoute + '/payments': typeof AuthenticatedPaymentsRoute + '/profile': typeof AuthenticatedProfileRoute + '/seguridad': typeof AuthenticatedSeguridadRoute + '/settings': typeof AuthenticatedSettingsRoute + '/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute + '/join/$token': typeof JoinTokenRoute + '/groups/new': typeof AuthenticatedGroupsNewRoute + '/groups/': typeof AuthenticatedGroupsIndexRoute + '/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute + '/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute + '/groups/$groupId/': typeof AuthenticatedGroupsGroupIdIndexRoute +} +export interface FileRoutesByTo { + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/signup': typeof SignupRoute + '/verify-email': typeof VerifyEmailRoute + '/payments': typeof AuthenticatedPaymentsRoute + '/profile': typeof AuthenticatedProfileRoute + '/seguridad': typeof AuthenticatedSeguridadRoute + '/settings': typeof AuthenticatedSettingsRoute + '/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute + '/join/$token': typeof JoinTokenRoute + '/': typeof AuthenticatedIndexRoute + '/groups/new': typeof AuthenticatedGroupsNewRoute + '/groups': typeof AuthenticatedGroupsIndexRoute + '/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute + '/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute + '/groups/$groupId': typeof AuthenticatedGroupsGroupIdIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_authenticated': typeof AuthenticatedRouteWithChildren + '/login': typeof LoginRoute + '/onboarding': typeof OnboardingRoute + '/signup': typeof SignupRoute + '/verify-email': typeof VerifyEmailRoute + '/_authenticated/payments': typeof AuthenticatedPaymentsRoute + '/_authenticated/profile': typeof AuthenticatedProfileRoute + '/_authenticated/seguridad': typeof AuthenticatedSeguridadRoute + '/_authenticated/settings': typeof AuthenticatedSettingsRoute + '/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute + '/join/$token': typeof JoinTokenRoute + '/_authenticated/': typeof AuthenticatedIndexRoute + '/_authenticated/groups/new': typeof AuthenticatedGroupsNewRoute + '/_authenticated/groups/': typeof AuthenticatedGroupsIndexRoute + '/_authenticated/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute + '/_authenticated/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute + '/_authenticated/groups/$groupId/': typeof AuthenticatedGroupsGroupIdIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/login' + | '/onboarding' + | '/signup' + | '/verify-email' + | '/payments' + | '/profile' + | '/seguridad' + | '/settings' + | '/avisar-ausencia/$token' + | '/join/$token' + | '/groups/new' + | '/groups/' + | '/class/$sessionId/attendance' + | '/groups/$groupId/analytics' + | '/groups/$groupId/' + fileRoutesByTo: FileRoutesByTo + to: + | '/login' + | '/onboarding' + | '/signup' + | '/verify-email' + | '/payments' + | '/profile' + | '/seguridad' + | '/settings' + | '/avisar-ausencia/$token' + | '/join/$token' + | '/' + | '/groups/new' + | '/groups' + | '/class/$sessionId/attendance' + | '/groups/$groupId/analytics' + | '/groups/$groupId' + id: + | '__root__' + | '/_authenticated' + | '/login' + | '/onboarding' + | '/signup' + | '/verify-email' + | '/_authenticated/payments' + | '/_authenticated/profile' + | '/_authenticated/seguridad' + | '/_authenticated/settings' + | '/avisar-ausencia/$token' + | '/join/$token' + | '/_authenticated/' + | '/_authenticated/groups/new' + | '/_authenticated/groups/' + | '/_authenticated/class/$sessionId/attendance' + | '/_authenticated/groups/$groupId/analytics' + | '/_authenticated/groups/$groupId/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AuthenticatedRoute: typeof AuthenticatedRouteWithChildren + LoginRoute: typeof LoginRoute + OnboardingRoute: typeof OnboardingRoute + SignupRoute: typeof SignupRoute + VerifyEmailRoute: typeof VerifyEmailRoute + AvisarAusenciaTokenRoute: typeof AvisarAusenciaTokenRoute + JoinTokenRoute: typeof JoinTokenRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_authenticated': { + id: '/_authenticated' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/onboarding': { + id: '/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof OnboardingRouteImport + parentRoute: typeof rootRouteImport + } + '/signup': { + id: '/signup' + path: '/signup' + fullPath: '/signup' + preLoaderRoute: typeof SignupRouteImport + parentRoute: typeof rootRouteImport + } + '/verify-email': { + id: '/verify-email' + path: '/verify-email' + fullPath: '/verify-email' + preLoaderRoute: typeof VerifyEmailRouteImport + parentRoute: typeof rootRouteImport + } + '/_authenticated/': { + id: '/_authenticated/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/payments': { + id: '/_authenticated/payments' + path: '/payments' + fullPath: '/payments' + preLoaderRoute: typeof AuthenticatedPaymentsRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/profile': { + id: '/_authenticated/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof AuthenticatedProfileRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/seguridad': { + id: '/_authenticated/seguridad' + path: '/seguridad' + fullPath: '/seguridad' + preLoaderRoute: typeof AuthenticatedSeguridadRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/settings': { + id: '/_authenticated/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthenticatedSettingsRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/avisar-ausencia/$token': { + id: '/avisar-ausencia/$token' + path: '/avisar-ausencia/$token' + fullPath: '/avisar-ausencia/$token' + preLoaderRoute: typeof AvisarAusenciaTokenRouteImport + parentRoute: typeof rootRouteImport + } + '/join/$token': { + id: '/join/$token' + path: '/join/$token' + fullPath: '/join/$token' + preLoaderRoute: typeof JoinTokenRouteImport + parentRoute: typeof rootRouteImport + } + '/_authenticated/groups/': { + id: '/_authenticated/groups/' + path: '/groups' + fullPath: '/groups/' + preLoaderRoute: typeof AuthenticatedGroupsIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/groups/new': { + id: '/_authenticated/groups/new' + path: '/groups/new' + fullPath: '/groups/new' + preLoaderRoute: typeof AuthenticatedGroupsNewRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/class/$sessionId/attendance': { + id: '/_authenticated/class/$sessionId/attendance' + path: '/class/$sessionId/attendance' + fullPath: '/class/$sessionId/attendance' + preLoaderRoute: typeof AuthenticatedClassSessionIdAttendanceRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/groups/$groupId/': { + id: '/_authenticated/groups/$groupId/' + path: '/groups/$groupId' + fullPath: '/groups/$groupId/' + preLoaderRoute: typeof AuthenticatedGroupsGroupIdIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/groups/$groupId/analytics': { + id: '/_authenticated/groups/$groupId/analytics' + path: '/groups/$groupId/analytics' + fullPath: '/groups/$groupId/analytics' + preLoaderRoute: typeof AuthenticatedGroupsGroupIdAnalyticsRouteImport + parentRoute: typeof AuthenticatedRoute + } + } +} + +interface AuthenticatedRouteChildren { + AuthenticatedPaymentsRoute: typeof AuthenticatedPaymentsRoute + AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute + AuthenticatedSeguridadRoute: typeof AuthenticatedSeguridadRoute + AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute + AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute + AuthenticatedGroupsNewRoute: typeof AuthenticatedGroupsNewRoute + AuthenticatedGroupsIndexRoute: typeof AuthenticatedGroupsIndexRoute + AuthenticatedClassSessionIdAttendanceRoute: typeof AuthenticatedClassSessionIdAttendanceRoute + AuthenticatedGroupsGroupIdAnalyticsRoute: typeof AuthenticatedGroupsGroupIdAnalyticsRoute + AuthenticatedGroupsGroupIdIndexRoute: typeof AuthenticatedGroupsGroupIdIndexRoute +} + +const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedPaymentsRoute: AuthenticatedPaymentsRoute, + AuthenticatedProfileRoute: AuthenticatedProfileRoute, + AuthenticatedSeguridadRoute: AuthenticatedSeguridadRoute, + AuthenticatedSettingsRoute: AuthenticatedSettingsRoute, + AuthenticatedIndexRoute: AuthenticatedIndexRoute, + AuthenticatedGroupsNewRoute: AuthenticatedGroupsNewRoute, + AuthenticatedGroupsIndexRoute: AuthenticatedGroupsIndexRoute, + AuthenticatedClassSessionIdAttendanceRoute: + AuthenticatedClassSessionIdAttendanceRoute, + AuthenticatedGroupsGroupIdAnalyticsRoute: + AuthenticatedGroupsGroupIdAnalyticsRoute, + AuthenticatedGroupsGroupIdIndexRoute: AuthenticatedGroupsGroupIdIndexRoute, +} + +const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( + AuthenticatedRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + AuthenticatedRoute: AuthenticatedRouteWithChildren, + LoginRoute: LoginRoute, + OnboardingRoute: OnboardingRoute, + SignupRoute: SignupRoute, + VerifyEmailRoute: VerifyEmailRoute, + AvisarAusenciaTokenRoute: AvisarAusenciaTokenRoute, + JoinTokenRoute: JoinTokenRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 8644dba..2172c1c 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -1,151 +1,5 @@ -import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' -import { AppLayoutGuard } from './components/layout/AppLayoutGuard' -import { HomeView } from './routes/home' -import { GroupsView } from './routes/groups' -import { PaymentsView } from './routes/payments' -import { SettingsView } from './routes/settings' -import { ProfileView } from './routes/profile' -import { SecurityPage } from './routes/security' -import { LoginPage } from './routes/auth/login' -import { SignupPage } from './routes/auth/signup' -import { VerifyEmailPage } from './routes/auth/verify-email' -import { OnboardingView } from './routes/onboarding' -import { GroupDetailView } from './routes/group-detail' -import { CreateGroupView } from './routes/create-group' -import { JoinGroupView } from './routes/join-group' -import { ClassAttendanceView } from './routes/class-attendance' -import { AbsenceNotifyView } from './routes/absence-notify' -import { AnalyticsView } from './routes/analytics' - -const rootRoute = createRootRoute({ - component: () => , -}) - -const loginRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/login', - component: LoginPage, -}) - -const signupRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/signup', - component: SignupPage, -}) - -const verifyEmailRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/verify-email', - component: VerifyEmailPage, -}) - -const onboardingRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/onboarding', - component: OnboardingView, -}) - -const joinGroupRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/join/$token', - component: JoinGroupView, -}) - -// Página pública: el alumno avisa su ausencia con su token personal. -const absenceNotifyRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/avisar-ausencia/$token', - component: AbsenceNotifyView, -}) - -// Capa con la navegación de la app autenticada (Sidebar + BottomNav). -// El guard redirige a /onboarding a quien no completó el onboarding. -const appLayoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: 'app', - component: AppLayoutGuard, -}) - -const indexRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/', - component: HomeView, -}) - -const groupsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/groups', - component: GroupsView, -}) - -const createGroupRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/groups/new', - component: CreateGroupView, -}) - -const groupDetailRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/groups/$groupId', - component: GroupDetailView, -}) - -const groupAnalyticsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/groups/$groupId/analytics', - component: AnalyticsView, -}) - -const paymentsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/payments', - component: PaymentsView, -}) - -const settingsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/settings', - component: SettingsView, -}) - -const securityRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/seguridad', - component: SecurityPage, -}) - -const profileRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/profile', - component: ProfileView, -}) - -const classAttendanceRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/class/$sessionId/attendance', - component: ClassAttendanceView, -}) - -const routeTree = rootRoute.addChildren([ - loginRoute, - signupRoute, - verifyEmailRoute, - onboardingRoute, - joinGroupRoute, - absenceNotifyRoute, - appLayoutRoute.addChildren([ - indexRoute, - groupsRoute, - createGroupRoute, - groupDetailRoute, - groupAnalyticsRoute, - paymentsRoute, - settingsRoute, - securityRoute, - profileRoute, - classAttendanceRoute, - ]), -]) +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' export const router = createRouter({ routeTree }) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..f463b79 --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,5 @@ +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => , +}) diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx new file mode 100644 index 0000000..83d1644 --- /dev/null +++ b/apps/web/src/routes/_authenticated.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' +import { AppLayoutGuard } from '../components/layout/AppLayoutGuard' + +export const Route = createFileRoute('/_authenticated')({ + component: () => ( + + + + ), +}) diff --git a/apps/web/src/routes/_authenticated/class/$sessionId/attendance.tsx b/apps/web/src/routes/_authenticated/class/$sessionId/attendance.tsx new file mode 100644 index 0000000..0fbe458 --- /dev/null +++ b/apps/web/src/routes/_authenticated/class/$sessionId/attendance.tsx @@ -0,0 +1,15 @@ +import { useParams } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { AttendanceProvider } from '../../../../features/class-attendance/AttendanceProvider' +import { ClassAttendanceView } from '../../../../features/class-attendance/ClassAttendanceView' + +export const Route = createFileRoute('/_authenticated/class/$sessionId/attendance')({ + component: () => { + const { sessionId } = useParams({ from: '/_authenticated/class/$sessionId/attendance' }) + return ( + + + + ) + }, +}) diff --git a/apps/web/src/routes/_authenticated/groups/$groupId/analytics.tsx b/apps/web/src/routes/_authenticated/groups/$groupId/analytics.tsx new file mode 100644 index 0000000..cf270d5 --- /dev/null +++ b/apps/web/src/routes/_authenticated/groups/$groupId/analytics.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AnalyticsView } from '../../../../features/groups/analytics/AnalyticsView' + +export const Route = createFileRoute('/_authenticated/groups/$groupId/analytics')({ + component: AnalyticsView, +}) diff --git a/apps/web/src/routes/_authenticated/groups/$groupId/index.tsx b/apps/web/src/routes/_authenticated/groups/$groupId/index.tsx new file mode 100644 index 0000000..0342f96 --- /dev/null +++ b/apps/web/src/routes/_authenticated/groups/$groupId/index.tsx @@ -0,0 +1,15 @@ +import { useParams } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { GroupDetailProvider } from '../../../../features/groups/detail/GroupDetailProvider' +import { GroupDetailView } from '../../../../features/groups/detail/GroupDetailView' + +export const Route = createFileRoute('/_authenticated/groups/$groupId/')({ + component: () => { + const { groupId } = useParams({ from: '/_authenticated/groups/$groupId/' }) + return ( + + + + ) + }, +}) diff --git a/apps/web/src/routes/_authenticated/groups/index.tsx b/apps/web/src/routes/_authenticated/groups/index.tsx new file mode 100644 index 0000000..1fb30d8 --- /dev/null +++ b/apps/web/src/routes/_authenticated/groups/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { GroupsView } from '../../../features/groups/GroupsView' + +export const Route = createFileRoute('/_authenticated/groups/')({ + component: GroupsView, +}) diff --git a/apps/web/src/routes/_authenticated/groups/new.tsx b/apps/web/src/routes/_authenticated/groups/new.tsx new file mode 100644 index 0000000..c54f3f1 --- /dev/null +++ b/apps/web/src/routes/_authenticated/groups/new.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { CreateGroupView } from '../../../features/groups/CreateGroupView' + +export const Route = createFileRoute('/_authenticated/groups/new')({ + component: CreateGroupView, +}) diff --git a/apps/web/src/routes/_authenticated/index.tsx b/apps/web/src/routes/_authenticated/index.tsx new file mode 100644 index 0000000..fe48dab --- /dev/null +++ b/apps/web/src/routes/_authenticated/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { HomeView } from '../../features/home/HomeView' + +export const Route = createFileRoute('/_authenticated/')({ + component: HomeView, +}) diff --git a/apps/web/src/routes/_authenticated/payments.tsx b/apps/web/src/routes/_authenticated/payments.tsx new file mode 100644 index 0000000..5a9bce3 --- /dev/null +++ b/apps/web/src/routes/_authenticated/payments.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { PaymentsView } from '../../features/payments/PaymentsView' + +export const Route = createFileRoute('/_authenticated/payments')({ + component: PaymentsView, +}) diff --git a/apps/web/src/routes/_authenticated/profile.tsx b/apps/web/src/routes/_authenticated/profile.tsx new file mode 100644 index 0000000..6515713 --- /dev/null +++ b/apps/web/src/routes/_authenticated/profile.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ProfileView } from '../../features/profile/ProfileView' + +export const Route = createFileRoute('/_authenticated/profile')({ + component: ProfileView, +}) diff --git a/apps/web/src/routes/_authenticated/seguridad.tsx b/apps/web/src/routes/_authenticated/seguridad.tsx new file mode 100644 index 0000000..1f473bd --- /dev/null +++ b/apps/web/src/routes/_authenticated/seguridad.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SecurityPage } from '../../features/security/SecurityPage' + +export const Route = createFileRoute('/_authenticated/seguridad')({ + component: SecurityPage, +}) diff --git a/apps/web/src/routes/_authenticated/settings.tsx b/apps/web/src/routes/_authenticated/settings.tsx new file mode 100644 index 0000000..490e19f --- /dev/null +++ b/apps/web/src/routes/_authenticated/settings.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsView } from '../../features/settings/SettingsView' + +export const Route = createFileRoute('/_authenticated/settings')({ + component: SettingsView, +}) diff --git a/apps/web/src/routes/avisar-ausencia.$token.tsx b/apps/web/src/routes/avisar-ausencia.$token.tsx new file mode 100644 index 0000000..e9f9bbb --- /dev/null +++ b/apps/web/src/routes/avisar-ausencia.$token.tsx @@ -0,0 +1,15 @@ +import { useParams } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { AbsenceNotifyProvider } from '../features/absence-notify/AbsenceNotifyProvider' +import { AbsenceNotifyView } from '../features/absence-notify/AbsenceNotifyView' + +export const Route = createFileRoute('/avisar-ausencia/$token')({ + component: () => { + const { token } = useParams({ from: '/avisar-ausencia/$token' }) + return ( + + + + ) + }, +}) diff --git a/apps/web/src/routes/join.$token.tsx b/apps/web/src/routes/join.$token.tsx new file mode 100644 index 0000000..401714e --- /dev/null +++ b/apps/web/src/routes/join.$token.tsx @@ -0,0 +1,15 @@ +import { useParams } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { JoinGroupProvider } from '../features/join/JoinGroupProvider' +import { JoinGroupView } from '../features/join/JoinGroupView' + +export const Route = createFileRoute('/join/$token')({ + component: () => { + const { token } = useParams({ from: '/join/$token' }) + return ( + + + + ) + }, +}) diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx new file mode 100644 index 0000000..649bea8 --- /dev/null +++ b/apps/web/src/routes/login.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { LoginPage } from '../features/auth/LoginPage' + +export const Route = createFileRoute('/login')({ + component: LoginPage, +}) diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx new file mode 100644 index 0000000..27ea49c --- /dev/null +++ b/apps/web/src/routes/onboarding.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { OnboardingView } from '../features/onboarding/OnboardingView' + +export const Route = createFileRoute('/onboarding')({ + component: OnboardingView, +}) diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx new file mode 100644 index 0000000..420199b --- /dev/null +++ b/apps/web/src/routes/signup.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SignupPage } from '../features/auth/SignupPage' + +export const Route = createFileRoute('/signup')({ + component: SignupPage, +}) diff --git a/apps/web/src/routes/verify-email.tsx b/apps/web/src/routes/verify-email.tsx new file mode 100644 index 0000000..3ce5338 --- /dev/null +++ b/apps/web/src/routes/verify-email.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { VerifyEmailPage } from '../features/auth/VerifyEmailPage' + +export const Route = createFileRoute('/verify-email')({ + validateSearch: (search: Record): { token?: string; email?: string } => ({ + token: typeof search.token === 'string' ? search.token : undefined, + email: typeof search.email === 'string' ? search.email : undefined, + }), + component: VerifyEmailPage, +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b8a6726..328c59e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [tanstackRouter({ target: 'react' }), react(), tailwindcss()], server: { port: 6173, }, -}) +}) \ No newline at end of file diff --git a/bun.lock b/bun.lock index bb0389c..de6ce51 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@gruperly/shared": "workspace:*", "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.62.0", - "@tanstack/react-router": "^1.90.0", + "@tanstack/react-router": "^1.170.38", "better-auth": "1.7.2", "clsx": "^2.1.1", "lucide-react": "^1.34.0", @@ -56,6 +56,7 @@ "devDependencies": { "@gruperly/config": "workspace:*", "@tailwindcss/vite": "^4.3.3", + "@tanstack/router-plugin": "^1.168.40", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", @@ -408,19 +409,27 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/history": ["@tanstack/history@1.162.2", "", {}, "sha512-Lemp3DJbzNqcin/nZpWxycDaEqySDbnIshDbyHJMMCapD4ZQMe57szRpBXOfzfP6fyWAtHNrLrcBUyANJ6Vlow=="], + "@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="], "@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="], - "@tanstack/react-router": ["@tanstack/react-router@1.170.33", "", { "dependencies": { "@tanstack/history": "1.162.2", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.28", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iNnI98vH3kO/V4dy6YM0CInhqwWBddU0G5wZK5jiMvr3HsK2avDQSRx3RY/y6v+6zQAqb2kD6hUPHIenrJBTSw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.38", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.0", "@tanstack/router-core": "1.171.32", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iHM9b0aDJbuvftmkiQXawzoqsdV68p2Re2safbVLCnxafe+iLKV++2i3hI/BMAP6uR92/Mjw94JKJJfD7pbwHQ=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.171.28", "", { "dependencies": { "@tanstack/history": "1.162.2", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-PvPWSklhw6i9b0rzScVh0btQsK5u/gBYN3mBHyDzhC/U4LrB3WzPXPkUunQUKvQOGXCp16UEb7Htc/ITGm5DkQ=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.32", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-X86Jqk3vB2KJcUfS6oLi7B7yxbaa59QEhZRPPP1fRx3k+Jw/Fu1UYVBcRtdwK/OccQnrlQdVoKvNO3QXmyEBOw=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.38", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-lkqgFleDgfkW+QONVMKBs+ZGDUF0v9R9FkplS/GZvKDFpfnu+tZxmGlV2pryU1TDxyuSyjh56AtIla3NMrlScw=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.40", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-generator": "1.167.38", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.38", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-ifiXjjR4uivxwRDhgYAcfyrcu+Mwx+vlG0QjjQ7N5C5sKmzpt5UtsL21TNVvZDSZl2Vae8DcL84Z02fOs8ZZMQ=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="], + + "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="], @@ -504,12 +513,16 @@ "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ansis": ["ansis@4.4.0", "", {}, "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.22", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA=="], "better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="], @@ -582,6 +595,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], @@ -776,6 +791,8 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prettier": ["prettier@3.9.8", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A=="], + "prisma": ["prisma@7.10.0", "", { "dependencies": { "@prisma/config": "7.10.0", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.0", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw=="], "process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], @@ -880,6 +897,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unplugin": ["unplugin@3.4.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w=="], + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], @@ -888,6 +907,8 @@ "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -922,6 +943,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/router-generator/zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], + + "@tanstack/router-plugin/zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], + "@types/nodemailer/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], "@types/pg/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],