refactor(onboarding): restructure onboarding routes and components
- Removed obsolete onboarding routes: complete, create-complex, index, verify-email, and verify. - Introduced new setup stepper page for onboarding process. - Created a multi-step setup component to handle complex creation with validation. - Added new schemas for complex creation and plan features. - Implemented detailed steps for complex name, location, plan selection, and court setup. - Enhanced error handling and user feedback during the onboarding process.
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
model OnboardingRequest {
|
||||
id String @id @db.Uuid
|
||||
fullName String @map("full_name")
|
||||
email String
|
||||
otpHash String @map("otp_hash")
|
||||
otpExpiresAt DateTime @map("otp_expires_at")
|
||||
otpAttempts Int @default(0) @map("otp_attempts")
|
||||
otpLastSentAt DateTime @map("otp_last_sent_at")
|
||||
otpResendCount Int @default(0) @map("otp_resend_count")
|
||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([email])
|
||||
@@index([otpExpiresAt])
|
||||
@@map("onboarding_requests")
|
||||
}
|
||||
@@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) {
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
country: payload.country,
|
||||
setupCourts: payload.setupCourts,
|
||||
courtSportId: payload.courtSportId,
|
||||
courtStartTime: payload.courtStartTime,
|
||||
courtEndTime: payload.courtEndTime,
|
||||
courtDaysOfWeek: payload.courtDaysOfWeek,
|
||||
});
|
||||
|
||||
return c.json(complex, 201);
|
||||
|
||||
@@ -11,8 +11,47 @@ export type CreateComplexInput = {
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
setupCourts?: boolean;
|
||||
courtSportId?: string;
|
||||
courtStartTime?: string;
|
||||
courtEndTime?: string;
|
||||
courtDaysOfWeek?: string[];
|
||||
};
|
||||
|
||||
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
|
||||
|
||||
function toMinutes(value: string): number {
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function assertAvailabilityRanges(
|
||||
availability: Array<{
|
||||
dayOfWeek: DayOfWeek;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>
|
||||
) {
|
||||
for (const range of availability) {
|
||||
const start = toMinutes(range.startTime);
|
||||
const end = toMinutes(range.endTime);
|
||||
if (start >= end) {
|
||||
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureActiveSport(sportId: string) {
|
||||
const sport = await db.sport.findFirst({
|
||||
where: { id: sportId, isActive: true },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!sport) {
|
||||
throw new Error('El deporte seleccionado no existe o está inactivo.');
|
||||
}
|
||||
return sport;
|
||||
}
|
||||
|
||||
export type UpdateComplexInput = {
|
||||
complexName?: string;
|
||||
physicalAddress?: string | null;
|
||||
@@ -94,6 +133,38 @@ export async function createComplex(input: CreateComplexInput) {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.setupCourts && input.courtSportId) {
|
||||
const sport = await ensureActiveSport(input.courtSportId);
|
||||
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
|
||||
dayOfWeek: day as DayOfWeek,
|
||||
startTime: input.courtStartTime ?? '08:00',
|
||||
endTime: input.courtEndTime ?? '22:00',
|
||||
}));
|
||||
|
||||
assertAvailabilityRanges(availability);
|
||||
|
||||
const court = await tx.court.create({
|
||||
data: {
|
||||
id: uuidv7(),
|
||||
complexId: complex.id,
|
||||
sportId: input.courtSportId,
|
||||
name: `${sport.name} 1`,
|
||||
slotDurationMinutes: 60,
|
||||
basePrice: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.courtAvailability.createMany({
|
||||
data: availability.map((avail) => ({
|
||||
id: uuidv7(),
|
||||
courtId: court.id,
|
||||
dayOfWeek: avail.dayOfWeek,
|
||||
startTime: avail.startTime,
|
||||
endTime: avail.endTime,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return complex;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
OnboardingError,
|
||||
completeOnboarding,
|
||||
} from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingCompleteInput } from '@repo/api-contract';
|
||||
|
||||
export async function completeOnboardingHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
|
||||
|
||||
try {
|
||||
const result = await completeOnboarding(payload);
|
||||
return c.json({
|
||||
message: 'Onboarding completado correctamente.',
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardingError) {
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return c.json({ message: error.message }, 400);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
OnboardingError,
|
||||
resendOnboardingOtp,
|
||||
} from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingResendOtpInput } from '@repo/api-contract';
|
||||
|
||||
export async function resendOtpHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
|
||||
|
||||
try {
|
||||
const result = await resendOnboardingOtp(payload);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardingError) {
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return c.json({ message: error.message }, 400);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingStartInput } from '@repo/api-contract';
|
||||
|
||||
export async function startOnboardingHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingStartInput;
|
||||
|
||||
const result = await startOnboarding(payload);
|
||||
return c.json(result, 202);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
|
||||
|
||||
export async function verifyOtpHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
|
||||
const result = await verifyOnboardingOtp(payload);
|
||||
|
||||
return c.json(result);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
|
||||
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
|
||||
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
|
||||
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import {
|
||||
onboardingCompleteSchema,
|
||||
onboardingResendOtpSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export const onboardingRoutes = new Hono<AppEnv>();
|
||||
|
||||
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/verify-otp',
|
||||
zValidator('json', onboardingVerifyOtpSchema),
|
||||
verifyOtpHandler
|
||||
);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/resend-otp',
|
||||
zValidator('json', onboardingResendOtpSchema),
|
||||
resendOtpHandler
|
||||
);
|
||||
|
||||
onboardingRoutes.post(
|
||||
'/complete',
|
||||
zValidator('json', onboardingCompleteSchema),
|
||||
completeOnboardingHandler
|
||||
);
|
||||
@@ -1,415 +0,0 @@
|
||||
import { createHash, randomInt } from 'node:crypto';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { sendMail } from '@/lib/mailer';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type {
|
||||
OnboardingCompleteInput,
|
||||
OnboardingResendOtpInput,
|
||||
OnboardingStartInput,
|
||||
OnboardingVerifyOtpInput,
|
||||
} from '@repo/api-contract';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
|
||||
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
|
||||
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
|
||||
|
||||
type VerifyOtpResult = {
|
||||
message: string;
|
||||
verified: boolean;
|
||||
requestId: string;
|
||||
email: string | null;
|
||||
expiresAt: string | null;
|
||||
remainingAttempts: number;
|
||||
cooldownSeconds: number;
|
||||
};
|
||||
|
||||
type ResendOtpResult = {
|
||||
message: string;
|
||||
requestId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
cooldownSeconds: number;
|
||||
remainingAttempts: number;
|
||||
};
|
||||
|
||||
type StartOnboardingResult = {
|
||||
message: string;
|
||||
requestId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
cooldownSeconds: number;
|
||||
};
|
||||
|
||||
export class OnboardingError extends Error {
|
||||
status: 400 | 404 | 409 | 429;
|
||||
|
||||
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
|
||||
super(message);
|
||||
this.name = 'OnboardingError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function nowPlusMinutes(minutes: number): Date {
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
return date;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function createOtpCode(): string {
|
||||
return randomInt(0, 10 ** OTP_LENGTH)
|
||||
.toString()
|
||||
.padStart(OTP_LENGTH, '0');
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function getRemainingAttempts(otpAttempts: number): number {
|
||||
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
|
||||
}
|
||||
|
||||
function getCooldownSeconds(otpLastSentAt: Date): number {
|
||||
const elapsedMs = Date.now() - otpLastSentAt.getTime();
|
||||
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
|
||||
return Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
|
||||
const base = slugify(complexName);
|
||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
|
||||
|
||||
let candidate = fallback;
|
||||
let index = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await db.complex.findFirst({
|
||||
where: { complexSlug: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
candidate = `${fallback}-${index}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendOtpEmail(email: string, otpCode: string) {
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Codigo OTP para validar tu email',
|
||||
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
|
||||
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const otpCode = createOtpCode();
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||
const now = new Date();
|
||||
|
||||
const request = await db.onboardingRequest.create({
|
||||
data: {
|
||||
id: uuidv7(),
|
||||
fullName: input.fullName.trim(),
|
||||
email,
|
||||
otpHash: hashValue(otpCode),
|
||||
otpExpiresAt: expiresAt,
|
||||
otpAttempts: 0,
|
||||
otpLastSentAt: now,
|
||||
otpResendCount: 0,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
otpExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendOtpEmail(email, otpCode);
|
||||
|
||||
return {
|
||||
message: 'Te enviamos un codigo OTP para validar tu direccion.',
|
||||
requestId: request.id,
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyOnboardingOtp(
|
||||
input: OnboardingVerifyOtpInput
|
||||
): Promise<VerifyOtpResult> {
|
||||
const request = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.requestId },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
message: 'Solicitud de onboarding invalida o expirada.',
|
||||
verified: false,
|
||||
requestId: input.requestId,
|
||||
email: null,
|
||||
expiresAt: null,
|
||||
remainingAttempts: 0,
|
||||
cooldownSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
return {
|
||||
message: 'Este onboarding ya fue completado.',
|
||||
verified: true,
|
||||
requestId: request.id,
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.otpExpiresAt < new Date()) {
|
||||
return {
|
||||
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
|
||||
verified: false,
|
||||
requestId: request.id,
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
|
||||
return {
|
||||
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||
verified: false,
|
||||
requestId: request.id,
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: 0,
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
const otpMatches = hashValue(input.otp) === request.otpHash;
|
||||
|
||||
if (!otpMatches) {
|
||||
const updated = await db.onboardingRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
otpAttempts: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
otpAttempts: true,
|
||||
otpExpiresAt: true,
|
||||
otpLastSentAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
|
||||
|
||||
return {
|
||||
message:
|
||||
remainingAttempts > 0
|
||||
? 'Codigo OTP invalido.'
|
||||
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||
verified: false,
|
||||
requestId: updated.id,
|
||||
email: updated.email,
|
||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||
remainingAttempts,
|
||||
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.emailVerifiedAt) {
|
||||
await db.onboardingRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Email verificado correctamente.',
|
||||
verified: true,
|
||||
requestId: request.id,
|
||||
email: request.email,
|
||||
expiresAt: request.otpExpiresAt.toISOString(),
|
||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resendOnboardingOtp(
|
||||
input: OnboardingResendOtpInput
|
||||
): Promise<ResendOtpResult> {
|
||||
const request = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.requestId },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||
}
|
||||
|
||||
if (request.emailVerifiedAt) {
|
||||
throw new OnboardingError('El email ya fue verificado.', 400);
|
||||
}
|
||||
|
||||
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
|
||||
if (cooldownSeconds > 0) {
|
||||
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
|
||||
}
|
||||
|
||||
const otpCode = createOtpCode();
|
||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||
const now = new Date();
|
||||
|
||||
const updated = await db.onboardingRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
otpHash: hashValue(otpCode),
|
||||
otpExpiresAt: expiresAt,
|
||||
otpAttempts: 0,
|
||||
otpLastSentAt: now,
|
||||
otpResendCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
otpExpiresAt: true,
|
||||
otpAttempts: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendOtpEmail(updated.email, otpCode);
|
||||
|
||||
return {
|
||||
message: 'Te enviamos un nuevo codigo OTP.',
|
||||
requestId: updated.id,
|
||||
email: updated.email,
|
||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||
const onboardingRequest = await db.onboardingRequest.findUnique({
|
||||
where: { id: input.onboardingRequestId },
|
||||
});
|
||||
|
||||
if (!onboardingRequest) {
|
||||
throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400);
|
||||
}
|
||||
|
||||
if (onboardingRequest.otpExpiresAt < new Date()) {
|
||||
throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400);
|
||||
}
|
||||
|
||||
if (!onboardingRequest.emailVerifiedAt) {
|
||||
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
|
||||
}
|
||||
|
||||
if (onboardingRequest.completedAt) {
|
||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||
}
|
||||
|
||||
const plan = await db.plan.findUnique({
|
||||
where: { code: input.planCode },
|
||||
select: { code: true },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new OnboardingError('El plan seleccionado no existe.', 400);
|
||||
}
|
||||
|
||||
const { user } = await auth.api.signUpEmail({
|
||||
asResponse: false,
|
||||
body: {
|
||||
email: onboardingRequest.email,
|
||||
password: input.password,
|
||||
name: onboardingRequest.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
const complexSlug = await buildUniqueComplexSlug(input.complexName);
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
|
||||
const complex = await tx.complex.create({
|
||||
data: {
|
||||
id: uuidv7(),
|
||||
complexName: input.complexName.trim(),
|
||||
physicalAddress: input.physicalAddress.trim(),
|
||||
city: input.city?.trim() || null,
|
||||
state: input.state?.trim() || null,
|
||||
country: input.country?.trim() || null,
|
||||
complexSlug,
|
||||
adminEmail: onboardingRequest.email,
|
||||
planCode: input.planCode,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.complexUser.create({
|
||||
data: {
|
||||
complexId: complex.id,
|
||||
userId: user.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
await tx.onboardingRequest.update({
|
||||
where: { id: onboardingRequest.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
complexId: complex.id,
|
||||
complexSlug: complex.complexSlug,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listPlansHandler(c: AppContext) {
|
||||
const plans = await db.plan.findMany({
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
price: true,
|
||||
},
|
||||
orderBy: {
|
||||
price: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(
|
||||
plans.map((plan) => ({
|
||||
plans.map((plan) => {
|
||||
const rules = parsePlanRules(plan.rules);
|
||||
return {
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
}))
|
||||
features: rules.features,
|
||||
limits: rules.limits,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
|
||||
import { authRoutes } from '@/modules/auth/auth.routes';
|
||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||
import { courtRoutes } from '@/modules/court/court.routes';
|
||||
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
|
||||
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
|
||||
import { planRoutes } from '@/modules/plan/plan.routes';
|
||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
||||
@@ -20,7 +19,6 @@ export function registerRoutes(app: Hono<AppEnv>) {
|
||||
.route('', authRoutes)
|
||||
.route('/api/user', userRoutes)
|
||||
.route('/api/plans', planRoutes)
|
||||
.route('/api/onboarding', onboardingRoutes)
|
||||
.route('/api/password-reset', passwordResetRoutes)
|
||||
.route('/api/complexes', complexRoutes)
|
||||
.route('/api/sports', sportRoutes)
|
||||
|
||||
@@ -60,13 +60,23 @@ export function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
||||
if (
|
||||
!currentComplexQuery.isLoading &&
|
||||
!currentComplexQuery.data &&
|
||||
myComplexesQuery.data &&
|
||||
!myComplexesQuery.isLoading
|
||||
) {
|
||||
if (myComplexesQuery.data.length === 0) {
|
||||
navigate({ to: '/onboard/setup' });
|
||||
return;
|
||||
}
|
||||
autoSelect();
|
||||
}
|
||||
}, [
|
||||
currentComplexQuery.isLoading,
|
||||
currentComplexQuery.data,
|
||||
myComplexesQuery.data,
|
||||
myComplexesQuery.isLoading,
|
||||
navigate,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -18,12 +18,7 @@ const loginSchema = z.object({
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
type LoginPageProps = {
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
const navigate = useNavigate();
|
||||
export function LoginPage() {
|
||||
const { signInWithPassword } = useAuth();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -42,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
|
||||
async function handlePostLogin() {
|
||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||
const complexes = await apiClient.complexes.listMine();
|
||||
|
||||
if (complexes.length === 0) {
|
||||
await navigate({ to: redirectTo });
|
||||
} else if (complexes.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||
navigate({ to: redirectTo });
|
||||
} else {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (values: LoginForm) => {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function OnboardingCheckEmailStep() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Revisa tu correo y abre el link de verificacion para continuar.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CompleteValues } from '@/features/onboard/onboarding.types';
|
||||
import type { PlanSummary } from '@repo/api-contract';
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingCompleteStepProps = {
|
||||
form: UseFormReturn<CompleteValues>;
|
||||
plans: PlanSummary[];
|
||||
verifiedEmail: string | null;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
planPlaceholder: string;
|
||||
onSubmit: (values: CompleteValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingCompleteStep({
|
||||
form,
|
||||
plans,
|
||||
verifiedEmail,
|
||||
errorMessage,
|
||||
infoMessage,
|
||||
planPlaceholder,
|
||||
onSubmit,
|
||||
}: OnboardingCompleteStepProps) {
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{verifiedEmail && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="verified-email">Email verificado</FieldLabel>
|
||||
<Input id="verified-email" type="email" value={verifiedEmail} disabled readOnly />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.password)}>
|
||||
<FieldLabel htmlFor="onboard-password">Contraseña</FieldLabel>
|
||||
<Input
|
||||
id="onboard-password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
aria-invalid={Boolean(form.formState.errors.password)}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.password]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
|
||||
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
|
||||
<Input
|
||||
id="complex-name"
|
||||
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="physical-address">Direccion fisica</FieldLabel>
|
||||
<Input
|
||||
id="physical-address"
|
||||
type="text"
|
||||
placeholder="Ej: 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.planCode)}>
|
||||
<FieldLabel htmlFor="plan-code">Plan</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="planCode"
|
||||
render={({ field }) => (
|
||||
<select
|
||||
id="plan-code"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value);
|
||||
}}
|
||||
disabled={plans.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{plans.length > 0 ? planPlaceholder : '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 con el onboarding.
|
||||
</p>
|
||||
)}
|
||||
<FieldError errors={[form.formState.errors.planCode]} />
|
||||
</Field>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { StartValues } from '@/features/onboard/onboarding.types';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingStartStepProps = {
|
||||
form: UseFormReturn<StartValues>;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
onSubmit: (values: StartValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingStartStep({
|
||||
form,
|
||||
errorMessage,
|
||||
infoMessage,
|
||||
onSubmit,
|
||||
}: OnboardingStartStepProps) {
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
|
||||
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
|
||||
<Input
|
||||
id="onboard-fullname"
|
||||
type="text"
|
||||
placeholder="Nombre Apellido"
|
||||
aria-invalid={Boolean(form.formState.errors.fullName)}
|
||||
{...form.register('fullName')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.fullName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.email)}>
|
||||
<FieldLabel htmlFor="onboard-email">Email</FieldLabel>
|
||||
<Input
|
||||
id="onboard-email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
aria-invalid={Boolean(form.formState.errors.email)}
|
||||
{...form.register('email')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.email]} />
|
||||
</Field>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingVerifyOtpStepProps = {
|
||||
form: UseFormReturn<VerifyOtpValues>;
|
||||
email: string | null;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
remainingAttempts: number;
|
||||
resendCooldownSeconds: number;
|
||||
isResending: boolean;
|
||||
onSubmit: (values: VerifyOtpValues) => Promise<void>;
|
||||
onResend: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingVerifyOtpStep({
|
||||
form,
|
||||
email,
|
||||
errorMessage,
|
||||
infoMessage,
|
||||
remainingAttempts,
|
||||
resendCooldownSeconds,
|
||||
isResending,
|
||||
onSubmit,
|
||||
onResend,
|
||||
}: OnboardingVerifyOtpStepProps) {
|
||||
const resendDisabled = resendCooldownSeconds > 0 || isResending;
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingresa el codigo de 6 digitos que enviamos a{' '}
|
||||
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
|
||||
</p>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.otp)}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={resendDisabled}
|
||||
onClick={() => {
|
||||
void onResend();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{isResending
|
||||
? 'Reenviando...'
|
||||
: resendCooldownSeconds > 0
|
||||
? `Reenviar en ${resendCooldownSeconds}s`
|
||||
: 'Reenviar codigo'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<InputOTP
|
||||
id="onboard-otp"
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
pattern="\d*"
|
||||
value={form.watch('otp')}
|
||||
onChange={(value) => {
|
||||
form.setValue('otp', value, { shouldValidate: true });
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<FieldError errors={[form.formState.errors.otp]} />
|
||||
</Field>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Intentos restantes: <span className="font-medium">{remainingAttempts}</span>
|
||||
</p>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export function OnboardingVerifyingStep() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Verificando tu email, espera un momento...</p>
|
||||
);
|
||||
}
|
||||
161
apps/frontend/src/features/onboard/components/setup-stepper.tsx
Normal file
161
apps/frontend/src/features/onboard/components/setup-stepper.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperDescription,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperList,
|
||||
StepperNext,
|
||||
StepperPrev,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from '@/components/ui/stepper';
|
||||
import { ComplexNameStep } from '@/features/onboard/components/steps/complex-name-step';
|
||||
import { CourtsSetupStep } from '@/features/onboard/components/steps/courts-setup-step';
|
||||
import { LocationStep } from '@/features/onboard/components/steps/location-step';
|
||||
import { PlanSelectStep } from '@/features/onboard/components/steps/plan-select-step';
|
||||
import { ReviewStep } from '@/features/onboard/components/steps/review-step';
|
||||
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type SetupStepperProps = {
|
||||
form: UseFormReturn<any>;
|
||||
plans: PlanWithFeatures[];
|
||||
sports: Sport[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{ value: 'step-1', title: 'Nombre', description: 'Nombre del complejo' },
|
||||
{ value: 'step-2', title: 'Ubicación', description: 'Dirección y ciudad' },
|
||||
{ value: 'step-3', title: 'Plan', description: 'Elegí tu plan' },
|
||||
{ value: 'step-4', title: 'Canchas', description: 'Crear cancha inicial' },
|
||||
{ value: 'step-5', title: 'Revisar', description: 'Confirmar datos' },
|
||||
];
|
||||
|
||||
const VALIDATION_MAP: Record<string, string[]> = {
|
||||
'step-2': ['complexName'],
|
||||
'step-3': ['physicalAddress'],
|
||||
'step-4': ['planCode'],
|
||||
'step-5': ['planCode'],
|
||||
};
|
||||
|
||||
export function SetupStepper({ form, plans, sports, isSubmitting, onSubmit }: SetupStepperProps) {
|
||||
const [currentStep, setCurrentStep] = useState('step-1');
|
||||
const setupCourts = form.watch('setupCourts') as boolean | undefined;
|
||||
|
||||
const handleValidate = useCallback(
|
||||
async (nextValue: string, direction: string) => {
|
||||
if (direction === 'prev') return true;
|
||||
|
||||
let fields = VALIDATION_MAP[nextValue];
|
||||
|
||||
if (nextValue === 'step-5' && setupCourts) {
|
||||
fields = [
|
||||
...(fields ?? []),
|
||||
'courtSportId',
|
||||
'courtStartTime',
|
||||
'courtEndTime',
|
||||
'courtDaysOfWeek',
|
||||
];
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
return form.trigger(fields as never[]);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[form, setupCourts]
|
||||
);
|
||||
|
||||
const selectedPlan = plans.find((p) => p.code === form.watch('planCode'));
|
||||
const courtSportId = form.watch('courtSportId') as string | undefined;
|
||||
const selectedSport = sports.find((s) => s.id === courtSportId);
|
||||
|
||||
return (
|
||||
<Stepper value={currentStep} onValueChange={setCurrentStep} onValidate={handleValidate}>
|
||||
<StepperList>
|
||||
{STEPS.map((step) => (
|
||||
<StepperItem key={step.value} value={step.value}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator />
|
||||
<div className="flex flex-col gap-0.5 text-left max-sm:hidden">
|
||||
<StepperTitle>{step.title}</StepperTitle>
|
||||
<StepperDescription>{step.description}</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperList>
|
||||
|
||||
<div className="mt-6">
|
||||
<StepperContent value="step-1">
|
||||
<ComplexNameStep form={form} />
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value="step-2">
|
||||
<LocationStep form={form} />
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value="step-3">
|
||||
<PlanSelectStep form={form} plans={plans} />
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value="step-4">
|
||||
<CourtsSetupStep form={form} sports={sports} />
|
||||
</StepperContent>
|
||||
|
||||
<StepperContent value="step-5">
|
||||
<ReviewStep
|
||||
data={{
|
||||
complexName: (form.watch('complexName') as string) ?? '',
|
||||
physicalAddress: (form.watch('physicalAddress') as string) ?? '',
|
||||
city: form.watch('city') as string | undefined,
|
||||
state: form.watch('state') as string | undefined,
|
||||
country: form.watch('country') as string | undefined,
|
||||
planCode: (form.watch('planCode') as string) ?? '',
|
||||
setupCourts: setupCourts ?? false,
|
||||
courtSportName: selectedSport?.name,
|
||||
courtStartTime: (form.watch('courtStartTime') as string) ?? '08:00',
|
||||
courtEndTime: (form.watch('courtEndTime') as string) ?? '22:00',
|
||||
courtDaysOfWeek: form.watch('courtDaysOfWeek') as string[] | undefined,
|
||||
}}
|
||||
selectedPlan={selectedPlan}
|
||||
/>
|
||||
</StepperContent>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<StepperPrev asChild>
|
||||
<Button variant="outline" disabled={isSubmitting}>
|
||||
Anterior
|
||||
</Button>
|
||||
</StepperPrev>
|
||||
|
||||
{currentStep === 'step-5' ? (
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Creando complejo...
|
||||
</>
|
||||
) : (
|
||||
'Confirmar y crear complejo'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<StepperNext asChild>
|
||||
<Button disabled={isSubmitting}>Siguiente</Button>
|
||||
</StepperNext>
|
||||
)}
|
||||
</div>
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type ComplexNameStepProps = {
|
||||
form: UseFormReturn<any>;
|
||||
};
|
||||
|
||||
export function ComplexNameStep({ form }: ComplexNameStepProps) {
|
||||
const errors = form.formState.errors;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Nombre del complejo</h2>
|
||||
<p className="text-sm text-muted-foreground">¿Cómo se va a llamar tu complejo?</p>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.complexName)}>
|
||||
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
|
||||
<Input
|
||||
id="complex-name"
|
||||
type="text"
|
||||
placeholder="Ej: Complejo Las Palmeras"
|
||||
aria-invalid={Boolean(errors.complexName)}
|
||||
{...form.register('complexName')}
|
||||
/>
|
||||
<FieldError errors={[errors.complexName]} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Field, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Sport } from '@repo/api-contract';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type CourtsSetupStepProps = {
|
||||
form: UseFormReturn<any>;
|
||||
sports: Sport[];
|
||||
};
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 'MONDAY', label: 'Lun' },
|
||||
{ value: 'TUESDAY', label: 'Mar' },
|
||||
{ value: 'WEDNESDAY', label: 'Mié' },
|
||||
{ value: 'THURSDAY', label: 'Jue' },
|
||||
{ value: 'FRIDAY', label: 'Vie' },
|
||||
{ value: 'SATURDAY', label: 'Sáb' },
|
||||
{ value: 'SUNDAY', label: 'Dom' },
|
||||
] as const;
|
||||
|
||||
export function CourtsSetupStep({ form, sports }: CourtsSetupStepProps) {
|
||||
const setupCourts = form.watch('setupCourts') as boolean | undefined;
|
||||
const courtDaysOfWeek = form.watch('courtDaysOfWeek') as string[] | undefined;
|
||||
const errors = form.formState.errors;
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
const current = courtDaysOfWeek ?? [];
|
||||
const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
|
||||
form.setValue('courtDaysOfWeek', next, { shouldValidate: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Crear canchas</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configurá una cancha para empezar a recibir reservas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-lg border p-4 cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={setupCourts ?? false}
|
||||
onChange={(e) => {
|
||||
form.setValue('setupCourts', e.target.checked, { shouldValidate: true });
|
||||
if (!e.target.checked) {
|
||||
form.setValue('courtSportId', undefined);
|
||||
form.setValue('courtStartTime', undefined);
|
||||
form.setValue('courtEndTime', undefined);
|
||||
form.setValue('courtDaysOfWeek', undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Sí, crear una cancha ahora</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vas a poder agregar más canchas después desde el panel de administración.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{setupCourts && (
|
||||
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
|
||||
<Field data-invalid={Boolean(errors.courtSportId)}>
|
||||
<FieldLabel htmlFor="court-sport">Deporte</FieldLabel>
|
||||
<select
|
||||
id="court-sport"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm aria-invalid:border-destructive"
|
||||
aria-invalid={Boolean(errors.courtSportId)}
|
||||
{...form.register('courtSportId')}
|
||||
>
|
||||
<option value="">Seleccioná un deporte</option>
|
||||
{sports.map((sport) => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.courtSportId && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{String(errors.courtSportId.message ?? '')}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.courtStartTime)}>
|
||||
<FieldLabel htmlFor="court-start">Horario desde</FieldLabel>
|
||||
<Input
|
||||
id="court-start"
|
||||
type="time"
|
||||
defaultValue="08:00"
|
||||
aria-invalid={Boolean(errors.courtStartTime)}
|
||||
{...form.register('courtStartTime')}
|
||||
/>
|
||||
{errors.courtStartTime && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{String(errors.courtStartTime.message ?? '')}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.courtEndTime)}>
|
||||
<FieldLabel htmlFor="court-end">Horario hasta</FieldLabel>
|
||||
<Input
|
||||
id="court-end"
|
||||
type="time"
|
||||
defaultValue="22:00"
|
||||
aria-invalid={Boolean(errors.courtEndTime)}
|
||||
{...form.register('courtEndTime')}
|
||||
/>
|
||||
{errors.courtEndTime && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{String(errors.courtEndTime.message ?? '')}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm">Días de la semana</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map((day) => {
|
||||
const isSelected = courtDaysOfWeek?.includes(day.value) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-all',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="size-3" />}
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors.courtDaysOfWeek && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{String(errors.courtDaysOfWeek.message ?? '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type LocationStepProps = {
|
||||
form: UseFormReturn<any>;
|
||||
};
|
||||
|
||||
export function LocationStep({ form }: LocationStepProps) {
|
||||
const errors = form.formState.errors;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Ubicación</h2>
|
||||
<p className="text-sm text-muted-foreground">¿Dónde está ubicado tu complejo?</p>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.physicalAddress)}>
|
||||
<FieldLabel htmlFor="physical-address">Dirección</FieldLabel>
|
||||
<Input
|
||||
id="physical-address"
|
||||
type="text"
|
||||
placeholder="Ej: Av. San Martín 1234"
|
||||
aria-invalid={Boolean(errors.physicalAddress)}
|
||||
{...form.register('physicalAddress')}
|
||||
/>
|
||||
<FieldError errors={[errors.physicalAddress]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.city)}>
|
||||
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
placeholder="Ej: Salta"
|
||||
aria-invalid={Boolean(errors.city)}
|
||||
{...form.register('city')}
|
||||
/>
|
||||
<FieldError errors={[errors.city]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.state)}>
|
||||
<FieldLabel htmlFor="state">Provincia / Estado</FieldLabel>
|
||||
<Input
|
||||
id="state"
|
||||
type="text"
|
||||
placeholder="Ej: Salta"
|
||||
aria-invalid={Boolean(errors.state)}
|
||||
{...form.register('state')}
|
||||
/>
|
||||
<FieldError errors={[errors.state]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.country)}>
|
||||
<FieldLabel htmlFor="country">País</FieldLabel>
|
||||
<Input
|
||||
id="country"
|
||||
type="text"
|
||||
placeholder="Ej: Argentina"
|
||||
aria-invalid={Boolean(errors.country)}
|
||||
{...form.register('country')}
|
||||
/>
|
||||
<FieldError errors={[errors.country]} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type PlanSelectStepProps = {
|
||||
form: UseFormReturn<any>;
|
||||
plans: PlanWithFeatures[];
|
||||
};
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
publicBookingPage: 'Página de booking pública',
|
||||
onlinePayments: 'Pagos online',
|
||||
advancedReports: 'Reportes avanzados',
|
||||
whatsappReminders: 'Recordatorios WhatsApp',
|
||||
};
|
||||
|
||||
export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
|
||||
const selectedCode = form.watch('planCode') as string | undefined;
|
||||
const errors = form.formState.errors;
|
||||
|
||||
const featuresList = [
|
||||
'publicBookingPage',
|
||||
'onlinePayments',
|
||||
'advancedReports',
|
||||
'whatsappReminders',
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Elegí tu plan</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seleccioná el plan que mejor se adapte a tu complejo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const isSelected = selectedCode === plan.code;
|
||||
return (
|
||||
<button
|
||||
key={plan.code}
|
||||
type="button"
|
||||
onClick={() => form.setValue('planCode', plan.code, { shouldValidate: true })}
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-xl border-2 p-5 text-left transition-all',
|
||||
'hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'border-border bg-card'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute right-3 top-3 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||
<div className="mt-1">
|
||||
<span className="text-3xl font-bold">${plan.price}</span>
|
||||
<span className="text-sm text-muted-foreground">/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 border-t pt-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Incluye:</p>
|
||||
<p>Hasta {plan.limits.maxCourts} canchas</p>
|
||||
<p>Hasta {plan.limits.maxBookingsPerDay} reservas/día</p>
|
||||
{plan.limits.maxActiveUsers && <p>Hasta {plan.limits.maxActiveUsers} usuarios</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 border-t pt-3">
|
||||
{featuresList.map((feature) => {
|
||||
const enabled = plan.features[feature as keyof typeof plan.features];
|
||||
return (
|
||||
<div key={feature} className="flex items-center gap-2 text-xs">
|
||||
{enabled ? (
|
||||
<Check className="size-3.5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<X className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className={enabled ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
{FEATURE_LABELS[feature]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{errors.planCode && (
|
||||
<p className="text-sm text-destructive">{String(errors.planCode.message ?? '')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
type ReviewStepProps = {
|
||||
data: {
|
||||
complexName: string;
|
||||
physicalAddress: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
planCode: string;
|
||||
setupCourts: boolean;
|
||||
courtSportName?: string;
|
||||
courtStartTime?: string;
|
||||
courtEndTime?: string;
|
||||
courtDaysOfWeek?: string[];
|
||||
};
|
||||
selectedPlan: PlanWithFeatures | undefined;
|
||||
};
|
||||
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
MONDAY: 'Lunes',
|
||||
TUESDAY: 'Martes',
|
||||
WEDNESDAY: 'Miércoles',
|
||||
THURSDAY: 'Jueves',
|
||||
FRIDAY: 'Viernes',
|
||||
SATURDAY: 'Sábado',
|
||||
SUNDAY: 'Domingo',
|
||||
};
|
||||
|
||||
export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Revisá tus datos</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Antes de confirmar, revisá que todos los datos sean correctos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<Section title="Complejo">
|
||||
<Row label="Nombre" value={data.complexName} />
|
||||
<Row label="Dirección" value={data.physicalAddress} />
|
||||
{data.city && <Row label="Ciudad" value={data.city} />}
|
||||
{data.state && <Row label="Provincia" value={data.state} />}
|
||||
{data.country && <Row label="País" value={data.country} />}
|
||||
</Section>
|
||||
|
||||
<Section title="Plan seleccionado">
|
||||
{selectedPlan ? (
|
||||
<>
|
||||
<Row label="Plan" value={`${selectedPlan.name} - $${selectedPlan.price}/mes`} />
|
||||
<div className="mt-2 space-y-1">
|
||||
{[
|
||||
['publicBookingPage', 'Página de booking pública'],
|
||||
['onlinePayments', 'Pagos online'],
|
||||
['advancedReports', 'Reportes avanzados'],
|
||||
['whatsappReminders', 'Recordatorios WhatsApp'],
|
||||
].map(([feature, label]) => {
|
||||
const enabled =
|
||||
selectedPlan.features[feature as keyof typeof selectedPlan.features];
|
||||
return (
|
||||
<div key={feature} className="flex items-center gap-2 text-xs">
|
||||
{enabled ? (
|
||||
<Check className="size-3.5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<X className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className={enabled ? '' : 'text-muted-foreground'}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No se seleccionó un plan.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Cancha">
|
||||
{data.setupCourts ? (
|
||||
<>
|
||||
<Row label="Deporte" value={data.courtSportName ?? '-'} />
|
||||
<Row
|
||||
label="Horario"
|
||||
value={`${data.courtStartTime ?? '08:00'} - ${data.courtEndTime ?? '22:00'}`}
|
||||
/>
|
||||
<Row
|
||||
label="Días"
|
||||
value={(data.courtDaysOfWeek ?? []).map((d) => DAY_LABELS[d] ?? d).join(', ')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No se crearán canchas automáticamente. Podés agregarlas después.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 text-end font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
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-3 sm: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>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step';
|
||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
||||
import {
|
||||
clearOnboardingSessionState,
|
||||
getOnboardingSessionState,
|
||||
updateOnboardingSessionState,
|
||||
} from '@/features/onboard/onboarding-session';
|
||||
import type { CompleteValues } from '@/features/onboard/onboarding.types';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { type PlanSummary, onboardingCompleteSchema } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
export function OnboardCompletePage() {
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword } = useAuth();
|
||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
||||
|
||||
const [requestId] = useState<string | null>(initialState.requestId);
|
||||
const [verifiedEmail] = useState<string | null>(initialState.verifiedEmail);
|
||||
const [onboardingEmail] = useState<string | null>(initialState.onboardingEmail);
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<CompleteValues>({
|
||||
resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
|
||||
mode: 'onChange',
|
||||
defaultValues: initialState.completeForm,
|
||||
});
|
||||
const values = useWatch({ control: form.control });
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestId) {
|
||||
void navigate({ to: '/onboard' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifiedEmail) {
|
||||
void navigate({ to: '/onboard/verify' });
|
||||
}
|
||||
}, [navigate, requestId, verifiedEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
completeForm: {
|
||||
password: values.password ?? '',
|
||||
complexName: values.complexName ?? '',
|
||||
physicalAddress: values.physicalAddress ?? '',
|
||||
planCode: values.planCode ?? '',
|
||||
},
|
||||
}));
|
||||
}, [values.complexName, values.password, values.physicalAddress, values.planCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await apiClient.plans.list();
|
||||
setPlans(result);
|
||||
} catch {
|
||||
setPlans([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (payload: CompleteValues) => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.complete({
|
||||
onboardingRequestId: requestId,
|
||||
password: payload.password,
|
||||
complexName: payload.complexName,
|
||||
physicalAddress: payload.physicalAddress,
|
||||
planCode: payload.planCode,
|
||||
});
|
||||
|
||||
setCurrentComplexSlug(result.complexSlug);
|
||||
const emailForSignIn = verifiedEmail ?? onboardingEmail;
|
||||
if (emailForSignIn) {
|
||||
await signInWithPassword({ email: emailForSignIn, password: payload.password });
|
||||
}
|
||||
|
||||
clearOnboardingSessionState();
|
||||
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } });
|
||||
} catch {
|
||||
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.');
|
||||
}
|
||||
};
|
||||
|
||||
const planPlaceholder =
|
||||
plans.length === 0 ? 'Codigo del plan (por ejemplo: BASIC)' : 'Selecciona un plan';
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title="Completa tu onboarding"
|
||||
description="Define tu contraseña y configura tu complejo."
|
||||
>
|
||||
<OnboardingCompleteStep
|
||||
form={form}
|
||||
plans={plans}
|
||||
verifiedEmail={verifiedEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={null}
|
||||
planPlaceholder={planPlaceholder}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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 { acceptStoredPendingInvite } from '@/lib/invitations';
|
||||
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>;
|
||||
|
||||
type OnboardLoginPageProps = {
|
||||
variant?: 'onboard' | 'invite';
|
||||
inviteEmail?: string | null;
|
||||
};
|
||||
|
||||
export function OnboardLoginPage({
|
||||
variant = 'onboard',
|
||||
inviteEmail = null,
|
||||
}: OnboardLoginPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword, signUp } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>(variant === 'invite' ? '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: inviteEmail ?? '', password: '' },
|
||||
});
|
||||
|
||||
const signupForm = useForm<SignupForm>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
email: inviteEmail ?? '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function handlePostLogin() {
|
||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||
|
||||
if (variant === 'invite') {
|
||||
await navigate({ to: '/' });
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (variant === 'invite') {
|
||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||
await navigate({ to: '/' });
|
||||
} else {
|
||||
await handlePostLogin();
|
||||
}
|
||||
}
|
||||
} 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}/auth-callback`,
|
||||
});
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión con Google.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm: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'
|
||||
? variant === 'invite'
|
||||
? 'Ingresá con tu cuenta para aceptar la invitación.'
|
||||
: 'Ingresá con tu cuenta para continuar.'
|
||||
: variant === 'invite'
|
||||
? 'Creá tu cuenta para aceptar la invitación.'
|
||||
: '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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
||||
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step';
|
||||
import {
|
||||
OTP_MAX_ATTEMPTS,
|
||||
defaultCompleteFormValues,
|
||||
defaultVerifyOtpFormValues,
|
||||
getOnboardingSessionState,
|
||||
updateOnboardingSessionState,
|
||||
} from '@/features/onboard/onboarding-session';
|
||||
import type { StartValues } from '@/features/onboard/onboarding.types';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { onboardingStartSchema } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
export function OnboardStartPage() {
|
||||
const navigate = useNavigate();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
||||
|
||||
const form = useForm<StartValues>({
|
||||
resolver: zodResolver(onboardingStartSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: initialState.startForm,
|
||||
});
|
||||
const values = useWatch({ control: form.control });
|
||||
|
||||
useEffect(() => {
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
startForm: {
|
||||
fullName: values.fullName ?? '',
|
||||
email: values.email ?? '',
|
||||
},
|
||||
}));
|
||||
}, [values.email, values.fullName]);
|
||||
|
||||
const onSubmit = async (payload: StartValues) => {
|
||||
setErrorMessage(null);
|
||||
setInfoMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.start(payload);
|
||||
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
requestId: result.requestId,
|
||||
onboardingEmail: result.email,
|
||||
verifiedEmail: null,
|
||||
remainingAttempts: OTP_MAX_ATTEMPTS,
|
||||
resendCooldownSeconds: result.cooldownSeconds,
|
||||
startForm: {
|
||||
fullName: payload.fullName,
|
||||
email: payload.email,
|
||||
},
|
||||
verifyOtpForm: defaultVerifyOtpFormValues,
|
||||
completeForm: defaultCompleteFormValues,
|
||||
}));
|
||||
|
||||
await navigate({ to: '/onboard/verify' });
|
||||
} catch {
|
||||
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout title="Onboarding" description="Ingresa tu nombre y email para comenzar.">
|
||||
<OnboardingStartStep
|
||||
form={form}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
||||
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step';
|
||||
import {
|
||||
OTP_MAX_ATTEMPTS,
|
||||
getOnboardingSessionState,
|
||||
updateOnboardingSessionState,
|
||||
} from '@/features/onboard/onboarding-session';
|
||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { onboardingVerifyOtpSchema } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
export function OnboardVerifyPage() {
|
||||
const navigate = useNavigate();
|
||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
||||
const [requestId] = useState<string | null>(initialState.requestId);
|
||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(
|
||||
initialState.onboardingEmail
|
||||
);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(
|
||||
initialState.remainingAttempts || OTP_MAX_ATTEMPTS
|
||||
);
|
||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(
|
||||
initialState.resendCooldownSeconds || 0
|
||||
);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<VerifyOtpValues>({
|
||||
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
|
||||
mode: 'onChange',
|
||||
defaultValues: initialState.verifyOtpForm,
|
||||
});
|
||||
const values = useWatch({ control: form.control });
|
||||
|
||||
useEffect(() => {
|
||||
if (requestId && onboardingEmail) return;
|
||||
void navigate({ to: '/onboard' });
|
||||
}, [navigate, onboardingEmail, requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
onboardingEmail,
|
||||
remainingAttempts,
|
||||
resendCooldownSeconds,
|
||||
verifyOtpForm: {
|
||||
otp: values.otp ?? '',
|
||||
},
|
||||
}));
|
||||
}, [onboardingEmail, remainingAttempts, resendCooldownSeconds, values.otp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldownSeconds <= 0) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setResendCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [resendCooldownSeconds]);
|
||||
|
||||
const onSubmit = async (payload: VerifyOtpValues) => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.verifyOtp({
|
||||
requestId,
|
||||
otp: payload.otp,
|
||||
});
|
||||
|
||||
setRemainingAttempts(result.remainingAttempts);
|
||||
setResendCooldownSeconds(result.cooldownSeconds);
|
||||
setInfoMessage(result.message);
|
||||
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
remainingAttempts: result.remainingAttempts,
|
||||
resendCooldownSeconds: result.cooldownSeconds,
|
||||
}));
|
||||
|
||||
if (result.verified) {
|
||||
const nextVerifiedEmail = result.email ?? onboardingEmail;
|
||||
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
verifiedEmail: nextVerifiedEmail,
|
||||
remainingAttempts: result.remainingAttempts,
|
||||
resendCooldownSeconds: result.cooldownSeconds,
|
||||
}));
|
||||
|
||||
await navigate({ to: '/onboard/complete' });
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.');
|
||||
}
|
||||
};
|
||||
|
||||
const onResend = async () => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResending(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.resendOtp({ requestId });
|
||||
setOnboardingEmail(result.email);
|
||||
setRemainingAttempts(result.remainingAttempts);
|
||||
setResendCooldownSeconds(result.cooldownSeconds);
|
||||
form.reset({ otp: '' });
|
||||
setInfoMessage(result.message);
|
||||
|
||||
updateOnboardingSessionState((current) => ({
|
||||
...current,
|
||||
onboardingEmail: result.email,
|
||||
remainingAttempts: result.remainingAttempts,
|
||||
resendCooldownSeconds: result.cooldownSeconds,
|
||||
verifyOtpForm: { otp: '' },
|
||||
}));
|
||||
} catch {
|
||||
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title="Verifica tu email"
|
||||
description="Ingresa el OTP para validar tu cuenta."
|
||||
>
|
||||
<OnboardingVerifyOtpStep
|
||||
form={form}
|
||||
email={onboardingEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
remainingAttempts={remainingAttempts}
|
||||
resendCooldownSeconds={resendCooldownSeconds}
|
||||
isResending={isResending}
|
||||
onSubmit={onSubmit}
|
||||
onResend={onResend}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-2 w-full"
|
||||
onClick={() => {
|
||||
void navigate({ to: '/onboard' });
|
||||
}}
|
||||
>
|
||||
Volver al paso anterior
|
||||
</Button>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import type {
|
||||
CompleteValues,
|
||||
StartValues,
|
||||
VerifyOtpValues,
|
||||
} from '@/features/onboard/onboarding.types';
|
||||
|
||||
const ONBOARDING_SESSION_KEY = 'playzer:onboarding-session';
|
||||
export const OTP_MAX_ATTEMPTS = 5;
|
||||
|
||||
export const defaultStartFormValues: StartValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
export const defaultVerifyOtpFormValues: VerifyOtpValues = {
|
||||
otp: '',
|
||||
};
|
||||
|
||||
export const defaultCompleteFormValues: CompleteValues = {
|
||||
password: '',
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
planCode: '',
|
||||
};
|
||||
|
||||
export type OnboardingSessionState = {
|
||||
requestId: string | null;
|
||||
onboardingEmail: string | null;
|
||||
verifiedEmail: string | null;
|
||||
remainingAttempts: number;
|
||||
resendCooldownSeconds: number;
|
||||
startForm: StartValues;
|
||||
verifyOtpForm: VerifyOtpValues;
|
||||
completeForm: CompleteValues;
|
||||
};
|
||||
|
||||
const defaultOnboardingSessionState: OnboardingSessionState = {
|
||||
requestId: null,
|
||||
onboardingEmail: null,
|
||||
verifiedEmail: null,
|
||||
remainingAttempts: OTP_MAX_ATTEMPTS,
|
||||
resendCooldownSeconds: 0,
|
||||
startForm: defaultStartFormValues,
|
||||
verifyOtpForm: defaultVerifyOtpFormValues,
|
||||
completeForm: defaultCompleteFormValues,
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeSessionState(value: unknown): OnboardingSessionState {
|
||||
if (!isObject(value)) {
|
||||
return defaultOnboardingSessionState;
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: typeof value.requestId === 'string' ? value.requestId : null,
|
||||
onboardingEmail: typeof value.onboardingEmail === 'string' ? value.onboardingEmail : null,
|
||||
verifiedEmail: typeof value.verifiedEmail === 'string' ? value.verifiedEmail : null,
|
||||
remainingAttempts:
|
||||
typeof value.remainingAttempts === 'number'
|
||||
? value.remainingAttempts
|
||||
: defaultOnboardingSessionState.remainingAttempts,
|
||||
resendCooldownSeconds:
|
||||
typeof value.resendCooldownSeconds === 'number'
|
||||
? value.resendCooldownSeconds
|
||||
: defaultOnboardingSessionState.resendCooldownSeconds,
|
||||
startForm: {
|
||||
fullName:
|
||||
isObject(value.startForm) && typeof value.startForm.fullName === 'string'
|
||||
? value.startForm.fullName
|
||||
: defaultStartFormValues.fullName,
|
||||
email:
|
||||
isObject(value.startForm) && typeof value.startForm.email === 'string'
|
||||
? value.startForm.email
|
||||
: defaultStartFormValues.email,
|
||||
},
|
||||
verifyOtpForm: {
|
||||
otp:
|
||||
isObject(value.verifyOtpForm) && typeof value.verifyOtpForm.otp === 'string'
|
||||
? value.verifyOtpForm.otp
|
||||
: defaultVerifyOtpFormValues.otp,
|
||||
},
|
||||
completeForm: {
|
||||
password:
|
||||
isObject(value.completeForm) && typeof value.completeForm.password === 'string'
|
||||
? value.completeForm.password
|
||||
: defaultCompleteFormValues.password,
|
||||
complexName:
|
||||
isObject(value.completeForm) && typeof value.completeForm.complexName === 'string'
|
||||
? value.completeForm.complexName
|
||||
: defaultCompleteFormValues.complexName,
|
||||
physicalAddress:
|
||||
isObject(value.completeForm) && typeof value.completeForm.physicalAddress === 'string'
|
||||
? value.completeForm.physicalAddress
|
||||
: defaultCompleteFormValues.physicalAddress,
|
||||
planCode:
|
||||
isObject(value.completeForm) && typeof value.completeForm.planCode === 'string'
|
||||
? value.completeForm.planCode
|
||||
: defaultCompleteFormValues.planCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getOnboardingSessionState(): OnboardingSessionState {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultOnboardingSessionState;
|
||||
}
|
||||
|
||||
const rawState = window.sessionStorage.getItem(ONBOARDING_SESSION_KEY);
|
||||
if (!rawState) {
|
||||
return defaultOnboardingSessionState;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeSessionState(JSON.parse(rawState));
|
||||
} catch {
|
||||
return defaultOnboardingSessionState;
|
||||
}
|
||||
}
|
||||
|
||||
export function setOnboardingSessionState(state: OnboardingSessionState) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.setItem(ONBOARDING_SESSION_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function updateOnboardingSessionState(
|
||||
updater: (current: OnboardingSessionState) => OnboardingSessionState
|
||||
): OnboardingSessionState {
|
||||
const currentState = getOnboardingSessionState();
|
||||
const nextState = updater(currentState);
|
||||
setOnboardingSessionState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function clearOnboardingSessionState() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_KEY);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import {
|
||||
onboardingCompleteSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from '@repo/api-contract';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type StartValues = z.infer<typeof onboardingStartSchema>;
|
||||
export type VerifyOtpValues = Omit<z.infer<typeof onboardingVerifyOtpSchema>, 'requestId'>;
|
||||
export type CompleteValues = Omit<z.infer<typeof onboardingCompleteSchema>, 'onboardingRequestId'>;
|
||||
114
apps/frontend/src/features/onboard/setup-stepper-page.tsx
Normal file
114
apps/frontend/src/features/onboard/setup-stepper-page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { SetupStepper } from '@/features/onboard/components/setup-stepper';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { createComplexSchema } from '@repo/api-contract';
|
||||
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
export function SetupStepperPage() {
|
||||
const navigate = useNavigate();
|
||||
const [plans, setPlans] = useState<PlanWithFeatures[]>([]);
|
||||
const [sports, setSports] = useState<Sport[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createComplexSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
planCode: '',
|
||||
setupCourts: false,
|
||||
courtSportId: undefined,
|
||||
courtStartTime: '08:00',
|
||||
courtEndTime: '22:00',
|
||||
courtDaysOfWeek: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Safety: if user already has complexes (e.g., accepted invite between redirects),
|
||||
// redirect to home
|
||||
const myComplexes = await apiClient.complexes.listMine();
|
||||
if (myComplexes.length > 0) {
|
||||
navigate({ to: '/' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [plansResult, sportsResult] = await Promise.all([
|
||||
apiClient.plans.list(),
|
||||
apiClient.sports.list(),
|
||||
]);
|
||||
setPlans(plansResult);
|
||||
setSports(sportsResult);
|
||||
} catch {
|
||||
setErrorMessage('Error al cargar datos iniciales.');
|
||||
}
|
||||
})();
|
||||
}, [navigate]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
setErrorMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const values = form.getValues();
|
||||
const result = await apiClient.complexes.create(values as any);
|
||||
|
||||
setCurrentComplexSlug(result.complexSlug);
|
||||
await navigate({
|
||||
to: '/complex/$slug/edit',
|
||||
params: { slug: result.complexSlug },
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (errorMessage && plans.length === 0) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
||||
<section className="w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm text-center">
|
||||
<p className="text-destructive">{errorMessage}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-start justify-center px-3 py-12 sm:px-6">
|
||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold">Configurá tu complejo</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Completá los datos para empezar a recibir reservas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SetupStepper
|
||||
form={form}
|
||||
plans={plans}
|
||||
sports={sports}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
{errorMessage && <p className="mt-4 text-sm text-destructive">{errorMessage}</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getPendingInvite } from '@/lib/invitations';
|
||||
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 pendingInvite = getPendingInvite();
|
||||
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-3 sm: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?{' '}
|
||||
{pendingInvite.token ? (
|
||||
<Link
|
||||
to="/invite"
|
||||
search={{
|
||||
email: pendingInvite.email ?? email,
|
||||
inviteToken: pendingInvite.token,
|
||||
}}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Continuar con la invitación
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/onboard/create-complex" className="text-primary hover:underline">
|
||||
Continuar
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function SignupPage() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await signUp({
|
||||
const { requiresEmailConfirmation } = await signUp({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
fullName: values.fullName,
|
||||
@@ -91,7 +91,11 @@ export function SignupPage() {
|
||||
});
|
||||
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
await navigate({ to: '/' });
|
||||
|
||||
if (requiresEmailConfirmation) {
|
||||
sessionStorage.setItem('pending-signup-email', values.email);
|
||||
await navigate({ to: '/onboard/setup' });
|
||||
}
|
||||
} catch {
|
||||
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
|
||||
} finally {
|
||||
|
||||
@@ -43,7 +43,6 @@ export class ApiClientError extends Error {
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
onboarding: api.onboarding,
|
||||
plans: api.plans,
|
||||
user: api.user,
|
||||
complexes: api.complexes,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PlanSummary } from '@repo/api-contract';
|
||||
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||
import { http } from '../http';
|
||||
|
||||
export async function list() {
|
||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
||||
const response = await http.get<PlanWithFeatures[]>('/api/plans');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as InviteRouteImport } from './routes/invite'
|
||||
import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
|
||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
||||
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
|
||||
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
||||
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
|
||||
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
|
||||
import { Route as OnboardSetupRouteImport } from './routes/onboard/setup'
|
||||
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
||||
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
|
||||
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
||||
@@ -69,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({
|
||||
id: '/_app',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OnboardIndexRoute = OnboardIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
|
||||
id: '/verify-email',
|
||||
path: '/verify-email',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
|
||||
id: '/verify',
|
||||
path: '/verify',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
|
||||
id: '/create-complex',
|
||||
path: '/create-complex',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
|
||||
id: '/complete',
|
||||
path: '/complete',
|
||||
const OnboardSetupRoute = OnboardSetupRouteImport.update({
|
||||
id: '/setup',
|
||||
path: '/setup',
|
||||
getParentRoute: () => OnboardRoute,
|
||||
} as any)
|
||||
const AppProfileRoute = AppProfileRouteImport.update({
|
||||
@@ -142,11 +118,7 @@ export interface FileRoutesByFullPath {
|
||||
'/signup': typeof SignupRoute
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard/': typeof OnboardIndexRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
@@ -156,15 +128,12 @@ export interface FileRoutesByTo {
|
||||
'/auth-callback': typeof AuthCallbackRoute
|
||||
'/invite': typeof InviteRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/onboard': typeof OnboardRouteWithChildren
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/select-complex': typeof SelectComplexRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard': typeof OnboardIndexRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
@@ -182,11 +151,7 @@ export interface FileRoutesById {
|
||||
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/_app/profile': typeof AppProfileRoute
|
||||
'/onboard/complete': typeof OnboardCompleteRoute
|
||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
||||
'/onboard/verify': typeof OnboardVerifyRoute
|
||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
||||
'/onboard/': typeof OnboardIndexRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
@@ -205,11 +170,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/$complexSlug/booking'
|
||||
| '/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard/'
|
||||
| '/onboard/setup'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/complex/$slug/edit'
|
||||
@@ -219,15 +180,12 @@ export interface FileRouteTypes {
|
||||
| '/auth-callback'
|
||||
| '/invite'
|
||||
| '/login'
|
||||
| '/onboard'
|
||||
| '/reset-password'
|
||||
| '/select-complex'
|
||||
| '/signup'
|
||||
| '/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard'
|
||||
| '/onboard/setup'
|
||||
| '/$complexSlug/booking'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/complex/$slug/edit'
|
||||
@@ -244,11 +202,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/_authenticated'
|
||||
| '/$complexSlug/booking'
|
||||
| '/_app/profile'
|
||||
| '/onboard/complete'
|
||||
| '/onboard/create-complex'
|
||||
| '/onboard/verify'
|
||||
| '/onboard/verify-email'
|
||||
| '/onboard/'
|
||||
| '/onboard/setup'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/_app/_authenticated/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
@@ -325,39 +279,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/onboard/': {
|
||||
id: '/onboard/'
|
||||
path: '/'
|
||||
fullPath: '/onboard/'
|
||||
preLoaderRoute: typeof OnboardIndexRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/verify-email': {
|
||||
id: '/onboard/verify-email'
|
||||
path: '/verify-email'
|
||||
fullPath: '/onboard/verify-email'
|
||||
preLoaderRoute: typeof OnboardVerifyEmailRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/verify': {
|
||||
id: '/onboard/verify'
|
||||
path: '/verify'
|
||||
fullPath: '/onboard/verify'
|
||||
preLoaderRoute: typeof OnboardVerifyRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/create-complex': {
|
||||
id: '/onboard/create-complex'
|
||||
path: '/create-complex'
|
||||
fullPath: '/onboard/create-complex'
|
||||
preLoaderRoute: typeof OnboardCreateComplexRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/onboard/complete': {
|
||||
id: '/onboard/complete'
|
||||
path: '/complete'
|
||||
fullPath: '/onboard/complete'
|
||||
preLoaderRoute: typeof OnboardCompleteRouteImport
|
||||
'/onboard/setup': {
|
||||
id: '/onboard/setup'
|
||||
path: '/setup'
|
||||
fullPath: '/onboard/setup'
|
||||
preLoaderRoute: typeof OnboardSetupRouteImport
|
||||
parentRoute: typeof OnboardRoute
|
||||
}
|
||||
'/_app/profile': {
|
||||
@@ -442,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface OnboardRouteChildren {
|
||||
OnboardCompleteRoute: typeof OnboardCompleteRoute
|
||||
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
|
||||
OnboardVerifyRoute: typeof OnboardVerifyRoute
|
||||
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
|
||||
OnboardIndexRoute: typeof OnboardIndexRoute
|
||||
OnboardSetupRoute: typeof OnboardSetupRoute
|
||||
}
|
||||
|
||||
const OnboardRouteChildren: OnboardRouteChildren = {
|
||||
OnboardCompleteRoute: OnboardCompleteRoute,
|
||||
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
|
||||
OnboardVerifyRoute: OnboardVerifyRoute,
|
||||
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
|
||||
OnboardIndexRoute: OnboardIndexRoute,
|
||||
OnboardSetupRoute: OnboardSetupRoute,
|
||||
}
|
||||
|
||||
const OnboardRouteWithChildren =
|
||||
|
||||
@@ -17,23 +17,9 @@ function AuthCallbackPage() {
|
||||
|
||||
try {
|
||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||
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: '/onboard/create-complex' });
|
||||
}
|
||||
} catch {
|
||||
navigate({ to: '/onboard' });
|
||||
navigate({ to: '/login' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations';
|
||||
@@ -25,6 +24,7 @@ function InvitePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate({ to: '/login' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,15 +34,11 @@ function InvitePage() {
|
||||
})();
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
||||
<p className="text-sm text-muted-foreground">Aceptando invitación...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <OnboardLoginPage variant="invite" inviteEmail={search.email ?? null} />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/invite')({
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { LoginPage } from '@/features/login/login-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSearchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: loginSearchSchema,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/' });
|
||||
}
|
||||
},
|
||||
component: LoginRouteComponent,
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginRouteComponent() {
|
||||
const search = Route.useSearch();
|
||||
return <LoginPage redirectTo={search.redirect} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { OnboardCompletePage } from '@/features/onboard/onboard-complete-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/complete')({
|
||||
component: OnboardCompletePage,
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/')({
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/login', search: { redirect: '/onboard/create-complex' } });
|
||||
}
|
||||
},
|
||||
component: OnboardLoginPage,
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { CreateComplexPage } from '@/features/onboard/create-complex-page';
|
||||
import { SetupStepperPage } from '@/features/onboard/setup-stepper-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/create-complex')({
|
||||
export const Route = createFileRoute('/onboard/setup')({
|
||||
beforeLoad: ({ context }) => {
|
||||
const hasSession = context.auth.isAuthenticated;
|
||||
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
|
||||
|
||||
if (!hasSession && !hasPendingSignup) {
|
||||
throw redirect({ to: '/onboard' });
|
||||
throw redirect({ to: '/login' });
|
||||
}
|
||||
},
|
||||
component: CreateComplexPage,
|
||||
component: SetupStepperPage,
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { OnboardVerifyPage } from '@/features/onboard/onboard-verify-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard/verify')({
|
||||
component: OnboardVerifyPage,
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const dayOfWeekEnum = z.enum([
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
'SUNDAY',
|
||||
])
|
||||
|
||||
export const complexSchema = z.object({
|
||||
id: z.uuid(),
|
||||
complexName: z.string(),
|
||||
@@ -14,7 +24,8 @@ export const complexSchema = z.object({
|
||||
updatedAt: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const createComplexSchema = z.object({
|
||||
export const createComplexSchema = z
|
||||
.object({
|
||||
complexName: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -46,7 +57,44 @@ export const createComplexSchema = z.object({
|
||||
.min(1, 'El planCode no puede estar vacío.')
|
||||
.max(10, 'El planCode no puede superar los 10 caracteres.')
|
||||
.optional(),
|
||||
})
|
||||
setupCourts: z.boolean().default(false),
|
||||
courtSportId: z.string().uuid('El deporte seleccionado no es válido.').optional(),
|
||||
courtStartTime: z.string().optional(),
|
||||
courtEndTime: z.string().optional(),
|
||||
courtDaysOfWeek: z.array(dayOfWeekEnum).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.setupCourts) {
|
||||
if (!data.courtSportId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['courtSportId'],
|
||||
message: 'Debes seleccionar un deporte.',
|
||||
})
|
||||
}
|
||||
if (!data.courtStartTime || !/^\d{2}:\d{2}$/.test(data.courtStartTime)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['courtStartTime'],
|
||||
message: 'La hora de inicio debe tener formato HH:mm.',
|
||||
})
|
||||
}
|
||||
if (!data.courtEndTime || !/^\d{2}:\d{2}$/.test(data.courtEndTime)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['courtEndTime'],
|
||||
message: 'La hora de fin debe tener formato HH:mm.',
|
||||
})
|
||||
}
|
||||
if (!data.courtDaysOfWeek || data.courtDaysOfWeek.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['courtDaysOfWeek'],
|
||||
message: 'Debes seleccionar al menos un día.',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const updateComplexSchema = z
|
||||
.object({
|
||||
|
||||
@@ -4,28 +4,18 @@ export {
|
||||
planRulesV1Schema,
|
||||
planSchema,
|
||||
planSummarySchema,
|
||||
planWithFeaturesSchema,
|
||||
planLimitsSchema,
|
||||
} from './plan'
|
||||
export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan'
|
||||
export {
|
||||
onboardingCompleteResponseSchema,
|
||||
onboardingCompleteSchema,
|
||||
onboardingResendOtpResponseSchema,
|
||||
onboardingResendOtpSchema,
|
||||
onboardingStartResponseSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpResponseSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from './onboarding'
|
||||
export type {
|
||||
OnboardingCompleteInput,
|
||||
OnboardingCompleteResponse,
|
||||
OnboardingResendOtpInput,
|
||||
OnboardingResendOtpResponse,
|
||||
OnboardingStartInput,
|
||||
OnboardingStartResponse,
|
||||
OnboardingVerifyOtpInput,
|
||||
OnboardingVerifyOtpResponse,
|
||||
} from './onboarding'
|
||||
Plan,
|
||||
PlanFeatureFlags,
|
||||
PlanRules,
|
||||
PlanRulesV1,
|
||||
PlanSummary,
|
||||
PlanWithFeatures,
|
||||
PlanLimits,
|
||||
} from './plan'
|
||||
export {
|
||||
acceptComplexInvitationsSchema,
|
||||
complexInvitationSchema,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const otpSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, 'El OTP debe tener 6 digitos numericos.')
|
||||
|
||||
export const onboardingStartSchema = z.object({
|
||||
fullName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'El nombre debe tener al menos 2 caracteres.')
|
||||
.max(80, 'El nombre no puede superar los 80 caracteres.'),
|
||||
email: z.email('Ingresa un email valido.'),
|
||||
})
|
||||
|
||||
export const onboardingVerifyOtpSchema = z.object({
|
||||
requestId: z.uuid('El requestId de onboarding no es valido.'),
|
||||
otp: otpSchema,
|
||||
})
|
||||
|
||||
export const onboardingResendOtpSchema = z.object({
|
||||
requestId: z.uuid('El requestId de onboarding no es valido.'),
|
||||
})
|
||||
|
||||
export const onboardingCompleteSchema = z.object({
|
||||
onboardingRequestId: z.uuid('El onboardingRequestId no es valido.'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'La contrasena debe tener al menos 8 caracteres.')
|
||||
.max(72, 'La contrasena no puede superar los 72 caracteres.'),
|
||||
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, 'Debes seleccionar un plan.')
|
||||
.max(10, 'El plan seleccionado no es valido.'),
|
||||
})
|
||||
|
||||
export const onboardingStartResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
requestId: z.uuid(),
|
||||
email: z.email(),
|
||||
expiresAt: z.string().datetime(),
|
||||
cooldownSeconds: z.int().nonnegative(),
|
||||
})
|
||||
|
||||
export const onboardingVerifyOtpResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
verified: z.boolean(),
|
||||
requestId: z.uuid(),
|
||||
email: z.email().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
remainingAttempts: z.int().nonnegative(),
|
||||
cooldownSeconds: z.int().nonnegative(),
|
||||
})
|
||||
|
||||
export const onboardingResendOtpResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
requestId: z.uuid(),
|
||||
email: z.email(),
|
||||
expiresAt: z.string().datetime(),
|
||||
cooldownSeconds: z.int().nonnegative(),
|
||||
remainingAttempts: z.int().nonnegative(),
|
||||
})
|
||||
|
||||
export const onboardingCompleteResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
userId: z.uuid(),
|
||||
complexId: z.uuid(),
|
||||
complexSlug: z.string(),
|
||||
})
|
||||
|
||||
export type OnboardingStartInput = z.infer<typeof onboardingStartSchema>
|
||||
export type OnboardingVerifyOtpInput = z.infer<typeof onboardingVerifyOtpSchema>
|
||||
export type OnboardingResendOtpInput = z.infer<typeof onboardingResendOtpSchema>
|
||||
export type OnboardingCompleteInput = z.infer<typeof onboardingCompleteSchema>
|
||||
|
||||
export type OnboardingStartResponse = z.infer<typeof onboardingStartResponseSchema>
|
||||
export type OnboardingVerifyOtpResponse = z.infer<
|
||||
typeof onboardingVerifyOtpResponseSchema
|
||||
>
|
||||
export type OnboardingResendOtpResponse = z.infer<
|
||||
typeof onboardingResendOtpResponseSchema
|
||||
>
|
||||
export type OnboardingCompleteResponse = z.infer<
|
||||
typeof onboardingCompleteResponseSchema
|
||||
>
|
||||
@@ -49,8 +49,25 @@ export const planSummarySchema = z.object({
|
||||
price: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
export const planLimitsSchema = z.object({
|
||||
maxCourts: positiveInt,
|
||||
maxBookingsPerDay: positiveInt,
|
||||
maxActiveUsers: positiveInt.optional(),
|
||||
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
||||
})
|
||||
|
||||
export const planWithFeaturesSchema = z.object({
|
||||
code: z.string().trim().min(1).max(10),
|
||||
name: z.string().trim().min(1).max(30),
|
||||
price: z.number().nonnegative(),
|
||||
features: planFeatureFlagsSchema,
|
||||
limits: planLimitsSchema,
|
||||
})
|
||||
|
||||
export type PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
|
||||
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
|
||||
export type PlanRules = z.infer<typeof planRulesSchema>
|
||||
export type Plan = z.infer<typeof planSchema>
|
||||
export type PlanSummary = z.infer<typeof planSummarySchema>
|
||||
export type PlanWithFeatures = z.infer<typeof planWithFeaturesSchema>
|
||||
export type PlanLimits = z.infer<typeof planLimitsSchema>
|
||||
|
||||
Reference in New Issue
Block a user