feat: implement onboarding flow with complex creation and email verification, and add Zustand for state management
This commit is contained in:
44
AGENTS.md
44
AGENTS.md
@@ -149,3 +149,47 @@ The app supports users with multiple complexes. Each user has a role per complex
|
|||||||
### Profile Role
|
### Profile Role
|
||||||
|
|
||||||
The profile page shows the user's role for the **selected complex**, not the global user role. This is fetched from `ComplexUser` table using the cookie value.
|
The profile page shows the user's role for the **selected complex**, not the global user role. This is fetched from `ComplexUser` table using the cookie value.
|
||||||
|
|
||||||
|
## Frontend API Client
|
||||||
|
|
||||||
|
The API client is split into modular files for better maintainability and testability.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── api-client.ts # Main barrel file (~70 lines)
|
||||||
|
└── api/
|
||||||
|
├── base.ts # ApiClientError, configureApiClient, apiBaseUrl
|
||||||
|
├── http.ts # Axios instance with interceptors
|
||||||
|
├── index.ts # Re-exports all resources
|
||||||
|
└── resources/
|
||||||
|
├── complexes.ts # Complex endpoints
|
||||||
|
├── courts.ts # Court endpoints
|
||||||
|
├── bookings.ts # Public & admin booking endpoints
|
||||||
|
├── user.ts # User profile endpoint
|
||||||
|
├── sports.ts # Sport endpoints
|
||||||
|
├── onboarding.ts # Onboarding endpoints
|
||||||
|
└── plans.ts # Plan endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the API Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { apiClient, authClient, ApiClientError, configureApiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
// Via apiClient (barrel)
|
||||||
|
await apiClient.complexes.listMine();
|
||||||
|
await apiClient.publicBookings.getAvailability('my-club', { date: '2026-04-20' });
|
||||||
|
|
||||||
|
// Direct imports (for better tree-shaking)
|
||||||
|
import { complexes, getAvailability } from '@/lib/api';
|
||||||
|
await complexes.listMine();
|
||||||
|
await getAvailability('my-club', { date: '2026-04-20' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Resource
|
||||||
|
|
||||||
|
1. Create `lib/api/resources/[resource].ts`
|
||||||
|
2. Export functions using `http` from `../http`
|
||||||
|
3. Add exports in `lib/api/index.ts`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { db } from '@/lib/prisma';
|
||||||
import { createComplex } from '@/modules/complex/services/complex.service';
|
import { createComplex } from '@/modules/complex/services/complex.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreateComplexInput } from '@repo/api-contract';
|
import type { CreateComplexInput } from '@repo/api-contract';
|
||||||
@@ -6,16 +7,32 @@ export async function createComplexHandler(c: AppContext) {
|
|||||||
const payload = c.req.valid('json' as never) as CreateComplexInput;
|
const payload = c.req.valid('json' as never) as CreateComplexInput;
|
||||||
|
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const adminEmail = user.email;
|
let adminEmail = user?.email;
|
||||||
|
let userId = user?.id;
|
||||||
|
|
||||||
if (!adminEmail) {
|
if (!adminEmail) {
|
||||||
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400);
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
adminEmail = body.adminEmail;
|
||||||
|
userId = body.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminEmail) {
|
||||||
|
return c.json({ message: 'Se requiere email del administrador.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId && adminEmail) {
|
||||||
|
const existingUser = await db.user.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
userId = existingUser?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complex = await createComplex({
|
const complex = await createComplex({
|
||||||
complexName: payload.complexName,
|
complexName: payload.complexName,
|
||||||
physicalAddress: payload.physicalAddress,
|
physicalAddress: payload.physicalAddress,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
|
userId,
|
||||||
planCode: payload.planCode,
|
planCode: payload.planCode,
|
||||||
city: payload.city,
|
city: payload.city,
|
||||||
state: payload.state,
|
state: payload.state,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type CreateComplexInput = {
|
|||||||
complexName: string;
|
complexName: string;
|
||||||
physicalAddress: string;
|
physicalAddress: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
|
userId?: string;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
@@ -64,20 +65,36 @@ async function buildUniqueSlug(source: string, excludeComplexId?: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createComplex(input: CreateComplexInput) {
|
export async function createComplex(input: CreateComplexInput) {
|
||||||
const complexSlug = await buildUniqueSlug(input.complexName);
|
const { userId, ...rest } = input;
|
||||||
|
|
||||||
return db.complex.create({
|
return db.$transaction(async (tx) => {
|
||||||
data: {
|
const complexSlug = await buildUniqueSlug(input.complexName);
|
||||||
id: uuidv7(),
|
|
||||||
complexName: input.complexName,
|
const complex = await tx.complex.create({
|
||||||
physicalAddress: input.physicalAddress.trim(),
|
data: {
|
||||||
city: input.city?.trim() || null,
|
id: uuidv7(),
|
||||||
state: input.state?.trim() || null,
|
complexName: input.complexName,
|
||||||
country: input.country?.trim() || null,
|
physicalAddress: input.physicalAddress.trim(),
|
||||||
complexSlug,
|
city: input.city?.trim() || null,
|
||||||
adminEmail: input.adminEmail,
|
state: input.state?.trim() || null,
|
||||||
planCode: input.planCode,
|
country: input.country?.trim() || null,
|
||||||
},
|
complexSlug,
|
||||||
|
adminEmail: input.adminEmail,
|
||||||
|
planCode: input.planCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await tx.complexUser.create({
|
||||||
|
data: {
|
||||||
|
complexId: complex.id,
|
||||||
|
userId,
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return complex;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"simple-icons": "^16.15.0",
|
"simple-icons": "^16.15.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2"
|
"vaul": "^1.1.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { AdminBooking } from '@repo/api-contract';
|
import type { AdminBooking } from '@repo/api-contract';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -136,6 +137,24 @@ export function HomePage() {
|
|||||||
queryFn: () => apiClient.complexes.getCurrent(),
|
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({
|
const myComplexesQuery = useQuery({
|
||||||
queryKey: ['my-complexes'],
|
queryKey: ['my-complexes'],
|
||||||
queryFn: () => apiClient.complexes.listMine(),
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
@@ -146,7 +165,11 @@ export function HomePage() {
|
|||||||
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||||
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
||||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
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' });
|
navigate({ to: '/select-complex' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +177,13 @@ export function HomePage() {
|
|||||||
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
||||||
autoSelect();
|
autoSelect();
|
||||||
}
|
}
|
||||||
}, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]);
|
}, [
|
||||||
|
currentComplexQuery.isLoading,
|
||||||
|
currentComplexQuery.data,
|
||||||
|
myComplexesQuery.data,
|
||||||
|
navigate,
|
||||||
|
queryClient,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedComplex = currentComplexQuery.data ?? null;
|
const selectedComplex = currentComplexQuery.data ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,48 +10,33 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import {
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
getCurrentComplexSlug,
|
|
||||||
setCurrentComplexSlug as persistCurrentComplexSlug,
|
|
||||||
} from '@/lib/current-complex';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
import { LogOut, Menu, UserRound } from 'lucide-react';
|
import { LogOut, Menu, UserRound } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ThemeSwitcher } from './theme-switcher';
|
import { ThemeSwitcher } from './theme-switcher';
|
||||||
|
|
||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
|
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
|
||||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
|
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
|
||||||
const myComplexesQuery = useQuery({
|
const myComplexesQuery = useQuery({
|
||||||
queryKey: ['my-complexes'],
|
queryKey: ['my-complexes'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => apiClient.complexes.listMine(),
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
|
||||||
setCurrentComplexSlug(getCurrentComplexSlug());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentComplexSlug) return;
|
|
||||||
|
|
||||||
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug;
|
|
||||||
if (fallbackSlug) {
|
|
||||||
setCurrentComplexSlug(fallbackSlug);
|
|
||||||
persistCurrentComplexSlug(fallbackSlug);
|
|
||||||
}
|
|
||||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
|
||||||
|
|
||||||
const currentComplexName = useMemo(() => {
|
const currentComplexName = useMemo(() => {
|
||||||
const complexes = myComplexesQuery.data ?? [];
|
const complexes = myComplexesQuery.data ?? [];
|
||||||
if (complexes.length === 0) return 'Mi complejo';
|
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';
|
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
||||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
}, [complexSlug, myComplexesQuery.data]);
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
@@ -110,6 +95,26 @@ export function RootLayout() {
|
|||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<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
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void navigate({ to: '/profile' });
|
void navigate({ to: '/profile' });
|
||||||
|
|||||||
@@ -41,18 +41,13 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
async function handlePostLogin() {
|
async function handlePostLogin() {
|
||||||
const complexes = await apiClient.complexes.listMine();
|
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 });
|
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||||
navigate({ to: redirectTo });
|
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 {
|
} 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;
|
// 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;
|
handlers.onError = nextHandlers.onError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handlers };
|
export { handlers };
|
||||||
|
|||||||
@@ -57,4 +57,4 @@ http.interceptors.response.use(
|
|||||||
|
|
||||||
return Promise.reject(normalized);
|
return Promise.reject(normalized);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ export {
|
|||||||
listByComplex,
|
listByComplex,
|
||||||
createAdmin,
|
createAdmin,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
} from './resources/bookings';
|
} from './resources/bookings';
|
||||||
|
|||||||
@@ -57,4 +57,4 @@ export async function updateStatus(bookingId: string, payload: UpdateAdminBookin
|
|||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import type {
|
|||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
import { http } from '../http';
|
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);
|
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -46,4 +48,4 @@ export async function select(payload: SelectComplexInput) {
|
|||||||
{ headers: { 'Content-Type': 'application/json' } }
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ export async function create(complexId: string, payload: CreateCourtInput) {
|
|||||||
export async function update(id: string, payload: UpdateCourtInput) {
|
export async function update(id: string, payload: UpdateCourtInput) {
|
||||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ export async function resendOtp(payload: OnboardingResendOtpInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function complete(payload: OnboardingCompleteInput) {
|
export async function complete(payload: OnboardingCompleteInput) {
|
||||||
const response = await http.post<OnboardingCompleteResponse>(
|
const response = await http.post<OnboardingCompleteResponse>('/api/onboarding/complete', payload);
|
||||||
'/api/onboarding/complete',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ import { http } from '../http';
|
|||||||
export async function list() {
|
export async function list() {
|
||||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
const response = await http.get<PlanSummary[]>('/api/plans');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ export async function create(payload: CreateSportInput) {
|
|||||||
export async function update(id: string, payload: UpdateSportInput) {
|
export async function update(id: string, payload: UpdateSportInput) {
|
||||||
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ import { http } from '../http';
|
|||||||
export async function getProfile() {
|
export async function getProfile() {
|
||||||
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
|
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
|
||||||
|
export const COMPLEX_CHANGE_EVENT = 'complex-change';
|
||||||
|
|
||||||
export function getCurrentComplexSlug(): string | null {
|
export function getCurrentComplexSlug(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -8,4 +9,5 @@ export function getCurrentComplexSlug(): string | null {
|
|||||||
export function setCurrentComplexSlug(complexSlug: string) {
|
export function setCurrentComplexSlug(complexSlug: string) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug);
|
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 AuthCallbackRouteImport } from './routes/auth-callback'
|
||||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||||
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
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 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 OnboardCompleteRouteImport } from './routes/onboard/complete'
|
||||||
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
||||||
import { Route as AppAboutRouteImport } from './routes/_app/about'
|
import { Route as AppAboutRouteImport } from './routes/_app/about'
|
||||||
@@ -61,11 +63,21 @@ const OnboardIndexRoute = OnboardIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => OnboardRoute,
|
getParentRoute: () => OnboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
|
||||||
|
id: '/verify-email',
|
||||||
|
path: '/verify-email',
|
||||||
|
getParentRoute: () => OnboardRoute,
|
||||||
|
} as any)
|
||||||
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
|
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
|
||||||
id: '/verify',
|
id: '/verify',
|
||||||
path: '/verify',
|
path: '/verify',
|
||||||
getParentRoute: () => OnboardRoute,
|
getParentRoute: () => OnboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
|
||||||
|
id: '/create-complex',
|
||||||
|
path: '/create-complex',
|
||||||
|
getParentRoute: () => OnboardRoute,
|
||||||
|
} as any)
|
||||||
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
|
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
|
||||||
id: '/complete',
|
id: '/complete',
|
||||||
path: '/complete',
|
path: '/complete',
|
||||||
@@ -124,7 +136,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/about': typeof AppAboutRoute
|
'/about': typeof AppAboutRoute
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
'/onboard/verify': typeof OnboardVerifyRoute
|
||||||
|
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||||
'/onboard/': typeof OnboardIndexRoute
|
'/onboard/': typeof OnboardIndexRoute
|
||||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||||
@@ -139,7 +153,9 @@ export interface FileRoutesByTo {
|
|||||||
'/about': typeof AppAboutRoute
|
'/about': typeof AppAboutRoute
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
'/onboard/verify': typeof OnboardVerifyRoute
|
||||||
|
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||||
'/onboard': typeof OnboardIndexRoute
|
'/onboard': typeof OnboardIndexRoute
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||||
@@ -158,7 +174,9 @@ export interface FileRoutesById {
|
|||||||
'/_app/about': typeof AppAboutRoute
|
'/_app/about': typeof AppAboutRoute
|
||||||
'/_app/profile': typeof AppProfileRoute
|
'/_app/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
|
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
'/onboard/verify': typeof OnboardVerifyRoute
|
||||||
|
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||||
'/onboard/': typeof OnboardIndexRoute
|
'/onboard/': typeof OnboardIndexRoute
|
||||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||||
@@ -178,7 +196,9 @@ export interface FileRouteTypes {
|
|||||||
| '/about'
|
| '/about'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
|
| '/onboard/create-complex'
|
||||||
| '/onboard/verify'
|
| '/onboard/verify'
|
||||||
|
| '/onboard/verify-email'
|
||||||
| '/onboard/'
|
| '/onboard/'
|
||||||
| '/$complexSlug/booking/'
|
| '/$complexSlug/booking/'
|
||||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
@@ -193,7 +213,9 @@ export interface FileRouteTypes {
|
|||||||
| '/about'
|
| '/about'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
|
| '/onboard/create-complex'
|
||||||
| '/onboard/verify'
|
| '/onboard/verify'
|
||||||
|
| '/onboard/verify-email'
|
||||||
| '/onboard'
|
| '/onboard'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
@@ -211,7 +233,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/about'
|
| '/_app/about'
|
||||||
| '/_app/profile'
|
| '/_app/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
|
| '/onboard/create-complex'
|
||||||
| '/onboard/verify'
|
| '/onboard/verify'
|
||||||
|
| '/onboard/verify-email'
|
||||||
| '/onboard/'
|
| '/onboard/'
|
||||||
| '/$complexSlug/booking/'
|
| '/$complexSlug/booking/'
|
||||||
| '/_app/_authenticated/'
|
| '/_app/_authenticated/'
|
||||||
@@ -280,6 +304,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof OnboardIndexRouteImport
|
preLoaderRoute: typeof OnboardIndexRouteImport
|
||||||
parentRoute: typeof OnboardRoute
|
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': {
|
'/onboard/verify': {
|
||||||
id: '/onboard/verify'
|
id: '/onboard/verify'
|
||||||
path: '/verify'
|
path: '/verify'
|
||||||
@@ -287,6 +318,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof OnboardVerifyRouteImport
|
preLoaderRoute: typeof OnboardVerifyRouteImport
|
||||||
parentRoute: typeof OnboardRoute
|
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': {
|
'/onboard/complete': {
|
||||||
id: '/onboard/complete'
|
id: '/onboard/complete'
|
||||||
path: '/complete'
|
path: '/complete'
|
||||||
@@ -386,13 +424,17 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
|||||||
|
|
||||||
interface OnboardRouteChildren {
|
interface OnboardRouteChildren {
|
||||||
OnboardCompleteRoute: typeof OnboardCompleteRoute
|
OnboardCompleteRoute: typeof OnboardCompleteRoute
|
||||||
|
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
|
||||||
OnboardVerifyRoute: typeof OnboardVerifyRoute
|
OnboardVerifyRoute: typeof OnboardVerifyRoute
|
||||||
|
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
|
||||||
OnboardIndexRoute: typeof OnboardIndexRoute
|
OnboardIndexRoute: typeof OnboardIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardRouteChildren: OnboardRouteChildren = {
|
const OnboardRouteChildren: OnboardRouteChildren = {
|
||||||
OnboardCompleteRoute: OnboardCompleteRoute,
|
OnboardCompleteRoute: OnboardCompleteRoute,
|
||||||
|
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
|
||||||
OnboardVerifyRoute: OnboardVerifyRoute,
|
OnboardVerifyRoute: OnboardVerifyRoute,
|
||||||
|
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
|
||||||
OnboardIndexRoute: OnboardIndexRoute,
|
OnboardIndexRoute: OnboardIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
function AuthCallbackPage() {
|
function AuthCallbackPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,10 +28,10 @@ function AuthCallbackPage() {
|
|||||||
navigate({ to: '/select-complex' });
|
navigate({ to: '/select-complex' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate({ to: '/' });
|
navigate({ to: '/onboard/create-complex' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
navigate({ to: '/login' });
|
navigate({ to: '/onboard' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,4 +47,4 @@ function AuthCallbackPage() {
|
|||||||
|
|
||||||
export const Route = createFileRoute('/auth-callback')({
|
export const Route = createFileRoute('/auth-callback')({
|
||||||
component: AuthCallbackPage,
|
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 { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
|
|
||||||
export const Route = createFileRoute('/onboard/')({
|
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,
|
||||||
|
});
|
||||||
3
bun.lock
3
bun.lock
@@ -69,6 +69,7 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"zustand": "^5.0.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
@@ -1467,6 +1468,8 @@
|
|||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
|
|
||||||
|
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
"@dotenvx/dotenvx/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user