Merge pull request 'select-complex' (#6) from select-complex into development
Reviewed-on: https://gitea.2pidev.com/jselesan/playzer/pulls/6
This commit is contained in:
38
AGENTS.md
38
AGENTS.md
@@ -111,3 +111,41 @@ To enable login with Google:
|
|||||||
3. The backend configures `socialProviders.google` automatically.
|
3. The backend configures `socialProviders.google` automatically.
|
||||||
|
|
||||||
4. Frontend uses `authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' })`.
|
4. Frontend uses `authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' })`.
|
||||||
|
|
||||||
|
## Complex Selection (Multi-tenancy)
|
||||||
|
|
||||||
|
The app supports users with multiple complexes. Each user has a role per complex (`ADMIN` or `EMPLOYEE`), stored in `ComplexUser` table.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. **Login** (password or Google OAuth):
|
||||||
|
- After auth, call `GET /api/complexes/mine` to get user's complexes
|
||||||
|
- If 1 complex → auto-select via `POST /api/complexes/select` → redirect to `/`
|
||||||
|
- If >1 complexes → redirect to `/select-complex`
|
||||||
|
|
||||||
|
2. **Select Complex Page** (`/select-complex`):
|
||||||
|
- List all complexes with roles
|
||||||
|
- User selects one → `POST /api/complexes/select` → set cookie → redirect to `/`
|
||||||
|
|
||||||
|
3. **Home Page** (`/`):
|
||||||
|
- If no cookie (first visit) → auto-select if 1 complex, else redirect
|
||||||
|
- Use `GET /api/complexes/me` to get current selected complex
|
||||||
|
|
||||||
|
### Cookie
|
||||||
|
|
||||||
|
- Name: `selected-complex-id`
|
||||||
|
- httpOnly, secure (prod), sameSite: strict
|
||||||
|
- MaxAge: 30 days
|
||||||
|
- Set by `POST /api/complexes/select`
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/complexes/mine` | List all complexes with role |
|
||||||
|
| GET | `/api/complexes/me` | Get currently selected complex |
|
||||||
|
| POST | `/api/complexes/select` | Select complex (sets cookie) |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,9 @@ import { requireAuth } from '@/middlewares/require-auth.middleware';
|
|||||||
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
|
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
|
||||||
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
|
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
|
||||||
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
|
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
|
||||||
|
import { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler';
|
||||||
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
|
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
|
||||||
|
import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler';
|
||||||
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
|
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
|
||||||
import type { AppEnv } from '@/types/hono';
|
import type { AppEnv } from '@/types/hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
@@ -17,6 +19,8 @@ const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) });
|
|||||||
complexRoutes.use('*', requireAuth);
|
complexRoutes.use('*', requireAuth);
|
||||||
|
|
||||||
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
|
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
|
||||||
|
complexRoutes.get('/me', getCurrentComplexHandler);
|
||||||
|
complexRoutes.post('/select', selectComplexHandler);
|
||||||
complexRoutes.get('/mine', listMyComplexesHandler);
|
complexRoutes.get('/mine', listMyComplexesHandler);
|
||||||
complexRoutes.get(
|
complexRoutes.get(
|
||||||
'/slug/:slug',
|
'/slug/:slug',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getCurrentComplex } from '@/modules/complex/services/complex.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import { getCookie } from 'hono/cookie';
|
||||||
|
|
||||||
|
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
|
||||||
|
|
||||||
|
export async function getCurrentComplexHandler(c: AppContext) {
|
||||||
|
const user = c.get('user');
|
||||||
|
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
|
||||||
|
|
||||||
|
if (!complexId) {
|
||||||
|
return c.json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const complex = await getCurrentComplex(user.id, complexId);
|
||||||
|
|
||||||
|
if (!complex) {
|
||||||
|
return c.json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(complex);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { selectComplex } from '@/modules/complex/services/complex.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import { setCookie } from 'hono/cookie';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
const selectComplexSchema = z.object({
|
||||||
|
complexId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function selectComplexHandler(c: AppContext) {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const result = selectComplexSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ message: 'Invalid payload', issues: result.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { complexId } = result.data;
|
||||||
|
const complex = await selectComplex(user.id, complexId);
|
||||||
|
|
||||||
|
if (!complex) {
|
||||||
|
return c.json({ message: 'Complex not found or not associated with user' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(c, SELECTED_COMPLEX_COOKIE, complexId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/',
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(complex);
|
||||||
|
}
|
||||||
@@ -104,7 +104,52 @@ export async function listMyComplexes(userId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return complexUsers.map((complexUser) => complexUser.complex);
|
return complexUsers.map((complexUser) => ({
|
||||||
|
...complexUser.complex,
|
||||||
|
role: complexUser.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentComplex(userId: string, complexId: string) {
|
||||||
|
const complexUser = await db.complexUser.findUnique({
|
||||||
|
where: {
|
||||||
|
complexId_userId: {
|
||||||
|
complexId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
complex: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!complexUser) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...complexUser.complex,
|
||||||
|
role: complexUser.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectComplex(userId: string, complexId: string) {
|
||||||
|
const complexUser = await db.complexUser.findUnique({
|
||||||
|
where: {
|
||||||
|
complexId_userId: {
|
||||||
|
complexId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
complex: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!complexUser) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...complexUser.complex,
|
||||||
|
role: complexUser.role,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateComplex(id: string, input: UpdateComplexInput) {
|
export async function updateComplex(id: string, input: UpdateComplexInput) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { getUserProfile } from '@/modules/user/services/user.service';
|
import { getUserProfile } from '@/modules/user/services/user.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import { getCookie } from 'hono/cookie';
|
||||||
|
|
||||||
export function getUserProfileHandler(c: AppContext) {
|
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
|
||||||
|
|
||||||
|
export async function getUserProfileHandler(c: AppContext) {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const profile = getUserProfile(user);
|
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
|
||||||
|
const profile = await getUserProfile(user, complexId);
|
||||||
return c.json(profile);
|
return c.json(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type { User } from '@/lib/auth';
|
import type { User } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/prisma';
|
||||||
import type { UserProfile } from '@repo/api-contract';
|
import type { UserProfile } from '@repo/api-contract';
|
||||||
|
|
||||||
function getGravatarUrl(email: string): string {
|
function getGravatarUrl(email: string): string {
|
||||||
@@ -7,12 +8,29 @@ function getGravatarUrl(email: string): string {
|
|||||||
return `https://www.gravatar.com/avatar/${hash}?d=identicon`;
|
return `https://www.gravatar.com/avatar/${hash}?d=identicon`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserProfile(user: User): UserProfile {
|
export async function getUserProfile(user: User, complexId?: string): Promise<UserProfile> {
|
||||||
|
let role = 'member';
|
||||||
|
|
||||||
|
if (complexId) {
|
||||||
|
const complexUser = await db.complexUser.findUnique({
|
||||||
|
where: {
|
||||||
|
complexId_userId: {
|
||||||
|
complexId,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (complexUser) {
|
||||||
|
role = complexUser.role.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
fullName: user.name,
|
fullName: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: (user as any).role || 'member',
|
role,
|
||||||
avatarUrl: user.image || getGravatarUrl(user.email),
|
avatarUrl: user.image || getGravatarUrl(user.email),
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,13 +20,10 @@ 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 {
|
|
||||||
getCurrentComplexSlug,
|
|
||||||
setCurrentComplexSlug as persistCurrentComplexSlug,
|
|
||||||
} from '@/lib/current-complex';
|
|
||||||
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';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Check, Copy } from 'lucide-react';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -123,9 +120,9 @@ function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
||||||
|
|
||||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
|
|
||||||
const [fromDate, setFromDate] = useState(todayIso);
|
const [fromDate, setFromDate] = useState(todayIso);
|
||||||
const [manualDate, setManualDate] = useState(todayIso);
|
const [manualDate, setManualDate] = useState(todayIso);
|
||||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
|
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
|
||||||
@@ -134,35 +131,32 @@ export function HomePage() {
|
|||||||
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
|
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
|
||||||
const [urlCopied, setUrlCopied] = useState(false);
|
const [urlCopied, setUrlCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const currentComplexQuery = useQuery({
|
||||||
setCurrentComplexSlug(getCurrentComplexSlug());
|
queryKey: ['current-complex'],
|
||||||
}, []);
|
queryFn: () => apiClient.complexes.getCurrent(),
|
||||||
|
});
|
||||||
|
|
||||||
const myComplexesQuery = useQuery({
|
const myComplexesQuery = useQuery({
|
||||||
queryKey: ['my-complexes'],
|
queryKey: ['my-complexes'],
|
||||||
queryFn: () => apiClient.complexes.listMine(),
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedComplex = useMemo(() => {
|
useEffect(() => {
|
||||||
const complexes = myComplexesQuery.data ?? [];
|
async function autoSelect() {
|
||||||
|
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||||
if (complexes.length === 0) return null;
|
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
||||||
if (currentComplexSlug) {
|
} else if (myComplexesQuery.data && myComplexesQuery.data.length > 1 && !currentComplexQuery.data) {
|
||||||
const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
|
navigate({ to: '/select-complex' });
|
||||||
|
}
|
||||||
if (match) return match;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return complexes[0];
|
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
||||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
autoSelect();
|
||||||
|
}
|
||||||
|
}, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedComplex = currentComplexQuery.data ?? null;
|
||||||
if (!selectedComplex) return;
|
|
||||||
|
|
||||||
setCurrentComplexSlug(selectedComplex.complexSlug);
|
|
||||||
persistCurrentComplexSlug(selectedComplex.complexSlug);
|
|
||||||
}, [selectedComplex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedComplex?.id) return;
|
if (!selectedComplex?.id) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { authClient } from '@/lib/api-client';
|
import { apiClient, authClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
@@ -38,12 +38,30 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handlePostLogin() {
|
||||||
|
const complexes = await apiClient.complexes.listMine();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: LoginForm) => {
|
const onSubmit = async (values: LoginForm) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signInWithPassword(values);
|
await signInWithPassword(values);
|
||||||
await navigate({ to: redirectTo });
|
await handlePostLogin();
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
||||||
}
|
}
|
||||||
@@ -55,9 +73,7 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
try {
|
try {
|
||||||
await authClient.signIn.social({
|
await authClient.signIn.social({
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
callbackURL: redirectTo.startsWith('http')
|
callbackURL: `${window.location.origin}/auth-callback`,
|
||||||
? redirectTo
|
|
||||||
: `${window.location.origin}${redirectTo}`,
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError('No pudimos iniciar sesión con Google.');
|
setSubmitError('No pudimos iniciar sesión con Google.');
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { Building2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function SelectComplexPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const complexesQuery = useQuery({
|
||||||
|
queryKey: ['my-complexes'],
|
||||||
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectMutation = useMutation({
|
||||||
|
mutationFn: async (complexId: string) => {
|
||||||
|
const response = await apiClient.complexes.select({ complexId });
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelect = (complex: ComplexWithRole) => {
|
||||||
|
selectMutation.mutate(complex.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (complexesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complexesQuery.isError) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">Error al cargar tus complejos.</p>
|
||||||
|
<Button variant="link" className="mt-2" onClick={() => complexesQuery.refetch()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const complexes = complexesQuery.data ?? [];
|
||||||
|
|
||||||
|
if (complexes.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">No tenés complejos asociados.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
|
<section className="w-full max-w-md space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Elegí el complejo con el que vas a trabajar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{complexes.map((complex) => (
|
||||||
|
<button
|
||||||
|
key={complex.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent"
|
||||||
|
onClick={() => handleSelect(complex)}
|
||||||
|
disabled={selectMutation.isPending}
|
||||||
|
>
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{complex.complexName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{complex.city ?? 'Sin ciudad'} · {complex.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectMutation.isPending && selectMutation.variables === complex.id && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,7 @@
|
|||||||
import type {
|
|
||||||
AdminBooking,
|
|
||||||
Complex,
|
|
||||||
Court,
|
|
||||||
CreateAdminBookingInput,
|
|
||||||
CreateComplexPayload,
|
|
||||||
CreateComplexResponse,
|
|
||||||
CreateCourtInput,
|
|
||||||
CreatePublicBookingInput,
|
|
||||||
CreateSportInput,
|
|
||||||
OnboardingCompleteInput,
|
|
||||||
OnboardingCompleteResponse,
|
|
||||||
OnboardingResendOtpInput,
|
|
||||||
OnboardingResendOtpResponse,
|
|
||||||
OnboardingStartInput,
|
|
||||||
OnboardingStartResponse,
|
|
||||||
OnboardingVerifyOtpInput,
|
|
||||||
OnboardingVerifyOtpResponse,
|
|
||||||
PlanSummary,
|
|
||||||
PublicAvailabilityResponse,
|
|
||||||
PublicBooking,
|
|
||||||
PublicBookingConfirmation,
|
|
||||||
Sport,
|
|
||||||
UpdateAdminBookingStatusInput,
|
|
||||||
UpdateComplexPayload,
|
|
||||||
UpdateComplexResponse,
|
|
||||||
UpdateCourtInput,
|
|
||||||
UpdateSportInput,
|
|
||||||
UserProfileResponse,
|
|
||||||
} from '@repo/api-contract';
|
|
||||||
import axios, { AxiosError } from 'axios';
|
|
||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
import * as api from './api';
|
||||||
|
|
||||||
const apiBaseUrl =
|
const apiBaseUrl = api.apiBaseUrl;
|
||||||
import.meta.env.VITE_API_BASE_URL?.trim() ||
|
|
||||||
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin);
|
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: apiBaseUrl,
|
baseURL: apiBaseUrl,
|
||||||
@@ -59,235 +27,30 @@ export class ApiClientError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorHandlers = {
|
|
||||||
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
|
||||||
onError?: (error: ApiClientError) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlers: ErrorHandlers = {};
|
|
||||||
|
|
||||||
const http = axios.create({
|
|
||||||
baseURL: apiBaseUrl,
|
|
||||||
withCredentials: true,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function extractMessage(details: unknown, fallback: string) {
|
|
||||||
if (
|
|
||||||
typeof details === 'object' &&
|
|
||||||
details &&
|
|
||||||
'message' in details &&
|
|
||||||
typeof details.message === 'string'
|
|
||||||
) {
|
|
||||||
return details.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeError(error: unknown): ApiClientError {
|
|
||||||
if (error instanceof ApiClientError) return error;
|
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
const status = error.response?.status;
|
|
||||||
const details = error.response?.data;
|
|
||||||
|
|
||||||
return new ApiClientError(extractMessage(details, error.message || 'Request failed'), {
|
|
||||||
status,
|
|
||||||
details,
|
|
||||||
causeError: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return new ApiClientError(error.message, {
|
|
||||||
causeError: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ApiClientError('Error inesperado.', {
|
|
||||||
causeError: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
http.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
async (error: AxiosError) => {
|
|
||||||
const normalized = normalizeError(error);
|
|
||||||
|
|
||||||
if (normalized.status === 403 && handlers.onForbidden) {
|
|
||||||
await handlers.onForbidden(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handlers.onError) {
|
|
||||||
await handlers.onError(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(normalized);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
onboarding: {
|
onboarding: api.onboarding,
|
||||||
start: async (payload: OnboardingStartInput) => {
|
plans: api.plans,
|
||||||
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
|
user: api.user,
|
||||||
return response.data;
|
complexes: api.complexes,
|
||||||
},
|
sports: api.sports,
|
||||||
verifyOtp: async (payload: OnboardingVerifyOtpInput) => {
|
courts: api.courts,
|
||||||
const response = await http.post<OnboardingVerifyOtpResponse>(
|
|
||||||
'/api/onboarding/verify-otp',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
resendOtp: async (payload: OnboardingResendOtpInput) => {
|
|
||||||
const response = await http.post<OnboardingResendOtpResponse>(
|
|
||||||
'/api/onboarding/resend-otp',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
complete: async (payload: OnboardingCompleteInput) => {
|
|
||||||
const response = await http.post<OnboardingCompleteResponse>(
|
|
||||||
'/api/onboarding/complete',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plans: {
|
|
||||||
list: async () => {
|
|
||||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
getProfile: async () => {
|
|
||||||
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
complexes: {
|
|
||||||
create: async (payload: CreateComplexPayload) => {
|
|
||||||
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
getById: async (id: string) => {
|
|
||||||
const response = await http.get<Complex>(`/api/complexes/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
getBySlug: async (slug: string) => {
|
|
||||||
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
update: async (id: string, payload: UpdateComplexPayload) => {
|
|
||||||
const response = await http.patch<UpdateComplexResponse>(`/api/complexes/${id}`, payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
listMine: async () => {
|
|
||||||
const response = await http.get<Complex[]>('/api/complexes/mine');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sports: {
|
|
||||||
list: async () => {
|
|
||||||
const response = await http.get<Sport[]>('/api/sports');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
create: async (payload: CreateSportInput) => {
|
|
||||||
const response = await http.post<Sport>('/api/sports', payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
update: async (id: string, payload: UpdateSportInput) => {
|
|
||||||
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
courts: {
|
|
||||||
listByComplex: async (complexId: string) => {
|
|
||||||
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
create: async (complexId: string, payload: CreateCourtInput) => {
|
|
||||||
const response = await http.post<Court>(`/api/courts/complex/${complexId}`, payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
update: async (id: string, payload: UpdateCourtInput) => {
|
|
||||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
publicBookings: {
|
publicBookings: {
|
||||||
getAvailability: async (
|
getAvailability: api.getAvailability,
|
||||||
complexSlug: string,
|
create: api.createPublic,
|
||||||
params: {
|
getConfirmation: api.getConfirmation,
|
||||||
date: string;
|
|
||||||
sportId?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const response = await http.get<PublicAvailabilityResponse>(
|
|
||||||
`/api/public-bookings/complex/${complexSlug}/availability`,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
create: async (complexSlug: string, payload: CreatePublicBookingInput) => {
|
|
||||||
const response = await http.post<PublicBooking>(
|
|
||||||
`/api/public-bookings/complex/${complexSlug}`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
getConfirmation: async (complexSlug: string, bookingCode: string) => {
|
|
||||||
const response = await http.get<PublicBookingConfirmation>(
|
|
||||||
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
adminBookings: {
|
adminBookings: {
|
||||||
listByComplex: async (
|
listByComplex: api.listByComplex,
|
||||||
complexId: string,
|
create: api.createAdmin,
|
||||||
params: {
|
updateStatus: api.updateStatus,
|
||||||
fromDate: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const response = await http.get<{ bookings: AdminBooking[] }>(
|
|
||||||
`/api/admin-bookings/complex/${complexId}`,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
create: async (complexId: string, payload: CreateAdminBookingInput) => {
|
|
||||||
const response = await http.post<AdminBooking>(
|
|
||||||
`/api/admin-bookings/complex/${complexId}`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
updateStatus: async (bookingId: string, payload: UpdateAdminBookingStatusInput) => {
|
|
||||||
const response = await http.patch<AdminBooking>(
|
|
||||||
`/api/admin-bookings/${bookingId}/status`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiClient = typeof apiClient;
|
export type { ErrorHandlers } from './api';
|
||||||
|
export { configureApiClient } from './api';
|
||||||
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
|
||||||
handlers.onForbidden = nextHandlers.onForbidden;
|
|
||||||
handlers.onError = nextHandlers.onError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setApiAccessToken(_token: string | null) {
|
export function setApiAccessToken(_token: string | null) {
|
||||||
// accessToken = token;
|
// accessToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiClient = typeof apiClient;
|
||||||
34
apps/frontend/src/lib/api/base.ts
Normal file
34
apps/frontend/src/lib/api/base.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export class ApiClientError extends Error {
|
||||||
|
status?: number;
|
||||||
|
details?: unknown;
|
||||||
|
causeError?: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
options?: { status?: number; details?: unknown; causeError?: unknown }
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiClientError';
|
||||||
|
this.status = options?.status;
|
||||||
|
this.details = options?.details;
|
||||||
|
this.causeError = options?.causeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorHandlers = {
|
||||||
|
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
||||||
|
onError?: (error: ApiClientError) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers: ErrorHandlers = {};
|
||||||
|
|
||||||
|
export const apiBaseUrl =
|
||||||
|
import.meta.env.VITE_API_BASE_URL?.trim() ||
|
||||||
|
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin);
|
||||||
|
|
||||||
|
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||||
|
handlers.onForbidden = nextHandlers.onForbidden;
|
||||||
|
handlers.onError = nextHandlers.onError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handlers };
|
||||||
60
apps/frontend/src/lib/api/http.ts
Normal file
60
apps/frontend/src/lib/api/http.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
import { ApiClientError, apiBaseUrl, handlers } from './base';
|
||||||
|
|
||||||
|
export const http: AxiosInstance = axios.create({
|
||||||
|
baseURL: apiBaseUrl,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractMessage(details: unknown, fallback: string): string {
|
||||||
|
if (
|
||||||
|
typeof details === 'object' &&
|
||||||
|
details &&
|
||||||
|
'message' in details &&
|
||||||
|
typeof details.message === 'string'
|
||||||
|
) {
|
||||||
|
return details.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(error: unknown): ApiClientError {
|
||||||
|
if (error instanceof ApiClientError) return error;
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const details = error.response?.data;
|
||||||
|
|
||||||
|
return new ApiClientError(extractMessage(details, error.message || 'Request failed'), {
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
causeError: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new ApiClientError(error.message, { causeError: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiClientError('Error inesperado.', { causeError: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const normalized = normalizeError(error);
|
||||||
|
|
||||||
|
if (normalized.status === 403 && handlers.onForbidden) {
|
||||||
|
await handlers.onForbidden(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onError) {
|
||||||
|
await handlers.onError(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(normalized);
|
||||||
|
}
|
||||||
|
);
|
||||||
18
apps/frontend/src/lib/api/index.ts
Normal file
18
apps/frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export { ApiClientError, configureApiClient, apiBaseUrl, type ErrorHandlers } from './base';
|
||||||
|
export { http } from './http';
|
||||||
|
|
||||||
|
export * as complexes from './resources/complexes';
|
||||||
|
export * as courts from './resources/courts';
|
||||||
|
export * as user from './resources/user';
|
||||||
|
export * as sports from './resources/sports';
|
||||||
|
export * as plans from './resources/plans';
|
||||||
|
export * as onboarding from './resources/onboarding';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAvailability,
|
||||||
|
createPublic,
|
||||||
|
getConfirmation,
|
||||||
|
listByComplex,
|
||||||
|
createAdmin,
|
||||||
|
updateStatus,
|
||||||
|
} from './resources/bookings';
|
||||||
60
apps/frontend/src/lib/api/resources/bookings.ts
Normal file
60
apps/frontend/src/lib/api/resources/bookings.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
AdminBooking,
|
||||||
|
CreateAdminBookingInput,
|
||||||
|
CreatePublicBookingInput,
|
||||||
|
PublicAvailabilityResponse,
|
||||||
|
PublicBooking,
|
||||||
|
PublicBookingConfirmation,
|
||||||
|
UpdateAdminBookingStatusInput,
|
||||||
|
} from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function getAvailability(
|
||||||
|
complexSlug: string,
|
||||||
|
params: { date: string; sportId?: string }
|
||||||
|
) {
|
||||||
|
const response = await http.get<PublicAvailabilityResponse>(
|
||||||
|
`/api/public-bookings/complex/${complexSlug}/availability`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPublic(complexSlug: string, payload: CreatePublicBookingInput) {
|
||||||
|
const response = await http.post<PublicBooking>(
|
||||||
|
`/api/public-bookings/complex/${complexSlug}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfirmation(complexSlug: string, bookingCode: string) {
|
||||||
|
const response = await http.get<PublicBookingConfirmation>(
|
||||||
|
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listByComplex(complexId: string, params: { fromDate: string }) {
|
||||||
|
const response = await http.get<{ bookings: AdminBooking[] }>(
|
||||||
|
`/api/admin-bookings/complex/${complexId}`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdmin(complexId: string, payload: CreateAdminBookingInput) {
|
||||||
|
const response = await http.post<AdminBooking>(
|
||||||
|
`/api/admin-bookings/complex/${complexId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
||||||
|
const response = await http.patch<AdminBooking>(
|
||||||
|
`/api/admin-bookings/${bookingId}/status`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
49
apps/frontend/src/lib/api/resources/complexes.ts
Normal file
49
apps/frontend/src/lib/api/resources/complexes.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
Complex,
|
||||||
|
ComplexWithRole,
|
||||||
|
CreateComplexPayload,
|
||||||
|
CreateComplexResponse,
|
||||||
|
SelectComplexInput,
|
||||||
|
UpdateComplexPayload,
|
||||||
|
UpdateComplexResponse,
|
||||||
|
} from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function create(payload: CreateComplexPayload) {
|
||||||
|
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getById(id: string) {
|
||||||
|
const response = await http.get<Complex>(`/api/complexes/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBySlug(slug: string) {
|
||||||
|
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(id: string, payload: UpdateComplexPayload) {
|
||||||
|
const response = await http.patch<UpdateComplexResponse>(`/api/complexes/${id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMine() {
|
||||||
|
const response = await http.get<ComplexWithRole[]>('/api/complexes/mine');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrent() {
|
||||||
|
const response = await http.get<ComplexWithRole | null>('/api/complexes/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function select(payload: SelectComplexInput) {
|
||||||
|
const response = await http.post<ComplexWithRole>(
|
||||||
|
'/api/complexes/select',
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
17
apps/frontend/src/lib/api/resources/courts.ts
Normal file
17
apps/frontend/src/lib/api/resources/courts.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Court, CreateCourtInput, UpdateCourtInput } from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function listByComplex(complexId: string) {
|
||||||
|
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(complexId: string, payload: CreateCourtInput) {
|
||||||
|
const response = await http.post<Court>(`/api/courts/complex/${complexId}`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(id: string, payload: UpdateCourtInput) {
|
||||||
|
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
40
apps/frontend/src/lib/api/resources/onboarding.ts
Normal file
40
apps/frontend/src/lib/api/resources/onboarding.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type {
|
||||||
|
OnboardingCompleteInput,
|
||||||
|
OnboardingCompleteResponse,
|
||||||
|
OnboardingResendOtpInput,
|
||||||
|
OnboardingResendOtpResponse,
|
||||||
|
OnboardingStartInput,
|
||||||
|
OnboardingStartResponse,
|
||||||
|
OnboardingVerifyOtpInput,
|
||||||
|
OnboardingVerifyOtpResponse,
|
||||||
|
} from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function start(payload: OnboardingStartInput) {
|
||||||
|
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOtp(payload: OnboardingVerifyOtpInput) {
|
||||||
|
const response = await http.post<OnboardingVerifyOtpResponse>(
|
||||||
|
'/api/onboarding/verify-otp',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendOtp(payload: OnboardingResendOtpInput) {
|
||||||
|
const response = await http.post<OnboardingResendOtpResponse>(
|
||||||
|
'/api/onboarding/resend-otp',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function complete(payload: OnboardingCompleteInput) {
|
||||||
|
const response = await http.post<OnboardingCompleteResponse>(
|
||||||
|
'/api/onboarding/complete',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
7
apps/frontend/src/lib/api/resources/plans.ts
Normal file
7
apps/frontend/src/lib/api/resources/plans.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PlanSummary } from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
const response = await http.get<PlanSummary[]>('/api/plans');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
17
apps/frontend/src/lib/api/resources/sports.ts
Normal file
17
apps/frontend/src/lib/api/resources/sports.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CreateSportInput, Sport, UpdateSportInput } from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
const response = await http.get<Sport[]>('/api/sports');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(payload: CreateSportInput) {
|
||||||
|
const response = await http.post<Sport>('/api/sports', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(id: string, payload: UpdateSportInput) {
|
||||||
|
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
7
apps/frontend/src/lib/api/resources/user.ts
Normal file
7
apps/frontend/src/lib/api/resources/user.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { UserProfileResponse } from '@repo/api-contract';
|
||||||
|
import { http } from '../http';
|
||||||
|
|
||||||
|
export async function getProfile() {
|
||||||
|
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as SelectComplexRouteImport } from './routes/select-complex'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
import { Route as OnboardRouteImport } from './routes/onboard'
|
import { Route as OnboardRouteImport } from './routes/onboard'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
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 AppRouteRouteImport } from './routes/_app/route'
|
||||||
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
||||||
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
||||||
@@ -25,6 +27,11 @@ import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSl
|
|||||||
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
||||||
|
|
||||||
|
const SelectComplexRoute = SelectComplexRouteImport.update({
|
||||||
|
id: '/select-complex',
|
||||||
|
path: '/select-complex',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||||
id: '/reset-password',
|
id: '/reset-password',
|
||||||
path: '/reset-password',
|
path: '/reset-password',
|
||||||
@@ -40,6 +47,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||||
|
id: '/auth-callback',
|
||||||
|
path: '/auth-callback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AppRouteRoute = AppRouteRouteImport.update({
|
const AppRouteRoute = AppRouteRouteImport.update({
|
||||||
id: '/_app',
|
id: '/_app',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@@ -103,9 +115,11 @@ const AppAuthenticatedComplexSlugEditRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AppAuthenticatedIndexRoute
|
'/': typeof AppAuthenticatedIndexRoute
|
||||||
|
'/auth-callback': typeof AuthCallbackRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/onboard': typeof OnboardRouteWithChildren
|
'/onboard': typeof OnboardRouteWithChildren
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/about': typeof AppAboutRoute
|
'/about': typeof AppAboutRoute
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
@@ -118,8 +132,10 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof AppAuthenticatedIndexRoute
|
'/': typeof AppAuthenticatedIndexRoute
|
||||||
|
'/auth-callback': typeof AuthCallbackRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/about': typeof AppAboutRoute
|
'/about': typeof AppAboutRoute
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/complete': typeof OnboardCompleteRoute
|
||||||
@@ -132,9 +148,11 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_app': typeof AppRouteRouteWithChildren
|
'/_app': typeof AppRouteRouteWithChildren
|
||||||
|
'/auth-callback': typeof AuthCallbackRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/onboard': typeof OnboardRouteWithChildren
|
'/onboard': typeof OnboardRouteWithChildren
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/_app/about': typeof AppAboutRoute
|
'/_app/about': typeof AppAboutRoute
|
||||||
@@ -151,9 +169,11 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/auth-callback'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/onboard'
|
| '/onboard'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
|
| '/select-complex'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
@@ -166,8 +186,10 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/auth-callback'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
|
| '/select-complex'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/complete'
|
||||||
@@ -179,9 +201,11 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_app'
|
| '/_app'
|
||||||
|
| '/auth-callback'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/onboard'
|
| '/onboard'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
|
| '/select-complex'
|
||||||
| '/_app/_authenticated'
|
| '/_app/_authenticated'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/_app/about'
|
| '/_app/about'
|
||||||
@@ -197,14 +221,23 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
AppRouteRoute: typeof AppRouteRouteWithChildren
|
AppRouteRoute: typeof AppRouteRouteWithChildren
|
||||||
|
AuthCallbackRoute: typeof AuthCallbackRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
OnboardRoute: typeof OnboardRouteWithChildren
|
OnboardRoute: typeof OnboardRouteWithChildren
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
|
SelectComplexRoute: typeof SelectComplexRoute
|
||||||
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
|
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/select-complex': {
|
||||||
|
id: '/select-complex'
|
||||||
|
path: '/select-complex'
|
||||||
|
fullPath: '/select-complex'
|
||||||
|
preLoaderRoute: typeof SelectComplexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/reset-password': {
|
'/reset-password': {
|
||||||
id: '/reset-password'
|
id: '/reset-password'
|
||||||
path: '/reset-password'
|
path: '/reset-password'
|
||||||
@@ -226,6 +259,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/auth-callback': {
|
||||||
|
id: '/auth-callback'
|
||||||
|
path: '/auth-callback'
|
||||||
|
fullPath: '/auth-callback'
|
||||||
|
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_app': {
|
'/_app': {
|
||||||
id: '/_app'
|
id: '/_app'
|
||||||
path: ''
|
path: ''
|
||||||
@@ -375,9 +415,11 @@ const ComplexSlugBookingRouteWithChildren =
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
AppRouteRoute: AppRouteRouteWithChildren,
|
AppRouteRoute: AppRouteRouteWithChildren,
|
||||||
|
AuthCallbackRoute: AuthCallbackRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
OnboardRoute: OnboardRouteWithChildren,
|
OnboardRoute: OnboardRouteWithChildren,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
|
SelectComplexRoute: SelectComplexRoute,
|
||||||
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
|
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
50
apps/frontend/src/routes/auth-callback.tsx
Normal file
50
apps/frontend/src/routes/auth-callback.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
function AuthCallbackPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function handleCallback() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const complexes = await apiClient.complexes.listMine();
|
||||||
|
|
||||||
|
if (complexes.length === 1) {
|
||||||
|
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||||
|
navigate({ to: '/' });
|
||||||
|
} else if (complexes.length > 1) {
|
||||||
|
const current = await apiClient.complexes.getCurrent();
|
||||||
|
if (current) {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
} else {
|
||||||
|
navigate({ to: '/select-complex' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Completando sesión...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/auth-callback')({
|
||||||
|
component: AuthCallbackPage,
|
||||||
|
});
|
||||||
6
apps/frontend/src/routes/select-complex.tsx
Normal file
6
apps/frontend/src/routes/select-complex.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SelectComplexPage } from '@/features/select-complex/select-complex-page';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/select-complex')({
|
||||||
|
component: SelectComplexPage,
|
||||||
|
});
|
||||||
@@ -113,6 +113,24 @@ export const updateComplexSchema = z
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const complexUserRoleSchema = z.enum(['ADMIN', 'EMPLOYEE'])
|
||||||
|
|
||||||
|
export type ComplexUserRole = z.infer<typeof complexUserRoleSchema>
|
||||||
|
|
||||||
|
export const selectComplexSchema = z.object({
|
||||||
|
complexId: z.string().uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SelectComplexInput = z.infer<typeof selectComplexSchema>
|
||||||
|
|
||||||
|
export const complexWithRoleSchema = complexSchema.merge(
|
||||||
|
z.object({
|
||||||
|
role: complexUserRoleSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ComplexWithRole = z.infer<typeof complexWithRoleSchema>
|
||||||
|
|
||||||
export type Complex = z.infer<typeof complexSchema>
|
export type Complex = z.infer<typeof complexSchema>
|
||||||
export type CreateComplexInput = z.infer<typeof createComplexSchema>
|
export type CreateComplexInput = z.infer<typeof createComplexSchema>
|
||||||
export type UpdateComplexInput = z.infer<typeof updateComplexSchema>
|
export type UpdateComplexInput = z.infer<typeof updateComplexSchema>
|
||||||
|
|||||||
@@ -26,12 +26,22 @@ export type {
|
|||||||
OnboardingVerifyOtpInput,
|
OnboardingVerifyOtpInput,
|
||||||
OnboardingVerifyOtpResponse,
|
OnboardingVerifyOtpResponse,
|
||||||
} from './onboarding'
|
} from './onboarding'
|
||||||
export { complexSchema, createComplexSchema, updateComplexSchema } from './complex'
|
export {
|
||||||
|
complexSchema,
|
||||||
|
complexUserRoleSchema,
|
||||||
|
complexWithRoleSchema,
|
||||||
|
createComplexSchema,
|
||||||
|
selectComplexSchema,
|
||||||
|
updateComplexSchema,
|
||||||
|
} from './complex'
|
||||||
export type {
|
export type {
|
||||||
Complex,
|
Complex,
|
||||||
|
ComplexUserRole,
|
||||||
|
ComplexWithRole,
|
||||||
CreateComplexInput,
|
CreateComplexInput,
|
||||||
CreateComplexPayload,
|
CreateComplexPayload,
|
||||||
CreateComplexResponse,
|
CreateComplexResponse,
|
||||||
|
SelectComplexInput,
|
||||||
UpdateComplexInput,
|
UpdateComplexInput,
|
||||||
UpdateComplexPayload,
|
UpdateComplexPayload,
|
||||||
UpdateComplexResponse,
|
UpdateComplexResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user