feat: basic initial UI layout for mobile and desktop

This commit is contained in:
Jose Selesan
2026-09-07 09:33:07 -03:00
parent 0aa5d70101
commit b41fffa40a
46 changed files with 2102 additions and 125 deletions

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { Logo, LogoIcon } from '../brand'
export type AuthShellProps = {
title: string
subtitle?: string
children: ReactNode
}
export function AuthShell({ title, subtitle, children }: AuthShellProps) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center justify-center gap-2">
<LogoIcon className="size-9" />
<Logo className="text-xl" />
</div>
<div className="rounded-xl border border-border bg-white p-6 shadow-sm">
<h1 className="text-xl font-bold text-primary">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
<div className="mt-5">{children}</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { AuthShell } from './AuthShell'

View File

@@ -0,0 +1,62 @@
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
type Crumb = { label: string; to?: string }
const BREADCRUMBS: Record<string, Crumb[]> = {
'/': [{ label: 'Inicio' }],
'/groups': [
{ label: 'Inicio', to: '/' },
{ label: 'Grupos' },
],
'/payments': [
{ label: 'Inicio', to: '/' },
{ label: 'Cobros' },
],
'/settings': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes' },
],
'/seguridad': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Seguridad' },
],
'/settings/organizations': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Organizaciones' },
],
'/profile': [
{ label: 'Inicio', to: '/' },
{ label: 'Mi perfil' },
],
}
export function Breadcrumb() {
const location = useLocation()
const crumbs = BREADCRUMBS[location.pathname] ?? [{ label: 'Inicio' }]
return (
<nav aria-label="Breadcrumb" className="hidden items-center gap-1.5 text-sm lg:flex">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<span key={crumb.label} className="flex items-center gap-1.5">
{i > 0 ? <ChevronRight className="size-3.5 text-foreground/40" /> : null}
{isLast ? (
<span className="font-medium text-primary">{crumb.label}</span>
) : (
<Link
to={crumb.to!}
className="text-foreground/50 transition-colors hover:text-primary"
>
{crumb.label}
</Link>
)}
</span>
)
})}
</nav>
)
}

View File

@@ -0,0 +1,20 @@
import { Logo, LogoIcon } from '../brand'
import { Breadcrumb } from './Breadcrumb'
import { UserMenu } from './UserMenu'
export function Header() {
return (
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6">
<div className="flex min-w-0 items-center">
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio">
<LogoIcon className="size-7" />
<Logo className="text-lg tracking-tight" />
</a>
<Breadcrumb />
</div>
<UserMenu />
</div>
</header>
)
}

View File

@@ -1,44 +1,22 @@
import { Outlet } from '@tanstack/react-router'
import { Bell } from 'lucide-react'
import { Logo, LogoIcon } from '../brand'
import { Avatar, Button } from '../ui'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import { BottomNav } from './BottomNav'
export function RootLayout() {
return (
<div className="mx-auto flex min-h-dvh w-full max-w-6xl gap-6 lg:px-6">
<Sidebar />
<div className="min-h-dvh w-full">
<Header />
<div className="flex min-h-dvh w-full flex-col lg:min-w-0">
<header className="sticky top-0 z-30 border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex h-14 w-full max-w-md items-center justify-between px-4 lg:max-w-none lg:px-0">
<a
href="/"
className="flex items-center gap-2 lg:hidden"
aria-label="Gruperly inicio"
>
<LogoIcon className="size-8" />
<Logo className="text-lg tracking-tight" />
</a>
<div className="ml-auto flex items-center gap-1 lg:ml-0">
<Button size="icon" variant="ghost" aria-label="Notificaciones" className="relative">
<Bell className="size-5" />
<span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-accent" />
</Button>
<Button size="icon" variant="ghost" aria-label="Perfil">
<Avatar name="Gruperly User" />
</Button>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
<Sidebar />
<main className="mx-auto w-full max-w-md flex-1 px-4 pb-24 pt-4 lg:max-w-none lg:px-0 lg:pb-8 lg:pt-6">
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">
<Outlet />
</main>
<BottomNav />
</div>
<BottomNav />
</div>
)
}
}

View File

@@ -7,7 +7,7 @@ export function Sidebar() {
const location = useLocation()
return (
<aside className="sticky top-0 hidden h-dvh w-60 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio">
<LogoIcon className="size-8" />
<Logo className="text-lg tracking-tight" />

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useNavigate } from '@tanstack/react-router'
import { ChevronDown, LogOut, Settings, UserRound, X } from 'lucide-react'
import { cn } from '../../lib/utils'
import { signOut } from '../../lib/auth-client'
import { useAuth } from '../../context/AuthProvider'
import { useIsMobile } from '../../hooks/useIsMobile'
import { Avatar } from '../ui'
const itemClass =
'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium text-primary transition-colors hover:bg-primary-soft'
export function UserMenu() {
const { user } = useAuth()
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
useEffect(() => {
if (!open || isMobile) return
const handlePointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [open, isMobile])
useEffect(() => {
if (isMobile && open) {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}
}, [isMobile, open])
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open])
const handleSignOut = async () => {
setOpen(false)
await signOut({ fetchOptions: { headers: { 'Cache-Control': 'no-cache' } } })
navigate({ to: '/login' })
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-xl p-1 transition-colors hover:bg-primary-soft"
>
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-8 lg:size-9" />
<span className="hidden max-w-40 flex-col items-start leading-tight lg:flex">
<span className="truncate text-sm font-medium text-primary">{user?.name ?? 'Usuario'}</span>
</span>
<ChevronDown
className={cn(
'hidden size-4 text-foreground/50 transition-transform lg:block',
open && 'rotate-180',
)}
/>
</button>
{isMobile
? open
? createPortal(
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
<div
aria-hidden="true"
className="absolute inset-0 animate-fade-in bg-black/20 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-white/90 backdrop-blur">
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Cerrar menú"
className="absolute right-4 top-4 z-20 rounded-full bg-white/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-white"
>
<X className="size-5" />
</button>
<div className="flex min-h-full flex-col items-center justify-center gap-8 px-6 py-12">
<div className="flex flex-col items-center gap-3 text-center">
<Avatar
name={user?.name}
src={user?.image ?? undefined}
className="size-16 text-xl"
/>
<div>
<p className="text-base font-semibold text-primary">
{user?.name ?? 'Usuario'}
</p>
<p className="text-sm text-foreground/50">{user?.email}</p>
</div>
</div>
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-white/90 p-2 shadow-xl backdrop-blur">
<Link
role="menuitem"
to="/profile"
onClick={() => setOpen(false)}
className={cn(itemClass, 'py-3.5')}
>
<UserRound className="size-5 text-foreground/60" />
Mi perfil
</Link>
<Link
role="menuitem"
to="/settings"
onClick={() => setOpen(false)}
className={cn(itemClass, 'py-3.5')}
>
<Settings className="size-5 text-foreground/60" />
Ajustes
</Link>
</div>
<button
type="button"
role="menuitem"
onClick={handleSignOut}
className={cn(
itemClass,
'w-full justify-center border border-border bg-white/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
)}
>
<LogOut className="size-5 text-foreground/60" />
Cerrar sesión
</button>
</div>
</div>
</div>,
document.body,
)
: null
: open ? (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-white p-1.5 shadow-lg"
>
<div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>
<p className="truncate text-xs text-foreground/50">{user?.email}</p>
</div>
<div className="mx-3 my-1 h-px bg-border" />
<Link role="menuitem" to="/profile" onClick={() => setOpen(false)} className={itemClass}>
<UserRound className="size-4 text-foreground/60" />
Mi perfil
</Link>
<Link role="menuitem" to="/settings" onClick={() => setOpen(false)} className={itemClass}>
<Settings className="size-4 text-foreground/60" />
Ajustes
</Link>
<div className="mx-3 my-1 h-px bg-border" />
<button type="button" role="menuitem" onClick={handleSignOut} className={itemClass}>
<LogOut className="size-4 text-foreground/60" />
Cerrar sesión
</button>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
export { RootLayout } from './RootLayout'
export { Header } from './Header'
export { Sidebar } from './Sidebar'
export { BottomNav } from './BottomNav'
export { NAV_ITEMS, type NavItem } from './nav-items'

View File

@@ -1,3 +1,5 @@
export { Avatar, type AvatarProps } from './avatar'
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'
export { Badge, type BadgeProps, type BadgeVariant } from './badge'
export { Input, type InputProps } from './input'
export { Label, type LabelProps } from './label'

View File

@@ -0,0 +1,24 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
invalid?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, invalid, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
'h-10 w-full rounded-xl border border-border bg-white px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
invalid && 'border-danger focus:border-danger focus:ring-danger/30',
className,
)}
{...props}
/>
)
},
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,13 @@
import type { LabelHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>
export function Label({ className, ...props }: LabelProps) {
return (
<label
className={cn('mb-1.5 block text-sm font-medium text-primary', className)}
{...props}
/>
)
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { authClient } from '../lib/auth-client'
type AuthSession = {
user: (typeof authClient.$Infer.Session)['user']
session: (typeof authClient.$Infer.Session)['session']
}
type AuthContextValue = {
user: AuthSession['user'] | null
session: AuthSession['session'] | null
isPending: boolean
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, isPending } = authClient.useSession()
const refresh = async () => {
await authClient.getSession()
}
return (
<AuthContext.Provider value={{ user: data?.user ?? null, session: data?.session ?? null, isPending, refresh }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
return ctx
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react'
const MOBILE_QUERY = '(max-width: 1023px)'
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
)
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY)
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches)
setIsMobile(mql.matches)
mql.addEventListener('change', handleChange)
return () => mql.removeEventListener('change', handleChange)
}, [])
return isMobile
}

View File

@@ -21,6 +21,18 @@
/* Border radius por defecto */
--radius-xl: 0.75rem;
/* Animaciones */
--animate-fade-in: fade-in 0.2s ease-out;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@layer base {

View File

@@ -0,0 +1,18 @@
import { createAuthClient } from 'better-auth/react'
import { organizationClient } from 'better-auth/client/plugins'
import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:4000',
plugins: [organizationClient(), passkeyClient()],
})
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
passkey,
organization,
} = authClient

View File

@@ -2,10 +2,13 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import { AuthProvider } from './context/AuthProvider'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode>,
)
)

View File

@@ -1,43 +1,100 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { RootLayout } from './components/layout'
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 { OrganizationsPage } from './routes/organizations'
import { LoginPage } from './routes/auth/login'
import { SignupPage } from './routes/auth/signup'
import { VerifyEmailPage } from './routes/auth/verify-email'
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
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,
})
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
component: RootLayout,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => appLayoutRoute,
path: '/',
component: HomeView,
})
const groupsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => appLayoutRoute,
path: '/groups',
component: GroupsView,
})
const paymentsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => appLayoutRoute,
path: '/payments',
component: PaymentsView,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
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 organizationsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/settings/organizations',
component: OrganizationsPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
groupsRoute,
paymentsRoute,
settingsRoute,
loginRoute,
signupRoute,
verifyEmailRoute,
appLayoutRoute.addChildren([
indexRoute,
groupsRoute,
paymentsRoute,
settingsRoute,
securityRoute,
organizationsRoute,
profileRoute,
]),
])
export const router = createRouter({ routeTree })
@@ -46,4 +103,4 @@ declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
}

View File

@@ -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 '../../components/auth'
const schema = z.object({
email: z.string().email('Ingresá un email válido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
})
type FormValues = z.infer<typeof schema>
export function LoginPage() {
const navigate = useNavigate()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = handleSubmit(async ({ email, password }) => {
const { error } = await authClient.signIn.email({ email, password })
if (error) {
setError('root', { message: error.message ?? 'No se pudo iniciar sesión' })
return
}
navigate({ to: '/' })
})
const handleGoogle = async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
}
const handlePasskey = async () => {
const { error, data } = await authClient.signIn.passkey()
if (error) {
setError('root', { message: error.message ?? 'No se pudo autenticar con passkey' })
return
}
if (data) navigate({ to: '/' })
}
return (
<AuthShell title="Iniciar sesión" subtitle="Accedé a tus cobros grupales">
<div className="space-y-4">
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="vos@ejemplo.com"
invalid={!!errors.email}
{...register('email')}
/>
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
</div>
<div>
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
invalid={!!errors.password}
{...register('password')}
/>
{errors.password ? (
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
) : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
</Button>
</form>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={handleGoogle}>
Continuar con Google
</Button>
<Button variant="outline" className="w-full" onClick={handlePasskey}>
<Fingerprint className="size-4" />
Entrar con passkey
</Button>
</div>
<p className="text-center text-sm text-foreground/60">
¿No tenés cuenta?{' '}
<Link to="/signup" className="font-medium text-accent hover:underline">
Registrate
</Link>
</p>
</div>
</AuthShell>
)
}

View File

@@ -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 '../../components/auth'
const schema = z
.object({
name: z.string().min(2, 'Ingresá tu nombre'),
email: z.string().email('Ingresá un email válido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
confirmPassword: z.string(),
})
.refine((v) => v.password === v.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
})
type FormValues = z.infer<typeof schema>
export function SignupPage() {
const navigate = useNavigate()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = handleSubmit(async ({ name, email, password }) => {
const { error, data } = await authClient.signUp.email(
{ name, email, password },
{
onSuccess: async () => {
await authClient.getSession()
},
},
)
if (error) {
setError('root', { message: error.message ?? 'No se pudo registrar' })
return
}
if (data?.token) {
// Con email verification activo, la sesión no se crea hasta verificar.
navigate({ to: '/verify-email', search: { email } })
}
})
const handleGoogle = async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
}
return (
<AuthShell title="Crear cuenta" subtitle="Empezá a cobrar en grupo en minutos">
<div className="space-y-4">
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
autoComplete="name"
placeholder="Nombre completo"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="vos@ejemplo.com"
invalid={!!errors.email}
{...register('email')}
/>
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
</div>
<div>
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
placeholder="Mínimo 8 caracteres"
invalid={!!errors.password}
{...register('password')}
/>
{errors.password ? (
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
) : null}
</div>
<div>
<Label htmlFor="confirmPassword">Repetí la contraseña</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
invalid={!!errors.confirmPassword}
{...register('confirmPassword')}
/>
{errors.confirmPassword ? (
<p className="mt-1 text-sm text-danger">{errors.confirmPassword.message}</p>
) : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
{isSubmitting ? 'Creando…' : 'Crear cuenta'}
</Button>
</form>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
<div className="h-px flex-1 bg-border" />
</div>
<Button variant="outline" className="w-full" onClick={handleGoogle}>
Continuar con Google
</Button>
<p className="text-center text-sm text-foreground/60">
¿Ya tenés cuenta?{' '}
<Link to="/login" className="font-medium text-accent hover:underline">
Iniciá sesión
</Link>
</p>
</div>
</AuthShell>
)
}

View File

@@ -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 '../../components/auth'
type SearchParams = {
token?: string
email?: string
}
type Status = 'idle' | 'verifying' | 'success' | 'error'
export function VerifyEmailPage() {
const { token, email } = useSearch({ strict: false }) as SearchParams
const navigate = useNavigate()
const [status, setStatus] = useState<Status>(token ? 'verifying' : 'idle')
useEffect(() => {
if (!token || status !== 'verifying') return
let cancelled = false
authClient
.verifyEmail({ query: { token } })
.then(async ({ error }) => {
if (cancelled) return
if (error) {
setStatus('error')
return
}
await authClient.getSession()
navigate({ to: '/' })
})
.catch(() => {
if (!cancelled) setStatus('error')
})
return () => {
cancelled = true
}
}, [token, status, navigate])
const title =
status === 'success'
? 'Email verificado'
: status === 'error'
? 'Vínculo inválido'
: 'Verificá tu email'
return (
<AuthShell title={title}>
<div className="flex flex-col items-center gap-4 py-2 text-center">
{status === 'verifying' ? (
<Loader2 className="size-10 animate-spin text-accent" />
) : status === 'success' ? (
<MailCheck className="size-10 text-success" />
) : (
<MailWarning className="size-10 text-warning" />
)}
{status === 'success' ? (
<p className="text-sm text-foreground/60">
Tu cuenta quedó verificada. Te estamos llevando a Gruperly…
</p>
) : status === 'error' ? (
<p className="text-sm text-foreground/60">
El vínculo de verificación no es válido o expiró.
</p>
) : (
<p className="text-sm text-foreground/60">
Te enviamos un correo a <span className="font-medium text-primary">{email}</span> para
confirmar tu cuenta.
</p>
)}
{status === 'success' ? (
<Link
to="/"
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-white hover:bg-accent-strong"
>
Ir a Gruperly
</Link>
) : null}
{status === 'error' ? (
<Link to="/login" className="text-sm font-medium text-accent hover:underline">
Intentar iniciar sesión
</Link>
) : null}
</div>
</AuthShell>
)
}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Building2, Check, Loader2, Plus } from 'lucide-react'
import { authClient } from '../lib/auth-client'
import { Badge, Button, Input, Label } from '../components/ui'
const createOrgSchema = z.object({
name: z.string().min(2, 'Ingresá el nombre del grupo'),
slug: z
.string()
.min(2, 'Mínimo 2 caracteres')
.regex(/^[a-z0-9-]+$/, 'Solo minúsculas, números y guiones'),
})
type CreateOrgValues = z.infer<typeof createOrgSchema>
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
type OrganizationRow = {
id: string
name: string
slug: string
logo: string | null
}
function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
const { data, isPending } = authClient.useListOrganizations()
const [syncing, setSyncing] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const organizations = (data ?? []) as OrganizationRow[]
const handleSyncGroup = async (org: OrganizationRow) => {
setSyncing(org.id)
try {
const res = await fetch(`${API_URL}/api/groups/from-organization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ organizationId: org.id }),
})
const body = (await res.json()) as { message?: string }
if (!res.ok) {
onRefresh()
setSyncing(null)
return
}
void body
} finally {
setSyncing(null)
}
onRefresh()
}
const handleSetActive = async (organizationId: string) => {
setActiveId(organizationId)
await authClient.organization.setActive({ organizationId })
setActiveId(null)
}
if (isPending) {
return <Loader2 className="size-5 animate-spin text-foreground/40" />
}
if (organizations.length === 0) {
return <p className="text-sm text-foreground/60">Todavía no pertenecés a ningún grupo.</p>
}
return (
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
{organizations.map((org) => (
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary">{org.name}</p>
<p className="text-xs text-foreground/50">/{org.slug}</p>
</div>
<Button
variant="outline"
size="sm"
disabled={syncing === org.id}
onClick={() => handleSyncGroup(org)}
>
{syncing === org.id ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Sincronizar grupo
</Button>
<Button
variant="ghost"
size="sm"
disabled={activeId === org.id}
onClick={() => handleSetActive(org.id)}
>
{activeId === org.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
<span className="hidden sm:inline">Usar</span>
</Button>
<Badge>Owner</Badge>
</li>
))}
</ul>
)
}
export function OrganizationsPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [feedback, setFeedback] = useState<string | null>(null)
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<CreateOrgValues>({ resolver: zodResolver(createOrgSchema) })
const refreshOrganizations = () => setRefreshKey((k) => k + 1)
const onCreateOrg = handleSubmit(async ({ name, slug }) => {
setFeedback(null)
const { error, data } = await authClient.organization.create({ name, slug })
if (error) {
setError('root', { message: error.message ?? 'No se pudo crear el grupo' })
return
}
reset({ name: '', slug: '' })
setFeedback('Grupo creado. Sincronizalo con el cobro grupal.')
refreshOrganizations()
void data
})
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
<p className="mt-1 text-sm text-foreground/60">
Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo.
</p>
</div>
{feedback ? (
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
) : null}
<div className="rounded-xl border border-border bg-white p-5">
<div className="mb-4 flex items-center gap-2">
<Plus className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
</div>
<form onSubmit={onCreateOrg} className="space-y-4">
<div>
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
placeholder="Gimnasio, club, kermés…"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
placeholder="gimnasio-don-bosco"
invalid={!!errors.slug}
{...register('slug')}
/>
{errors.slug ? <p className="mt-1 text-sm text-danger">{errors.slug.message}</p> : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Crear grupo
</Button>
</form>
</div>
<div className="rounded-xl border border-border bg-white p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
</div>
</section>
)
}

View File

@@ -0,0 +1,27 @@
import { useAuth } from '../context/AuthProvider'
import { Avatar } from '../components/ui'
export function ProfileView() {
const { user } = useAuth()
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Mi perfil</h1>
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
</div>
<div className="rounded-xl border border-border bg-white p-5">
<div className="flex items-center gap-4">
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
<div className="min-w-0">
<p className="truncate text-base font-semibold text-primary">
{user?.name ?? 'Usuario'}
</p>
<p className="truncate text-sm text-foreground/50">{user?.email}</p>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,183 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Fingerprint, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
import { authClient } from '../lib/auth-client'
import { Button, Input, Label } from '../components/ui'
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Ingresá tu contraseña actual'),
newPassword: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
})
.refine((v) => v.currentPassword !== v.newPassword, {
message: 'La nueva contraseña debe ser distinta',
path: ['newPassword'],
})
type ChangePasswordValues = z.infer<typeof changePasswordSchema>
function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
const { data, isPending } = authClient.useListPasskeys()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = async (id: string) => {
setDeletingId(id)
const { error } = await authClient.$fetch('/passkey/delete-passkey', {
method: 'POST',
body: { id },
})
setDeletingId(null)
if (!error) onRefresh()
}
if (isPending) {
return <Loader2 className="size-5 animate-spin text-foreground/40" />
}
const passkeys = data ?? []
if (passkeys.length === 0) {
return <p className="text-sm text-foreground/60">Todavía no registraste ninguna passkey.</p>
}
return (
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2">
<Fingerprint className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary">
{passkey.name ?? 'Passkey'}
</p>
<p className="text-xs text-foreground/50">
{passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '}
{new Date(passkey.createdAt).toLocaleDateString('es-AR')}
</p>
</div>
<Button
size="icon"
variant="ghost"
aria-label="Eliminar passkey"
disabled={deletingId === passkey.id}
onClick={() => handleDelete(passkey.id)}
>
<Trash2 className="size-4 text-danger" />
</Button>
</li>
))}
</ul>
)
}
export function SecurityPage() {
const [passkeyKeys, setPasskeyKeys] = useState(0)
const [feedback, setFeedback] = useState<string | null>(null)
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordValues>({ resolver: zodResolver(changePasswordSchema) })
const refreshPasskeys = () => {
setPasskeyKeys((k) => k + 1)
}
const handleAddPasskey = async () => {
setFeedback(null)
const { error } = await authClient.passkey.addPasskey()
if (error) setFeedback(`No se pudo agregar: ${error.message}`)
else {
setFeedback('Passkey registrada correctamente.')
refreshPasskeys()
}
}
const onChangePassword = handleSubmit(async ({ currentPassword, newPassword }) => {
setFeedback(null)
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
})
if (error) {
setError('currentPassword', { message: error.message ?? 'No se pudo cambiar la contraseña' })
return
}
reset({ currentPassword: '', newPassword: '' })
setFeedback('Contraseña actualizada.')
})
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Seguridad</h1>
<p className="mt-1 text-sm text-foreground/60">
Gestioná tu contraseña y tus llaves de acceso (passkeys).
</p>
</div>
{feedback ? (
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
) : null}
<div className="rounded-xl border border-border bg-white p-5">
<div className="mb-4 flex items-center gap-2">
<KeyRound className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
</div>
<form onSubmit={onChangePassword} className="space-y-4">
<div>
<Label htmlFor="currentPassword">Contraseña actual</Label>
<Input
id="currentPassword"
type="password"
autoComplete="current-password"
invalid={!!errors.currentPassword}
{...register('currentPassword')}
/>
{errors.currentPassword ? (
<p className="mt-1 text-sm text-danger">{errors.currentPassword.message}</p>
) : null}
</div>
<div>
<Label htmlFor="newPassword">Nueva contraseña</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
invalid={!!errors.newPassword}
{...register('newPassword')}
/>
{errors.newPassword ? (
<p className="mt-1 text-sm text-danger">{errors.newPassword.message}</p>
) : null}
</div>
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Actualizar contraseña
</Button>
</form>
</div>
<div className="rounded-xl border border-border bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Fingerprint className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Passkeys</h2>
</div>
<Button variant="outline" size="sm" onClick={handleAddPasskey}>
<Plus className="size-4" />
Registrar
</Button>
</div>
<PasskeyList key={passkeyKeys} onRefresh={refreshPasskeys} />
</div>
</section>
)
}

View File

@@ -1,8 +1,51 @@
import { Link } from '@tanstack/react-router'
import { Building2, Fingerprint } from 'lucide-react'
import { signOut } from '../lib/auth-client'
import { Button } from '../components/ui'
export function SettingsView() {
const handleSignOut = async () => {
await signOut({
fetchOptions: { headers: { 'Cache-Control': 'no-cache' } },
})
window.location.assign('/login')
}
return (
<section>
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
<p className="mt-2 text-sm text-foreground/60">Configura tu cuenta.</p>
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
</div>
<div className="divide-y divide-border rounded-xl border border-border bg-white">
<Link
to="/settings/organizations"
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
>
<span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Grupos</p>
<p className="text-xs text-foreground/50">Crear y gestionar tus organizaciones</p>
</div>
</Link>
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
<span className="rounded-lg bg-accent-soft p-2">
<Fingerprint className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Seguridad</p>
<p className="text-xs text-foreground/50">Contraseña y passkeys</p>
</div>
</Link>
</div>
<Button variant="outline" onClick={handleSignOut}>
Cerrar sesión
</Button>
</section>
)
}
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />