3 Commits

Author SHA1 Message Date
83fa1ea020 Merge pull request 'feat/onboard-redesign' (#4) from feat/onboard-redesign into development
Reviewed-on: #4
2026-06-02 13:03:43 +00:00
Jose Selesan
4544911f0e 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.
2026-06-02 10:02:28 -03:00
Jose Selesan
2aaa91444d feat: add phone field to User model and update authentication flow
- Added phone field to User model in Prisma schema and generated types.
- Updated backend authentication to include phone as an additional field.
- Implemented phone input in the signup form with validation.
- Created a new SignupPage component for user registration.
- Updated routing to include signup path and redirect authenticated users.
- Modified login page to reference Playzer and added link to signup.
2026-06-01 16:43:52 -03:00
61 changed files with 1356 additions and 2390 deletions

View File

@@ -4,6 +4,7 @@ model User {
email String email String
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
phone String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "phone" TEXT;

View File

@@ -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")
}

File diff suppressed because one or more lines are too long

View File

@@ -1648,6 +1648,7 @@ export const UserScalarFieldEnum = {
email: 'email', email: 'email',
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'

View File

@@ -91,6 +91,7 @@ export const UserScalarFieldEnum = {
email: 'email', email: 'email',
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'

View File

@@ -30,6 +30,7 @@ export type UserMinAggregateOutputType = {
email: string | null email: string | null
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -41,6 +42,7 @@ export type UserMaxAggregateOutputType = {
email: string | null email: string | null
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -52,6 +54,7 @@ export type UserCountAggregateOutputType = {
email: number email: number
emailVerified: number emailVerified: number
image: number image: number
phone: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
role: number role: number
@@ -65,6 +68,7 @@ export type UserMinAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -76,6 +80,7 @@ export type UserMaxAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -87,6 +92,7 @@ export type UserCountAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -171,6 +177,7 @@ export type UserGroupByOutputType = {
email: string email: string
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -203,6 +210,7 @@ export type UserWhereInput = {
email?: Prisma.StringFilter<"User"> | string email?: Prisma.StringFilter<"User"> | string
emailVerified?: Prisma.BoolFilter<"User"> | boolean emailVerified?: Prisma.BoolFilter<"User"> | boolean
image?: Prisma.StringNullableFilter<"User"> | string | null image?: Prisma.StringNullableFilter<"User"> | string | null
phone?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
role?: Prisma.StringFilter<"User"> | string role?: Prisma.StringFilter<"User"> | string
@@ -217,6 +225,7 @@ export type UserOrderByWithRelationInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -234,6 +243,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
name?: Prisma.StringFilter<"User"> | string name?: Prisma.StringFilter<"User"> | string
emailVerified?: Prisma.BoolFilter<"User"> | boolean emailVerified?: Prisma.BoolFilter<"User"> | boolean
image?: Prisma.StringNullableFilter<"User"> | string | null image?: Prisma.StringNullableFilter<"User"> | string | null
phone?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
role?: Prisma.StringFilter<"User"> | string role?: Prisma.StringFilter<"User"> | string
@@ -248,6 +258,7 @@ export type UserOrderByWithAggregationInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -265,6 +276,7 @@ export type UserScalarWhereWithAggregatesInput = {
email?: Prisma.StringWithAggregatesFilter<"User"> | string email?: Prisma.StringWithAggregatesFilter<"User"> | string
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
role?: Prisma.StringWithAggregatesFilter<"User"> | string role?: Prisma.StringWithAggregatesFilter<"User"> | string
@@ -276,6 +288,7 @@ export type UserCreateInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -290,6 +303,7 @@ export type UserUncheckedCreateInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -304,6 +318,7 @@ export type UserUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -318,6 +333,7 @@ export type UserUncheckedUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -332,6 +348,7 @@ export type UserCreateManyInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -343,6 +360,7 @@ export type UserUpdateManyMutationInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -354,6 +372,7 @@ export type UserUncheckedUpdateManyInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -365,6 +384,7 @@ export type UserCountOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -376,6 +396,7 @@ export type UserMaxOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -387,6 +408,7 @@ export type UserMinOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -461,6 +483,7 @@ export type UserCreateWithoutSessionsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -474,6 +497,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -503,6 +527,7 @@ export type UserUpdateWithoutSessionsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -516,6 +541,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -529,6 +555,7 @@ export type UserCreateWithoutAccountsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -542,6 +569,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -571,6 +599,7 @@ export type UserUpdateWithoutAccountsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -584,6 +613,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -597,6 +627,7 @@ export type UserCreateWithoutComplexesInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -610,6 +641,7 @@ export type UserUncheckedCreateWithoutComplexesInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -639,6 +671,7 @@ export type UserUpdateWithoutComplexesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -652,6 +685,7 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -714,6 +748,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -729,6 +764,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -740,6 +776,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -751,12 +788,13 @@ export type UserSelectScalar = {
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
} }
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]> export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs> accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
@@ -779,6 +817,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
email: string email: string
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -1213,6 +1252,7 @@ export interface UserFieldRefs {
readonly email: Prisma.FieldRef<"User", 'String'> readonly email: Prisma.FieldRef<"User", 'String'>
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'> readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
readonly image: Prisma.FieldRef<"User", 'String'> readonly image: Prisma.FieldRef<"User", 'String'>
readonly phone: Prisma.FieldRef<"User", 'String'>
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'> readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
readonly role: Prisma.FieldRef<"User", 'String'> readonly role: Prisma.FieldRef<"User", 'String'>

View File

@@ -2,6 +2,7 @@ import { dash } from '@better-auth/infra';
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma'; import { prismaAdapter } from 'better-auth/adapters/prisma';
import { openAPI } from 'better-auth/plugins'; import { openAPI } from 'better-auth/plugins';
import { sendMail } from './mailer';
import { db } from './prisma'; import { db } from './prisma';
export const auth = betterAuth({ export const auth = betterAuth({
@@ -18,6 +19,26 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
user: {
additionalFields: {
phone: {
type: 'string',
required: false,
},
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendMail({
to: user.email,
subject: 'Verificá tu email en Playzer',
html: `Hacé click para verificar tu email: <a href="${url}">${url}</a>`,
text: `Hacé click para verificar tu email: ${url}`,
});
},
},
socialProviders: { socialProviders: {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID!, clientId: process.env.GOOGLE_CLIENT_ID!,

View File

@@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) {
city: payload.city, city: payload.city,
state: payload.state, state: payload.state,
country: payload.country, country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
}); });
return c.json(complex, 201); return c.json(complex, 201);

View File

@@ -11,8 +11,47 @@ export type CreateComplexInput = {
city?: string; city?: string;
state?: string; state?: string;
country?: 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 = { export type UpdateComplexInput = {
complexName?: string; complexName?: string;
physicalAddress?: string | null; 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; return complex;
}); });
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
);

View File

@@ -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,
};
}

View File

@@ -1,23 +1,24 @@
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
export async function listPlansHandler(c: AppContext) { export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({ const plans = await db.plan.findMany({
select: {
code: true,
name: true,
price: true,
},
orderBy: { orderBy: {
price: 'asc', price: 'asc',
}, },
}); });
return c.json( return c.json(
plans.map((plan) => ({ plans.map((plan) => {
const rules = parsePlanRules(plan.rules);
return {
code: plan.code, code: plan.code,
name: plan.name, name: plan.name,
price: Number(plan.price), price: Number(plan.price),
})) features: rules.features,
limits: rules.limits,
};
})
); );
} }

View File

@@ -3,7 +3,6 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
import { authRoutes } from '@/modules/auth/auth.routes'; import { authRoutes } from '@/modules/auth/auth.routes';
import { complexRoutes } from '@/modules/complex/complex.routes'; import { complexRoutes } from '@/modules/complex/complex.routes';
import { courtRoutes } from '@/modules/court/court.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 { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
import { planRoutes } from '@/modules/plan/plan.routes'; import { planRoutes } from '@/modules/plan/plan.routes';
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
@@ -20,7 +19,6 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('', authRoutes) .route('', authRoutes)
.route('/api/user', userRoutes) .route('/api/user', userRoutes)
.route('/api/plans', planRoutes) .route('/api/plans', planRoutes)
.route('/api/onboarding', onboardingRoutes)
.route('/api/password-reset', passwordResetRoutes) .route('/api/password-reset', passwordResetRoutes)
.route('/api/complexes', complexRoutes) .route('/api/complexes', complexRoutes)
.route('/api/sports', sportRoutes) .route('/api/sports', sportRoutes)

View File

@@ -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(); autoSelect();
} }
}, [ }, [
currentComplexQuery.isLoading, currentComplexQuery.isLoading,
currentComplexQuery.data, currentComplexQuery.data,
myComplexesQuery.data, myComplexesQuery.data,
myComplexesQuery.isLoading,
navigate, navigate,
queryClient, queryClient,
]); ]);

View File

@@ -6,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations'; import { acceptStoredPendingInvite } from '@/lib/invitations';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -18,12 +18,7 @@ const loginSchema = z.object({
type LoginForm = z.infer<typeof loginSchema>; type LoginForm = z.infer<typeof loginSchema>;
type LoginPageProps = { export function LoginPage() {
redirectTo?: string;
};
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate();
const { signInWithPassword } = useAuth(); const { signInWithPassword } = useAuth();
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
@@ -42,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
async function handlePostLogin() { async function handlePostLogin() {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); 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) => { const onSubmit = async (values: LoginForm) => {
@@ -91,7 +76,7 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm"> <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">Iniciar sesión</h1> <h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
<p className="mb-4 text-sm text-muted-foreground"> <p className="mb-4 text-sm text-muted-foreground">
Ingresá con tu cuenta para acceder a Home. Ingresá con tu cuenta para acceder a Playzer.
</p> </p>
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}> <Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
@@ -161,6 +146,10 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
<Link to="/reset-password" className="text-primary hover:underline"> <Link to="/reset-password" className="text-primary hover:underline">
¿Olvidaste tu contraseña? ¿Olvidaste tu contraseña?
</Link> </Link>
<span className="mx-3 text-muted-foreground">|</span>
<Link to="/signup" className="text-primary hover:underline">
Crear cuenta
</Link>
</p> </p>
</section> </section>
</main> </main>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">, 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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'>;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,232 @@
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
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.'),
phone: z
.string()
.optional()
.refine(
(val) => !val || val.replace(/\D/g, '').length >= 7,
'El teléfono debe tener al menos 7 dígitos.'
),
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 SignupForm = z.infer<typeof signupSchema>;
const STORAGE_KEY = 'signup-form-pending';
function loadFormState(): Partial<SignupForm> {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch {}
return {};
}
export function SignupPage() {
const navigate = useNavigate();
const { signUp } = useAuth();
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid },
watch,
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
defaultValues: loadFormState(),
});
useEffect(() => {
const sub = watch((values) => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(values));
});
return () => sub.unsubscribe();
}, [watch]);
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.');
}
};
const onSubmit = async (values: SignupForm) => {
setSubmitError(null);
setIsSubmitting(true);
try {
const { requiresEmailConfirmation } = await signUp({
email: values.email,
password: values.password,
fullName: values.fullName,
phone: values.phone || undefined,
});
sessionStorage.removeItem(STORAGE_KEY);
if (requiresEmailConfirmation) {
sessionStorage.setItem('pending-signup-email', values.email);
await navigate({ to: '/onboard/setup' });
}
} catch {
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
} finally {
setIsSubmitting(false);
}
};
return (
<main className="flex min-h-screen w-full flex-col items-center justify-center gap-6 px-3 sm:px-6">
<Link to="/" className="group flex items-center gap-3">
<div className="flex size-10 items-center justify-center">
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
</div>
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
Playzer
</span>
</Link>
<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 cuenta</h1>
<p className="mb-4 text-sm text-muted-foreground">
Creá tu cuenta para comenzar a usar Playzer.
</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>
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.fullName)}>
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
<Input
id="fullName"
type="text"
placeholder="Juan Pérez"
aria-invalid={Boolean(errors.fullName)}
{...register('fullName')}
/>
<FieldError errors={[errors.fullName]} />
</Field>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field data-invalid={Boolean(errors.phone)}>
<FieldLabel htmlFor="phone">
Teléfono <span className="text-muted-foreground">(opcional)</span>
</FieldLabel>
<Input
id="phone"
type="tel"
placeholder="+54 11 1234-5678"
aria-invalid={Boolean(errors.phone)}
{...register('phone')}
/>
<FieldError errors={[errors.phone]} />
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
<FieldError errors={[errors.password]} />
</Field>
<Field data-invalid={Boolean(errors.confirmPassword)}>
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.confirmPassword)}
{...register('confirmPassword')}
/>
<FieldError errors={[errors.confirmPassword]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
¿Ya tenés cuenta?{' '}
<Link to="/login" className="text-primary hover:underline">
Iniciar sesión
</Link>
</p>
</section>
</main>
);
}

View File

@@ -1,10 +1,21 @@
import { sentinelClient } from '@better-auth/infra/client'; import { sentinelClient } from '@better-auth/infra/client';
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react'; import { createAuthClient } from 'better-auth/react';
import * as api from './api'; import * as api from './api';
const apiBaseUrl = api.apiBaseUrl; const apiBaseUrl = api.apiBaseUrl;
const plugins = [sentinelClient()]; const plugins = [
sentinelClient(),
inferAdditionalFields({
user: {
phone: {
type: 'string',
required: false,
},
},
}),
];
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: apiBaseUrl, baseURL: apiBaseUrl,
@@ -32,7 +43,6 @@ export class ApiClientError extends Error {
} }
export const apiClient = { export const apiClient = {
onboarding: api.onboarding,
plans: api.plans, plans: api.plans,
user: api.user, user: api.user,
complexes: api.complexes, complexes: api.complexes,

View File

@@ -6,7 +6,6 @@ export * as courts from './resources/courts';
export * as user from './resources/user'; export * as user from './resources/user';
export * as sports from './resources/sports'; export * as sports from './resources/sports';
export * as plans from './resources/plans'; export * as plans from './resources/plans';
export * as onboarding from './resources/onboarding';
export { export {
getAvailability, getAvailability,

View File

@@ -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;
}

View File

@@ -1,7 +1,7 @@
import type { PlanSummary } from '@repo/api-contract'; import type { PlanWithFeatures } from '@repo/api-contract';
import { http } from '../http'; import { http } from '../http';
export async function list() { export async function list() {
const response = await http.get<PlanSummary[]>('/api/plans'); const response = await http.get<PlanWithFeatures[]>('/api/plans');
return response.data; return response.data;
} }

View File

@@ -13,6 +13,7 @@ type SignUpParams = {
email: string; email: string;
password: string; password: string;
fullName: string; fullName: string;
phone?: string;
}; };
type UpdateProfileParams = { type UpdateProfileParams = {
@@ -162,11 +163,12 @@ export function AuthProvider({ children }: PropsWithChildren) {
throw error; throw error;
} }
}, },
signUp: async ({ email, password, fullName }) => { signUp: async ({ email, password, fullName, phone }) => {
const { data: signUpData, error } = await authClient.signUp.email({ const { data: signUpData, error } = await authClient.signUp.email({
email, email,
password, password,
name: fullName, name: fullName,
phone,
}); });
if (error) { if (error) {

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as SelectComplexRouteImport } from './routes/select-complex' import { Route as SelectComplexRouteImport } from './routes/select-complex'
import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as OnboardRouteImport } from './routes/onboard' import { Route as OnboardRouteImport } from './routes/onboard'
@@ -16,11 +17,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as InviteRouteImport } from './routes/invite' import { Route as InviteRouteImport } from './routes/invite'
import { Route as AuthCallbackRouteImport } from './routes/auth-callback' import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as OnboardIndexRouteImport } from './routes/onboard/index' import { Route as OnboardSetupRouteImport } from './routes/onboard/setup'
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
import { Route as AppProfileRouteImport } from './routes/_app/profile' import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking' import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route' import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
@@ -29,6 +26,11 @@ import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSl
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode' import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit' import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const SelectComplexRoute = SelectComplexRouteImport.update({ const SelectComplexRoute = SelectComplexRouteImport.update({
id: '/select-complex', id: '/select-complex',
path: '/select-complex', path: '/select-complex',
@@ -63,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app', id: '/_app',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const OnboardIndexRoute = OnboardIndexRouteImport.update({ const OnboardSetupRoute = OnboardSetupRouteImport.update({
id: '/', id: '/setup',
path: '/', path: '/setup',
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',
getParentRoute: () => OnboardRoute, getParentRoute: () => OnboardRoute,
} as any) } as any)
const AppProfileRoute = AppProfileRouteImport.update({ const AppProfileRoute = AppProfileRouteImport.update({
@@ -133,13 +115,10 @@ export interface FileRoutesByFullPath {
'/onboard': typeof OnboardRouteWithChildren '/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute '/select-complex': typeof SelectComplexRoute
'/signup': typeof SignupRoute
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -149,14 +128,12 @@ export interface FileRoutesByTo {
'/auth-callback': typeof AuthCallbackRoute '/auth-callback': typeof AuthCallbackRoute
'/invite': typeof InviteRoute '/invite': typeof InviteRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute '/select-complex': typeof SelectComplexRoute
'/signup': typeof SignupRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard': typeof OnboardIndexRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -170,14 +147,11 @@ export interface FileRoutesById {
'/onboard': typeof OnboardRouteWithChildren '/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute '/select-complex': typeof SelectComplexRoute
'/signup': typeof SignupRoute
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/profile': typeof AppProfileRoute '/_app/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
@@ -193,13 +167,10 @@ export interface FileRouteTypes {
| '/onboard' | '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex' | '/select-complex'
| '/signup'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit' | '/complex/$slug/edit'
@@ -209,14 +180,12 @@ export interface FileRouteTypes {
| '/auth-callback' | '/auth-callback'
| '/invite' | '/invite'
| '/login' | '/login'
| '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex' | '/select-complex'
| '/signup'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit' | '/complex/$slug/edit'
@@ -229,14 +198,11 @@ export interface FileRouteTypes {
| '/onboard' | '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex' | '/select-complex'
| '/signup'
| '/_app/_authenticated' | '/_app/_authenticated'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/_app/profile' | '/_app/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/_app/_authenticated/' | '/_app/_authenticated/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
@@ -251,11 +217,19 @@ export interface RootRouteChildren {
OnboardRoute: typeof OnboardRouteWithChildren OnboardRoute: typeof OnboardRouteWithChildren
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
SelectComplexRoute: typeof SelectComplexRoute SelectComplexRoute: typeof SelectComplexRoute
SignupRoute: typeof SignupRoute
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/select-complex': { '/select-complex': {
id: '/select-complex' id: '/select-complex'
path: '/select-complex' path: '/select-complex'
@@ -305,39 +279,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppRouteRouteImport preLoaderRoute: typeof AppRouteRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/onboard/': { '/onboard/setup': {
id: '/onboard/' id: '/onboard/setup'
path: '/' path: '/setup'
fullPath: '/onboard/' fullPath: '/onboard/setup'
preLoaderRoute: typeof OnboardIndexRouteImport preLoaderRoute: typeof OnboardSetupRouteImport
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
parentRoute: typeof OnboardRoute parentRoute: typeof OnboardRoute
} }
'/_app/profile': { '/_app/profile': {
@@ -422,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
) )
interface OnboardRouteChildren { interface OnboardRouteChildren {
OnboardCompleteRoute: typeof OnboardCompleteRoute OnboardSetupRoute: typeof OnboardSetupRoute
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
OnboardVerifyRoute: typeof OnboardVerifyRoute
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
OnboardIndexRoute: typeof OnboardIndexRoute
} }
const OnboardRouteChildren: OnboardRouteChildren = { const OnboardRouteChildren: OnboardRouteChildren = {
OnboardCompleteRoute: OnboardCompleteRoute, OnboardSetupRoute: OnboardSetupRoute,
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
OnboardVerifyRoute: OnboardVerifyRoute,
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
OnboardIndexRoute: OnboardIndexRoute,
} }
const OnboardRouteWithChildren = const OnboardRouteWithChildren =
@@ -462,6 +400,7 @@ const rootRouteChildren: RootRouteChildren = {
OnboardRoute: OnboardRouteWithChildren, OnboardRoute: OnboardRouteWithChildren,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
SelectComplexRoute: SelectComplexRoute, SelectComplexRoute: SelectComplexRoute,
SignupRoute: SignupRoute,
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren, ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -18,7 +18,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}); });
const AUTH_PAGE_REGEX = const AUTH_PAGE_REGEX =
/^\/(login|select-complex|reset-password|onboard|invite|auth-callback)(?:\/|$)/; /^\/(login|signup|select-complex|reset-password|onboard|invite|auth-callback)(?:\/|$)/;
function RootRoute() { function RootRoute() {
const location = useLocation(); const location = useLocation();

View File

@@ -17,23 +17,9 @@ function AuthCallbackPage() {
try { try {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); 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: '/' }); 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 { } catch {
navigate({ to: '/onboard' }); navigate({ to: '/login' });
} }
} }

View File

@@ -1,4 +1,3 @@
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations'; import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations';
@@ -25,6 +24,7 @@ function InvitePage() {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
navigate({ to: '/login' });
return; return;
} }
@@ -34,7 +34,6 @@ function InvitePage() {
})(); })();
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
if (isAuthenticated) {
return ( return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6"> <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> <p className="text-sm text-muted-foreground">Aceptando invitación...</p>
@@ -42,9 +41,6 @@ function InvitePage() {
); );
} }
return <OnboardLoginPage variant="invite" inviteEmail={search.email ?? null} />;
}
export const Route = createFileRoute('/invite')({ export const Route = createFileRoute('/invite')({
validateSearch: inviteSearchSchema, validateSearch: inviteSearchSchema,
component: InvitePage, component: InvitePage,

View File

@@ -1,22 +1,11 @@
import { LoginPage } from '@/features/login/login-page'; import { LoginPage } from '@/features/login/login-page';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
const loginSearchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
validateSearch: loginSearchSchema,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) { if (context.auth.isAuthenticated) {
throw redirect({ to: '/' }); throw redirect({ to: '/' });
} }
}, },
component: LoginRouteComponent, component: LoginPage,
}); });
function LoginRouteComponent() {
const search = Route.useSearch();
return <LoginPage redirectTo={search.redirect} />;
}

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/create-complex')({ export const Route = createFileRoute('/onboard/setup')({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const hasSession = context.auth.isAuthenticated; const hasSession = context.auth.isAuthenticated;
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email')); const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasSession && !hasPendingSignup) { if (!hasSession && !hasPendingSignup) {
throw redirect({ to: '/onboard' }); throw redirect({ to: '/login' });
} }
}, },
component: CreateComplexPage, component: SetupStepperPage,
}); });

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -0,0 +1,11 @@
import { SignupPage } from '@/features/signup/signup-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/signup')({
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/' });
}
},
component: SignupPage,
});

View File

@@ -1,5 +1,15 @@
import { z } from 'zod' import { z } from 'zod'
const dayOfWeekEnum = z.enum([
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
])
export const complexSchema = z.object({ export const complexSchema = z.object({
id: z.uuid(), id: z.uuid(),
complexName: z.string(), complexName: z.string(),
@@ -14,7 +24,8 @@ export const complexSchema = z.object({
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}) })
export const createComplexSchema = z.object({ export const createComplexSchema = z
.object({
complexName: z complexName: z
.string() .string()
.trim() .trim()
@@ -46,6 +57,43 @@ export const createComplexSchema = z.object({
.min(1, 'El planCode no puede estar vacío.') .min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.') .max(10, 'El planCode no puede superar los 10 caracteres.')
.optional(), .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 export const updateComplexSchema = z

View File

@@ -4,28 +4,18 @@ export {
planRulesV1Schema, planRulesV1Schema,
planSchema, planSchema,
planSummarySchema, planSummarySchema,
planWithFeaturesSchema,
planLimitsSchema,
} from './plan' } from './plan'
export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan'
export {
onboardingCompleteResponseSchema,
onboardingCompleteSchema,
onboardingResendOtpResponseSchema,
onboardingResendOtpSchema,
onboardingStartResponseSchema,
onboardingStartSchema,
onboardingVerifyOtpResponseSchema,
onboardingVerifyOtpSchema,
} from './onboarding'
export type { export type {
OnboardingCompleteInput, Plan,
OnboardingCompleteResponse, PlanFeatureFlags,
OnboardingResendOtpInput, PlanRules,
OnboardingResendOtpResponse, PlanRulesV1,
OnboardingStartInput, PlanSummary,
OnboardingStartResponse, PlanWithFeatures,
OnboardingVerifyOtpInput, PlanLimits,
OnboardingVerifyOtpResponse, } from './plan'
} from './onboarding'
export { export {
acceptComplexInvitationsSchema, acceptComplexInvitationsSchema,
complexInvitationSchema, complexInvitationSchema,

View File

@@ -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
>

View File

@@ -49,8 +49,25 @@ export const planSummarySchema = z.object({
price: z.number().nonnegative(), 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 PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema> export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
export type PlanRules = z.infer<typeof planRulesSchema> export type PlanRules = z.infer<typeof planRulesSchema>
export type Plan = z.infer<typeof planSchema> export type Plan = z.infer<typeof planSchema>
export type PlanSummary = z.infer<typeof planSummarySchema> export type PlanSummary = z.infer<typeof planSummarySchema>
export type PlanWithFeatures = z.infer<typeof planWithFeaturesSchema>
export type PlanLimits = z.infer<typeof planLimitsSchema>