feat: add city/state/country to complex, new settings page with sidebar, and Biome linting

- Added city, state, and country optional fields to Complex model
- Updated onboarding to include optional location fields
- Created new settings page with sidebar navigation (Datos del Complejo, Canchas)
- Replaced ESLint with Biome for frontend and backend linting
- Added parallel dev script with concurrently
- Migrated register-routes to use direct app.route() pattern
This commit is contained in:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -3,5 +3,5 @@ export function OnboardingCheckEmailStep() {
<p className="text-sm text-muted-foreground">
Revisa tu correo y abre el link de verificacion para continuar.
</p>
)
);
}

View File

@@ -1,19 +1,19 @@
import type { PlanSummary } from '@repo/api-contract'
import { Controller, type UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { CompleteValues } from '@/features/onboard/onboarding.types'
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { CompleteValues } from '@/features/onboard/onboarding.types';
import type { PlanSummary } from '@repo/api-contract';
import { Controller, type UseFormReturn } from 'react-hook-form';
type OnboardingCompleteStepProps = {
form: UseFormReturn<CompleteValues>
plans: PlanSummary[]
verifiedEmail: string | null
errorMessage: string | null
infoMessage: string | null
planPlaceholder: string
onSubmit: (values: CompleteValues) => Promise<void>
}
form: UseFormReturn<CompleteValues>;
plans: PlanSummary[];
verifiedEmail: string | null;
errorMessage: string | null;
infoMessage: string | null;
planPlaceholder: string;
onSubmit: (values: CompleteValues) => Promise<void>;
};
export function OnboardingCompleteStep({
form,
@@ -81,7 +81,7 @@ export function OnboardingCompleteStep({
aria-invalid={Boolean(form.formState.errors.planCode)}
value={field.value ?? ''}
onChange={(event) => {
field.onChange(event.target.value)
field.onChange(event.target.value);
}}
disabled={plans.length === 0}
>
@@ -115,5 +115,5 @@ export function OnboardingCompleteStep({
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
</Button>
</form>
)
);
}

View File

@@ -1,15 +1,11 @@
import type { PropsWithChildren } from 'react'
import type { PropsWithChildren } from 'react';
type OnboardingLayoutProps = PropsWithChildren<{
title: string
description: string
}>
title: string;
description: string;
}>;
export function OnboardingLayout({
title,
description,
children,
}: OnboardingLayoutProps) {
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
@@ -18,5 +14,5 @@ export function OnboardingLayout({
{children}
</section>
</main>
)
);
}

View File

@@ -1,15 +1,15 @@
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { StartValues } from '@/features/onboard/onboarding.types'
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { StartValues } from '@/features/onboard/onboarding.types';
import type { UseFormReturn } from 'react-hook-form';
type OnboardingStartStepProps = {
form: UseFormReturn<StartValues>
errorMessage: string | null
infoMessage: string | null
onSubmit: (values: StartValues) => Promise<void>
}
form: UseFormReturn<StartValues>;
errorMessage: string | null;
infoMessage: string | null;
onSubmit: (values: StartValues) => Promise<void>;
};
export function OnboardingStartStep({
form,
@@ -54,5 +54,5 @@ export function OnboardingStartStep({
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
</Button>
</form>
)
);
}

View File

@@ -1,26 +1,26 @@
import { RefreshCw } from 'lucide-react'
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp'
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'
} from '@/components/ui/input-otp';
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
import { RefreshCw } from 'lucide-react';
import type { UseFormReturn } from 'react-hook-form';
type OnboardingVerifyOtpStepProps = {
form: UseFormReturn<VerifyOtpValues>
email: string | null
errorMessage: string | null
infoMessage: string | null
remainingAttempts: number
resendCooldownSeconds: number
isResending: boolean
onSubmit: (values: VerifyOtpValues) => Promise<void>
onResend: () => Promise<void>
}
form: UseFormReturn<VerifyOtpValues>;
email: string | null;
errorMessage: string | null;
infoMessage: string | null;
remainingAttempts: number;
resendCooldownSeconds: number;
isResending: boolean;
onSubmit: (values: VerifyOtpValues) => Promise<void>;
onResend: () => Promise<void>;
};
export function OnboardingVerifyOtpStep({
form,
@@ -33,7 +33,7 @@ export function OnboardingVerifyOtpStep({
onSubmit,
onResend,
}: OnboardingVerifyOtpStepProps) {
const resendDisabled = resendCooldownSeconds > 0 || isResending
const resendDisabled = resendCooldownSeconds > 0 || isResending;
return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
@@ -51,7 +51,7 @@ export function OnboardingVerifyOtpStep({
size="sm"
disabled={resendDisabled}
onClick={() => {
void onResend()
void onResend();
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
@@ -70,7 +70,7 @@ export function OnboardingVerifyOtpStep({
pattern="\d*"
value={form.watch('otp')}
onChange={(value) => {
form.setValue('otp', value, { shouldValidate: true })
form.setValue('otp', value, { shouldValidate: true });
}}
>
<InputOTPGroup>
@@ -104,5 +104,5 @@ export function OnboardingVerifyOtpStep({
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
</Button>
</form>
)
);
}

View File

@@ -1,7 +1,5 @@
export function OnboardingVerifyingStep() {
return (
<p className="text-sm text-muted-foreground">
Verificando tu email, espera un momento...
</p>
)
<p className="text-sm text-muted-foreground">Verificando tu email, espera un momento...</p>
);
}

View File

@@ -1,43 +1,43 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
type PlanSummary,
} from '@repo/api-contract'
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step';
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step';
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step';
import type {
CompleteValues,
StartValues,
VerifyOtpValues,
} from '@/features/onboard/onboarding.types'
import { apiClient } from '@/lib/api-client'
import { useAuth } from '@/lib/auth'
import { setCurrentComplexSlug } from '@/lib/current-complex'
} from '@/features/onboard/onboarding.types';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { setCurrentComplexSlug } from '@/lib/current-complex';
import { zodResolver } from '@hookform/resolvers/zod';
import {
type PlanSummary,
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
const OTP_MAX_ATTEMPTS = 5
type OnboardStep = 'start' | 'verify-otp' | 'complete'
const OTP_MAX_ATTEMPTS = 5;
type OnboardStep = 'start' | 'verify-otp' | 'complete';
export function OnboardPage() {
const navigate = useNavigate()
const { signInWithPassword } = useAuth()
const navigate = useNavigate();
const { signInWithPassword } = useAuth();
const [step, setStep] = useState<OnboardStep>('start')
const [requestId, setRequestId] = useState<string | null>(null)
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null)
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null)
const [plans, setPlans] = useState<PlanSummary[]>([])
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS)
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0)
const [isResending, setIsResending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [infoMessage, setInfoMessage] = useState<string | null>(null)
const [step, setStep] = useState<OnboardStep>('start');
const [requestId, setRequestId] = useState<string | null>(null);
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null);
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null);
const [plans, setPlans] = useState<PlanSummary[]>([]);
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS);
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0);
const [isResending, setIsResending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [infoMessage, setInfoMessage] = useState<string | null>(null);
const startForm = useForm<StartValues>({
resolver: zodResolver(onboardingStartSchema),
@@ -46,12 +46,10 @@ export function OnboardPage() {
fullName: '',
email: '',
},
})
});
const completeForm = useForm<CompleteValues>({
resolver: zodResolver(
onboardingCompleteSchema.omit({ onboardingRequestId: true }),
),
resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
mode: 'onChange',
defaultValues: {
password: '',
@@ -59,7 +57,7 @@ export function OnboardPage() {
physicalAddress: '',
planCode: '',
},
})
});
const verifyOtpForm = useForm<VerifyOtpValues>({
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
@@ -67,114 +65,114 @@ export function OnboardPage() {
defaultValues: {
otp: '',
},
})
});
useEffect(() => {
if (step !== 'complete') return
if (step !== 'complete') return;
void (async () => {
try {
const result = await apiClient.plans.list()
setPlans(result)
const result = await apiClient.plans.list();
setPlans(result);
} catch {
setPlans([])
setPlans([]);
}
})()
}, [step])
})();
}, [step]);
const planPlaceholder = useMemo(() => {
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'
return 'Selecciona un plan'
}, [plans.length])
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)';
return 'Selecciona un plan';
}, [plans.length]);
useEffect(() => {
if (resendCooldownSeconds <= 0) return
if (resendCooldownSeconds <= 0) return;
const timeout = window.setTimeout(() => {
setResendCooldownSeconds((current) => Math.max(0, current - 1))
}, 1000)
setResendCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeout)
}
}, [resendCooldownSeconds])
window.clearTimeout(timeout);
};
}, [resendCooldownSeconds]);
const onSubmitStart = async (values: StartValues) => {
setErrorMessage(null)
setInfoMessage(null)
setErrorMessage(null);
setInfoMessage(null);
try {
const result = await apiClient.onboarding.start(values)
setRequestId(result.requestId)
setOnboardingEmail(result.email)
setRemainingAttempts(OTP_MAX_ATTEMPTS)
setResendCooldownSeconds(result.cooldownSeconds)
verifyOtpForm.reset({ otp: '' })
setStep('verify-otp')
setInfoMessage(result.message)
const result = await apiClient.onboarding.start(values);
setRequestId(result.requestId);
setOnboardingEmail(result.email);
setRemainingAttempts(OTP_MAX_ATTEMPTS);
setResendCooldownSeconds(result.cooldownSeconds);
verifyOtpForm.reset({ otp: '' });
setStep('verify-otp');
setInfoMessage(result.message);
} catch {
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.')
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.');
}
}
};
const onSubmitVerifyOtp = async (values: VerifyOtpValues) => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
setErrorMessage('No encontramos una solicitud de onboarding activa.');
return;
}
setErrorMessage(null)
setErrorMessage(null);
try {
const result = await apiClient.onboarding.verifyOtp({
requestId,
otp: values.otp,
})
});
setRemainingAttempts(result.remainingAttempts)
setResendCooldownSeconds(result.cooldownSeconds)
setInfoMessage(result.message)
setRemainingAttempts(result.remainingAttempts);
setResendCooldownSeconds(result.cooldownSeconds);
setInfoMessage(result.message);
if (result.verified) {
setVerifiedEmail(result.email ?? onboardingEmail)
setStep('complete')
return
setVerifiedEmail(result.email ?? onboardingEmail);
setStep('complete');
return;
}
} catch {
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.')
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.');
}
}
};
const onResendOtp = async () => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
setErrorMessage('No encontramos una solicitud de onboarding activa.');
return;
}
setIsResending(true)
setErrorMessage(null)
setIsResending(true);
setErrorMessage(null);
try {
const result = await apiClient.onboarding.resendOtp({ requestId })
setOnboardingEmail(result.email)
setRemainingAttempts(result.remainingAttempts)
setResendCooldownSeconds(result.cooldownSeconds)
verifyOtpForm.reset({ otp: '' })
setInfoMessage(result.message)
const result = await apiClient.onboarding.resendOtp({ requestId });
setOnboardingEmail(result.email);
setRemainingAttempts(result.remainingAttempts);
setResendCooldownSeconds(result.cooldownSeconds);
verifyOtpForm.reset({ otp: '' });
setInfoMessage(result.message);
} catch {
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.')
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.');
} finally {
setIsResending(false)
setIsResending(false);
}
}
};
const onSubmitComplete = async (values: CompleteValues) => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
setErrorMessage('No encontramos una solicitud de onboarding activa.');
return;
}
setErrorMessage(null)
setErrorMessage(null);
try {
const result = await apiClient.onboarding.complete({
@@ -183,19 +181,19 @@ export function OnboardPage() {
complexName: values.complexName,
physicalAddress: values.physicalAddress,
planCode: values.planCode,
})
setCurrentComplexSlug(result.complexSlug)
});
setCurrentComplexSlug(result.complexSlug);
const emailForSignIn = verifiedEmail ?? onboardingEmail
const emailForSignIn = verifiedEmail ?? onboardingEmail;
if (emailForSignIn) {
await signInWithPassword({ email: emailForSignIn, password: values.password })
await signInWithPassword({ email: emailForSignIn, password: values.password });
}
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } })
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } });
} catch {
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.')
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.');
}
}
};
return (
<OnboardingLayout
@@ -237,5 +235,5 @@ export function OnboardPage() {
/>
)}
</OnboardingLayout>
)
);
}

View File

@@ -2,15 +2,9 @@ import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract'
import { z } from 'zod'
} from '@repo/api-contract';
import { z } from 'zod';
export type StartValues = z.infer<typeof onboardingStartSchema>
export type VerifyOtpValues = Omit<
z.infer<typeof onboardingVerifyOtpSchema>,
'requestId'
>
export type CompleteValues = Omit<
z.infer<typeof onboardingCompleteSchema>,
'onboardingRequestId'
>
export type StartValues = z.infer<typeof onboardingStartSchema>;
export type VerifyOtpValues = Omit<z.infer<typeof onboardingVerifyOtpSchema>, 'requestId'>;
export type CompleteValues = Omit<z.infer<typeof onboardingCompleteSchema>, 'onboardingRequestId'>;