feat: implement onboarding flow with complex creation and email verification, and add Zustand for state management
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { AdminBooking } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -136,6 +137,24 @@ export function HomePage() {
|
||||
queryFn: () => apiClient.complexes.getCurrent(),
|
||||
});
|
||||
|
||||
const { setCurrentComplex } = useCurrentComplexStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentComplexQuery.data) {
|
||||
setCurrentComplex(currentComplexQuery.data.complexSlug);
|
||||
}
|
||||
}, [currentComplexQuery.data, setCurrentComplex]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useCurrentComplexStore.subscribe((state, prevState) => {
|
||||
if (state.currentComplexSlug !== prevState.currentComplexSlug) {
|
||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-bookings'] });
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [queryClient]);
|
||||
|
||||
const myComplexesQuery = useQuery({
|
||||
queryKey: ['my-complexes'],
|
||||
queryFn: () => apiClient.complexes.listMine(),
|
||||
@@ -146,7 +165,11 @@ export function HomePage() {
|
||||
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
||||
} else if (myComplexesQuery.data && myComplexesQuery.data.length > 1 && !currentComplexQuery.data) {
|
||||
} else if (
|
||||
myComplexesQuery.data &&
|
||||
myComplexesQuery.data.length > 1 &&
|
||||
!currentComplexQuery.data
|
||||
) {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
}
|
||||
@@ -154,7 +177,13 @@ export function HomePage() {
|
||||
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
||||
autoSelect();
|
||||
}
|
||||
}, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]);
|
||||
}, [
|
||||
currentComplexQuery.isLoading,
|
||||
currentComplexQuery.data,
|
||||
myComplexesQuery.data,
|
||||
navigate,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const selectedComplex = currentComplexQuery.data ?? null;
|
||||
|
||||
|
||||
@@ -10,48 +10,33 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
getCurrentComplexSlug,
|
||||
setCurrentComplexSlug as persistCurrentComplexSlug,
|
||||
} from '@/lib/current-complex';
|
||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { LogOut, Menu, UserRound } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { ThemeSwitcher } from './theme-switcher';
|
||||
|
||||
export function RootLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
|
||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
|
||||
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
|
||||
const myComplexesQuery = useQuery({
|
||||
queryKey: ['my-complexes'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => apiClient.complexes.listMine(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(getCurrentComplexSlug());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentComplexSlug) return;
|
||||
|
||||
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug;
|
||||
if (fallbackSlug) {
|
||||
setCurrentComplexSlug(fallbackSlug);
|
||||
persistCurrentComplexSlug(fallbackSlug);
|
||||
}
|
||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
||||
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
|
||||
|
||||
const currentComplexName = useMemo(() => {
|
||||
const complexes = myComplexesQuery.data ?? [];
|
||||
if (complexes.length === 0) return 'Mi complejo';
|
||||
|
||||
const selected = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
|
||||
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
|
||||
|
||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
||||
}, [complexSlug, myComplexesQuery.data]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
@@ -110,6 +95,26 @@ export function RootLayout() {
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Cambiar complejo
|
||||
</DropdownMenuLabel>
|
||||
{myComplexesQuery.data.map((complex) => (
|
||||
<DropdownMenuItem
|
||||
key={complex.id}
|
||||
onSelect={async () => {
|
||||
await apiClient.complexes.select({ complexId: complex.id });
|
||||
setCurrentComplex(complex.complexSlug);
|
||||
}}
|
||||
>
|
||||
{complex.complexSlug === complexSlug && <span className="mr-2">✓</span>}
|
||||
{complex.complexName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/profile' });
|
||||
|
||||
@@ -41,18 +41,13 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
async function handlePostLogin() {
|
||||
const complexes = await apiClient.complexes.listMine();
|
||||
|
||||
if (complexes.length === 1) {
|
||||
if (complexes.length === 0) {
|
||||
await navigate({ to: redirectTo });
|
||||
} else if (complexes.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||
navigate({ to: redirectTo });
|
||||
} else if (complexes.length > 1) {
|
||||
const current = await apiClient.complexes.getCurrent();
|
||||
if (current) {
|
||||
navigate({ to: redirectTo });
|
||||
} else {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
} else {
|
||||
await navigate({ to: redirectTo });
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
apps/frontend/src/features/onboard/create-complex-page.tsx
Normal file
222
apps/frontend/src/features/onboard/create-complex-page.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const createComplexSchema = z.object({
|
||||
complexName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
|
||||
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
|
||||
physicalAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'La direccion debe tener al menos 5 caracteres.')
|
||||
.max(200, 'La direccion no puede superar los 200 caracteres.'),
|
||||
city: z.string().trim().max(100, 'La ciudad no puede superar los 100 caracteres.').optional(),
|
||||
state: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, 'La provincia/estado no puede superar los 100 caracteres.')
|
||||
.optional(),
|
||||
country: z.string().trim().max(100, 'El país no puede superar los 100 caracteres.').optional(),
|
||||
planCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'El planCode no puede estar vacío.')
|
||||
.max(10, 'El planCode no puede superar los 10 caracteres.')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type CreateComplexForm = z.infer<typeof createComplexSchema>;
|
||||
|
||||
export function CreateComplexPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<CreateComplexForm>({
|
||||
resolver: zodResolver(createComplexSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
planCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await apiClient.plans.list();
|
||||
setPlans(result);
|
||||
} catch {
|
||||
setPlans([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (values: CreateComplexForm) => {
|
||||
setErrorMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const pendingEmail = sessionStorage.getItem('pending-signup-email');
|
||||
const payload: Parameters<typeof apiClient.complexes.create>[0] = {
|
||||
complexName: values.complexName,
|
||||
physicalAddress: values.physicalAddress,
|
||||
city: values.city,
|
||||
state: values.state,
|
||||
country: values.country,
|
||||
planCode: values.planCode,
|
||||
};
|
||||
|
||||
if (pendingEmail) {
|
||||
payload.adminEmail = pendingEmail;
|
||||
}
|
||||
|
||||
const complex = await apiClient.complexes.create(payload);
|
||||
|
||||
sessionStorage.removeItem('pending-signup-email');
|
||||
sessionStorage.removeItem('pending-signup-userId');
|
||||
setCurrentComplexSlug(complex.complexSlug);
|
||||
await navigate({
|
||||
to: '/complex/$slug/edit',
|
||||
params: { slug: complex.complexSlug },
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Crear tu complejo</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Completá los datos de tu complejo para comenzar.
|
||||
</p>
|
||||
|
||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{user?.email && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="user-email">Email</FieldLabel>
|
||||
<Input id="user-email" type="email" value={user.email} disabled readOnly />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
|
||||
<FieldLabel htmlFor="complexName">Nombre del complejo</FieldLabel>
|
||||
<Input
|
||||
id="complexName"
|
||||
type="text"
|
||||
placeholder="Complejo Las Palmeras"
|
||||
aria-invalid={Boolean(form.formState.errors.complexName)}
|
||||
{...form.register('complexName')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.complexName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
|
||||
<FieldLabel htmlFor="physicalAddress">Dirección física</FieldLabel>
|
||||
<Input
|
||||
id="physicalAddress"
|
||||
type="text"
|
||||
placeholder="Av. San Martin 1234, Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
|
||||
{...form.register('physicalAddress')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.physicalAddress]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.city)}>
|
||||
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
placeholder="Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.city)}
|
||||
{...form.register('city')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.city]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.state)}>
|
||||
<FieldLabel htmlFor="state">Provincia/Estado</FieldLabel>
|
||||
<Input
|
||||
id="state"
|
||||
type="text"
|
||||
placeholder="Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.state)}
|
||||
{...form.register('state')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.state]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.country)}>
|
||||
<FieldLabel htmlFor="country">País</FieldLabel>
|
||||
<Input
|
||||
id="country"
|
||||
type="text"
|
||||
placeholder="Argentina"
|
||||
aria-invalid={Boolean(form.formState.errors.country)}
|
||||
{...form.register('country')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.country]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
|
||||
<FieldLabel htmlFor="planCode">Plan</FieldLabel>
|
||||
<select
|
||||
id="planCode"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
||||
{...form.register('planCode')}
|
||||
>
|
||||
<option value="">
|
||||
{plans.length > 0 ? 'Selecciona un plan' : 'No hay planes disponibles'}
|
||||
</option>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.code} value={plan.code}>
|
||||
{plan.name} - ${plan.price.toFixed(2)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{plans.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Carga planes en la base para continuar.
|
||||
</p>
|
||||
)}
|
||||
<FieldError errors={[form.formState.errors.planCode]} />
|
||||
</Field>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{isSubmitting ? 'Creando complejo...' : 'Crear complejo'}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
287
apps/frontend/src/features/onboard/onboard-login-page.tsx
Normal file
287
apps/frontend/src/features/onboard/onboard-login-page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiClient, authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Ingresá un email válido.'),
|
||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||
});
|
||||
|
||||
const signupSchema = z
|
||||
.object({
|
||||
fullName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres.'),
|
||||
email: z.string().email('Ingresá un email válido.'),
|
||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
type SignupForm = z.infer<typeof signupSchema>;
|
||||
|
||||
export function OnboardLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword, signUp } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loginForm = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
const signupForm = useForm<SignupForm>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: { fullName: '', email: '', password: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
async function handlePostLogin() {
|
||||
const complexes = await apiClient.complexes.listMine();
|
||||
|
||||
if (complexes.length === 0) {
|
||||
navigate({ to: '/onboard/create-complex' });
|
||||
} else if (complexes.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||
navigate({ to: '/' });
|
||||
} else {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
}
|
||||
|
||||
const onLoginSubmit = async (values: LoginForm) => {
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await signInWithPassword(values);
|
||||
await handlePostLogin();
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSignupSubmit = async (values: SignupForm) => {
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await signUp({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
fullName: values.fullName,
|
||||
});
|
||||
|
||||
if (result.requiresEmailConfirmation) {
|
||||
sessionStorage.setItem('pending-signup-email', values.email);
|
||||
navigate({
|
||||
to: '/onboard/verify-email',
|
||||
search: { email: values.email },
|
||||
});
|
||||
} else {
|
||||
navigate({ to: '/onboard/create-complex' });
|
||||
}
|
||||
} catch {
|
||||
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: `${window.location.origin}/onboard/create-complex`,
|
||||
});
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión con Google.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">
|
||||
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
|
||||
</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{mode === 'login'
|
||||
? 'Ingresá con tu cuenta para continuar.'
|
||||
: 'Creá tu cuenta para comenzar.'}
|
||||
</p>
|
||||
|
||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">O</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<form className="space-y-3" onSubmit={loginForm.handleSubmit(onLoginSubmit)}>
|
||||
<Field data-invalid={Boolean(loginForm.formState.errors.email)}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
aria-invalid={Boolean(loginForm.formState.errors.email)}
|
||||
{...loginForm.register('email')}
|
||||
/>
|
||||
<FieldError errors={[loginForm.formState.errors.email]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(loginForm.formState.errors.password)}>
|
||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(loginForm.formState.errors.password)}
|
||||
{...loginForm.register('password')}
|
||||
/>
|
||||
<FieldError errors={[loginForm.formState.errors.password]} />
|
||||
</Field>
|
||||
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!loginForm.formState.isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={signupForm.handleSubmit(onSignupSubmit)}>
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.fullName)}>
|
||||
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="Juan Pérez"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.fullName)}
|
||||
{...signupForm.register('fullName')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.fullName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.email)}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.email)}
|
||||
{...signupForm.register('email')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.email]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.password)}>
|
||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.password)}
|
||||
{...signupForm.register('password')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.password]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.confirmPassword)}>
|
||||
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.confirmPassword)}
|
||||
{...signupForm.register('confirmPassword')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.confirmPassword]} />
|
||||
</Field>
|
||||
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!signupForm.formState.isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
¿No tenés cuenta?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setMode('signup')}
|
||||
>
|
||||
Crear cuenta
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
Iniciar sesión
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
53
apps/frontend/src/features/onboard/verify-email-page.tsx
Normal file
53
apps/frontend/src/features/onboard/verify-email-page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Route } from '@/routes/onboard/verify-email';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const search = Route.useSearch();
|
||||
const email = search.email || sessionStorage.getItem('pending-signup-email') || '';
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown > 0) {
|
||||
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCooldown]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || resendCooldown > 0) return;
|
||||
|
||||
setResendCooldown(60);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Verificá tu email</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Te enviamos un email de verificación a <strong>{email}</strong>. Hacé click en el link
|
||||
para confirmar tu cuenta.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0}
|
||||
>
|
||||
{resendCooldown > 0 ? `Reenviar en ${resendCooldown}s` : 'Reenviar email'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya verificaste tu email?{' '}
|
||||
<Link to="/onboard/create-complex" className="text-primary hover:underline">
|
||||
Continuar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -53,4 +53,4 @@ export function setApiAccessToken(_token: string | null) {
|
||||
// accessToken = token;
|
||||
}
|
||||
|
||||
export type ApiClient = typeof apiClient;
|
||||
export type ApiClient = typeof apiClient;
|
||||
|
||||
@@ -31,4 +31,4 @@ export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||
handlers.onError = nextHandlers.onError;
|
||||
}
|
||||
|
||||
export { handlers };
|
||||
export { handlers };
|
||||
|
||||
@@ -57,4 +57,4 @@ http.interceptors.response.use(
|
||||
|
||||
return Promise.reject(normalized);
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
@@ -15,4 +15,4 @@ export {
|
||||
listByComplex,
|
||||
createAdmin,
|
||||
updateStatus,
|
||||
} from './resources/bookings';
|
||||
} from './resources/bookings';
|
||||
|
||||
@@ -57,4 +57,4 @@ export async function updateStatus(bookingId: string, payload: UpdateAdminBookin
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
} from '@repo/api-contract';
|
||||
import { http } from '../http';
|
||||
|
||||
export async function create(payload: CreateComplexPayload) {
|
||||
export async function create(
|
||||
payload: CreateComplexPayload & { adminEmail?: string; userId?: string }
|
||||
) {
|
||||
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
|
||||
return response.data;
|
||||
}
|
||||
@@ -46,4 +48,4 @@ export async function select(payload: SelectComplexInput) {
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@ export async function create(complexId: string, payload: CreateCourtInput) {
|
||||
export async function update(id: string, payload: UpdateCourtInput) {
|
||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ export async function resendOtp(payload: OnboardingResendOtpInput) {
|
||||
}
|
||||
|
||||
export async function complete(payload: OnboardingCompleteInput) {
|
||||
const response = await http.post<OnboardingCompleteResponse>(
|
||||
'/api/onboarding/complete',
|
||||
payload
|
||||
);
|
||||
const response = await http.post<OnboardingCompleteResponse>('/api/onboarding/complete', payload);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ import { http } from '../http';
|
||||
export async function list() {
|
||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@ export async function create(payload: CreateSportInput) {
|
||||
export async function update(id: string, payload: UpdateSportInput) {
|
||||
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ import { http } from '../http';
|
||||
export async function getProfile() {
|
||||
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
|
||||
export const COMPLEX_CHANGE_EVENT = 'complex-change';
|
||||
|
||||
export function getCurrentComplexSlug(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -8,4 +9,5 @@ export function getCurrentComplexSlug(): string | null {
|
||||
export function setCurrentComplexSlug(complexSlug: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug);
|
||||
window.dispatchEvent(new Event(COMPLEX_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
19
apps/frontend/src/lib/stores/current-complex-store.ts
Normal file
19
apps/frontend/src/lib/stores/current-complex-store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface CurrentComplexState {
|
||||
currentComplexSlug: string | null;
|
||||
setCurrentComplex: (slug: string | null) => void;
|
||||
}
|
||||
|
||||
export const useCurrentComplexStore = create<CurrentComplexState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentComplexSlug: null,
|
||||
setCurrentComplex: (slug) => set({ currentComplexSlug: slug }),
|
||||
}),
|
||||
{
|
||||
name: 'current-complex-slug',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -16,7 +16,9 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
|
||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
||||
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
|
||||
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
||||
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
|
||||
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
|
||||
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
||||
import { Route as AppAboutRouteImport } from './routes/_app/about'
|
||||
@@ -61,11 +63,21 @@ const OnboardIndexRoute = OnboardIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
|
||||
id: '/verify-email',
|
||||
path: '/verify-email',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
|
||||
id: '/verify',
|
||||
path: '/verify',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
|
||||
id: '/create-complex',
|
||||
path: '/create-complex',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
|
||||
id: '/complete',
|
||||
path: '/complete',
|
||||
@@ -124,7 +136,9 @@ export interface FileRoutesByFullPath {
|
||||
'/about': typeof AppAboutRoute
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard/': typeof OnboardIndexRoute
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
@@ -139,7 +153,9 @@ export interface FileRoutesByTo {
|
||||
'/about': typeof AppAboutRoute
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard': typeof OnboardIndexRoute
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
@@ -158,7 +174,9 @@ export interface FileRoutesById {
|
||||
'/_app/about': typeof AppAboutRoute
|
||||
'/_app/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard/': typeof OnboardIndexRoute
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||
@@ -178,7 +196,9 @@ export interface FileRouteTypes {
|
||||
| '/about'
|
||||
| '/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard/'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
@@ -193,7 +213,9 @@ export interface FileRouteTypes {
|
||||
| '/about'
|
||||
| '/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard'
|
||||
| '/$complexSlug/booking'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
@@ -211,7 +233,9 @@ export interface FileRouteTypes {
|
||||
| '/_app/about'
|
||||
| '/_app/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard/'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/_app/_authenticated/'
|
||||
@@ -280,6 +304,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OnboardIndexRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/verify-email': {
|
||||
id: '/onboard/verify-email'
|
||||
path: '/verify-email'
|
||||
fullPath: '/onboard/verify-email'
|
||||
preLoaderRoute: typeof OnboardVerifyEmailRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/verify': {
|
||||
id: '/onboard/verify'
|
||||
path: '/verify'
|
||||
@@ -287,6 +318,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OnboardVerifyRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/create-complex': {
|
||||
id: '/onboard/create-complex'
|
||||
path: '/create-complex'
|
||||
fullPath: '/onboard/create-complex'
|
||||
preLoaderRoute: typeof OnboardCreateComplexRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/complete': {
|
||||
id: '/onboard/complete'
|
||||
path: '/complete'
|
||||
@@ -386,13 +424,17 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
||||
|
||||
interface OnboardRouteChildren {
|
||||
OnboardCompleteRoute: typeof OnboardCompleteRoute
|
||||
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
|
||||
OnboardVerifyRoute: typeof OnboardVerifyRoute
|
||||
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
|
||||
OnboardIndexRoute: typeof OnboardIndexRoute
|
||||
}
|
||||
|
||||
const OnboardRouteChildren: OnboardRouteChildren = {
|
||||
OnboardCompleteRoute: OnboardCompleteRoute,
|
||||
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
|
||||
OnboardVerifyRoute: OnboardVerifyRoute,
|
||||
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
|
||||
OnboardIndexRoute: OnboardIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,10 +28,10 @@ function AuthCallbackPage() {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
} else {
|
||||
navigate({ to: '/' });
|
||||
navigate({ to: '/onboard/create-complex' });
|
||||
}
|
||||
} catch {
|
||||
navigate({ to: '/login' });
|
||||
navigate({ to: '/onboard' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,4 @@ function AuthCallbackPage() {
|
||||
|
||||
export const Route = createFileRoute('/auth-callback')({
|
||||
component: AuthCallbackPage,
|
||||
});
|
||||
});
|
||||
|
||||
14
apps/frontend/src/routes/onboard/create-complex.tsx
Normal file
14
apps/frontend/src/routes/onboard/create-complex.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CreateComplexPage } from '@/features/onboard/create-complex-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/create-complex')({
|
||||
beforeLoad: ({ context }) => {
|
||||
const hasSession = context.auth.isAuthenticated;
|
||||
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
|
||||
|
||||
if (!hasSession && !hasPendingSignup) {
|
||||
throw redirect({ to: '/onboard' });
|
||||
}
|
||||
},
|
||||
component: CreateComplexPage,
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { OnboardStartPage } from '@/features/onboard/onboard-start-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/')({
|
||||
component: OnboardStartPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/login', search: { redirect: '/onboard/create-complex' } });
|
||||
}
|
||||
},
|
||||
component: OnboardLoginPage,
|
||||
});
|
||||
|
||||
18
apps/frontend/src/routes/onboard/verify-email.tsx
Normal file
18
apps/frontend/src/routes/onboard/verify-email.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { VerifyEmailPage } from '@/features/onboard/verify-email-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const verifyEmailSearchSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/onboard/verify-email')({
|
||||
validateSearch: verifyEmailSearchSchema,
|
||||
beforeLoad: () => {
|
||||
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
|
||||
if (!hasPendingSignup) {
|
||||
throw redirect({ to: '/onboard' });
|
||||
}
|
||||
},
|
||||
component: VerifyEmailPage,
|
||||
});
|
||||
Reference in New Issue
Block a user