Added 'No show' status. Edit user profile

This commit is contained in:
Jose Selesan
2026-04-09 23:01:09 -03:00
parent 4dcaae7136
commit 2d86881f94
7 changed files with 440 additions and 45 deletions

View File

@@ -12,6 +12,8 @@
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@repo/api-contract": "workspace:*", "@repo/api-contract": "workspace:*",
"@supabase/supabase-js": "^2.101.1", "@supabase/supabase-js": "^2.101.1",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",

View File

@@ -0,0 +1,174 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import type {
ControllerProps,
FieldPath,
FieldValues,
} from "react-hook-form"
import { Controller, FormProvider, useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormField,
FormMessage,
}

View File

@@ -128,7 +128,7 @@ export function CourtFormSection({
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha' const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
return ( return (
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5"> <section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5 mb-4">
<h3 className="text-lg font-semibold">{title}</h3> <h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Configura nombre, deporte, duración, precio base y horarios. El backend ya Configura nombre, deporte, duración, precio base y horarios. El backend ya

View File

@@ -63,13 +63,33 @@ function formatDateLabel(dateIso: string) {
}).format(date) }).format(date)
} }
function statusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') { function getEffectiveStatus(booking: AdminBooking): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' {
if (booking.status !== 'CONFIRMED') {
return booking.status
}
// Check if booking is past end time and still confirmed
const now = new Date()
const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`)
if (now > bookingDateTime) {
return 'NO_SHOW'
}
return 'CONFIRMED'
}
function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
if (status === 'NO_SHOW') return 'No show'
if (status === 'COMPLETED') return 'Cumplida' if (status === 'COMPLETED') return 'Cumplida'
if (status === 'CANCELLED') return 'Cancelada' if (status === 'CANCELLED') return 'Cancelada'
return 'Confirmada' return 'Confirmada'
} }
function statusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') { function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
if (status === 'NO_SHOW') {
return 'border-orange-200 bg-orange-50 text-orange-700'
}
if (status === 'COMPLETED') { if (status === 'COMPLETED') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700' return 'border-emerald-200 bg-emerald-50 text-emerald-700'
} }
@@ -333,7 +353,8 @@ export function HomePage() {
<h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2> <h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{bookings.map((booking) => { {bookings.map((booking) => {
const canManage = booking.status === 'CONFIRMED' const effectiveStatus = getEffectiveStatus(booking)
const canManage = booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'
const isMutating = updateStatusMutation.isPending const isMutating = updateStatusMutation.isPending
return ( return (
@@ -352,9 +373,9 @@ export function HomePage() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span <span
className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${statusClassName(booking.status)}`} className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${effectiveStatusClassName(effectiveStatus)}`}
> >
{statusLabel(booking.status)} {effectiveStatusLabel(effectiveStatus)}
</span> </span>
{canManage && ( {canManage && (

View File

@@ -73,13 +73,6 @@ export function RootLayout() {
> >
Home Home
</Link> </Link>
<Link
to="/about"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
About
</Link>
{isAuthenticated && currentComplexSlug && ( {isAuthenticated && currentComplexSlug && (
<Link <Link
to="/complex/$slug/edit" to="/complex/$slug/edit"

View File

@@ -1,19 +1,91 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { UserProfileResponse } from '@repo/api-contract' import type { UserProfileResponse } from '@repo/api-contract'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { apiClient } from '@/lib/api-client' import { apiClient } from '@/lib/api-client'
import { useAuth } from '@/lib/auth' import { useAuth } from '@/lib/auth'
const updateProfileSchema = z.object({
fullName: z.string().min(1, 'El nombre es requerido'),
})
const updatePasswordSchema = z.object({
currentPassword: z.string().min(1, 'La contraseña actual es requerida'),
newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'),
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
})
type UpdateProfileForm = z.infer<typeof updateProfileSchema>
type UpdatePasswordForm = z.infer<typeof updatePasswordSchema>
export function ProfilePage() { export function ProfilePage() {
const { user, displayName, initials, avatarUrl, isAuthenticated } = useAuth() const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } = useAuth()
const queryClient = useQueryClient()
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
enabled: isAuthenticated, enabled: isAuthenticated,
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(), queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
}) })
const updateProfileForm = useForm<UpdateProfileForm>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
fullName: displayName,
},
})
const updatePasswordForm = useForm<UpdatePasswordForm>({
resolver: zodResolver(updatePasswordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
})
const updateProfileMutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-profile'] })
updateProfileForm.reset()
},
})
const updatePasswordMutation = useMutation({
mutationFn: async (data: UpdatePasswordForm) => {
// Note: Supabase doesn't require current password for password update
// The user needs to be recently authenticated
await updatePassword({ password: data.newPassword })
},
onSuccess: () => {
updatePasswordForm.reset()
},
})
const onUpdateProfile = (data: UpdateProfileForm) => {
updateProfileMutation.mutate(data)
}
const onUpdatePassword = (data: UpdatePasswordForm) => {
updatePasswordMutation.mutate(data)
}
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6"> <main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
@@ -31,8 +103,9 @@ export function ProfilePage() {
} }
return ( return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6"> <main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6 py-8">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm"> <div className="w-full max-w-3xl space-y-6">
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar size="lg"> <Avatar size="lg">
<AvatarImage <AvatarImage
@@ -66,6 +139,107 @@ export function ProfilePage() {
</p> </p>
)} )}
</section> </section>
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold mb-4">Editar Perfil</h2>
<Form {...updateProfileForm}>
<form onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)} className="space-y-4">
<FormField
control={updateProfileForm.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre completo</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
{updateProfileMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al actualizar el perfil: {updateProfileMutation.error?.message}
</p>
)}
{updateProfileMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600">
Perfil actualizado exitosamente.
</p>
)}
</section>
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
<Form {...updatePasswordForm}>
<form onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)} className="space-y-4">
<FormField
control={updatePasswordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Contraseña actual</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updatePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar nueva contraseña</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={updatePasswordMutation.isPending}
>
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
</Button>
</form>
</Form>
{updatePasswordMutation.isError && (
<p className="mt-4 text-sm text-destructive">
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
</p>
)}
{updatePasswordMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600">
Contraseña cambiada exitosamente.
</p>
)}
</section>
</div>
</main> </main>
) )
} }

View File

@@ -21,6 +21,14 @@ type SignUpParams = {
fullName: string fullName: string
} }
type UpdateProfileParams = {
fullName: string
}
type UpdatePasswordParams = {
password: string
}
export type AuthContextValue = { export type AuthContextValue = {
user: User | null user: User | null
session: Session | null session: Session | null
@@ -31,6 +39,8 @@ export type AuthContextValue = {
avatarUrl: string | null avatarUrl: string | null
signInWithPassword: (params: SignInParams) => Promise<void> signInWithPassword: (params: SignInParams) => Promise<void>
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }> signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>
updateProfile: (params: UpdateProfileParams) => Promise<void>
updatePassword: (params: UpdatePasswordParams) => Promise<void>
signOut: () => Promise<void> signOut: () => Promise<void>
} }
@@ -143,6 +153,27 @@ export function AuthProvider({ children }: PropsWithChildren) {
requiresEmailConfirmation: !data.session, requiresEmailConfirmation: !data.session,
} }
}, },
updateProfile: async ({ fullName }) => {
const { error } = await supabase.auth.updateUser({
data: {
full_name: fullName,
name: fullName,
},
})
if (error) {
throw error
}
},
updatePassword: async ({ password }) => {
const { error } = await supabase.auth.updateUser({
password,
})
if (error) {
throw error
}
},
signOut: async () => { signOut: async () => {
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut()
if (error) { if (error) {