From a92e9e77c3d35570bec13c8bbbc23b8f98582bbe Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 15 Sep 2026 15:41:45 -0300 Subject: [PATCH] feat: basic onboarding flow\ --- apps/backend/prisma/models/auth.prisma | 18 +- apps/backend/prisma/models/domain.prisma | 47 ++- apps/backend/src/app.ts | 2 + apps/backend/src/http/problem-builders.ts | 14 + .../create-from-organization/use-case.ts | 11 +- .../groups/features/get-all/use-case.ts | 16 +- .../backend/src/modules/groups/lib/helpers.ts | 18 +- .../onboarding/features/first-group/route.ts | 20 ++ .../features/first-group/use-case.ts | 88 ++++++ .../features/payment-setup/route.ts | 20 ++ .../features/payment-setup/use-case.ts | 38 +++ .../onboarding/features/status/route.ts | 16 + .../onboarding/features/status/use-case.ts | 38 +++ apps/backend/src/modules/onboarding/index.ts | 1 + .../src/modules/onboarding/lib/helpers.ts | 35 +++ .../src/modules/onboarding/lib/index.ts | 1 + apps/backend/src/modules/onboarding/routes.ts | 12 + apps/backend/test/groups.test.ts | 6 + apps/backend/test/onboarding.test.ts | 281 ++++++++++++++++++ .../onboarding/ConfirmationStep.tsx | 84 ++++++ .../components/onboarding/FirstGroupStep.tsx | 236 +++++++++++++++ .../src/components/onboarding/PaymentStep.tsx | 171 +++++++++++ .../web/src/components/onboarding/Stepper.tsx | 59 ++++ .../src/components/onboarding/WelcomeStep.tsx | 63 ++++ .../src/components/onboarding/constants.ts | 44 +++ apps/web/src/components/onboarding/index.ts | 5 + apps/web/src/index.css | 10 +- apps/web/src/lib/api.ts | 65 ++++ apps/web/src/lib/format.ts | 26 ++ apps/web/src/main.tsx | 15 +- apps/web/src/router.tsx | 8 + apps/web/src/routes/groups.tsx | 111 ++++++- apps/web/src/routes/onboarding.tsx | 131 ++++++++ packages/shared/src/index.ts | 2 + packages/shared/src/schemas/enums.ts | 21 ++ packages/shared/src/schemas/groups.ts | 8 + packages/shared/src/schemas/onboarding.ts | 76 +++++ 37 files changed, 1775 insertions(+), 42 deletions(-) create mode 100644 apps/backend/src/modules/onboarding/features/first-group/route.ts create mode 100644 apps/backend/src/modules/onboarding/features/first-group/use-case.ts create mode 100644 apps/backend/src/modules/onboarding/features/payment-setup/route.ts create mode 100644 apps/backend/src/modules/onboarding/features/payment-setup/use-case.ts create mode 100644 apps/backend/src/modules/onboarding/features/status/route.ts create mode 100644 apps/backend/src/modules/onboarding/features/status/use-case.ts create mode 100644 apps/backend/src/modules/onboarding/index.ts create mode 100644 apps/backend/src/modules/onboarding/lib/helpers.ts create mode 100644 apps/backend/src/modules/onboarding/lib/index.ts create mode 100644 apps/backend/src/modules/onboarding/routes.ts create mode 100644 apps/backend/test/onboarding.test.ts create mode 100644 apps/web/src/components/onboarding/ConfirmationStep.tsx create mode 100644 apps/web/src/components/onboarding/FirstGroupStep.tsx create mode 100644 apps/web/src/components/onboarding/PaymentStep.tsx create mode 100644 apps/web/src/components/onboarding/Stepper.tsx create mode 100644 apps/web/src/components/onboarding/WelcomeStep.tsx create mode 100644 apps/web/src/components/onboarding/constants.ts create mode 100644 apps/web/src/components/onboarding/index.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/routes/onboarding.tsx create mode 100644 packages/shared/src/schemas/enums.ts create mode 100644 packages/shared/src/schemas/onboarding.ts diff --git a/apps/backend/prisma/models/auth.prisma b/apps/backend/prisma/models/auth.prisma index cc10dc1..10c38d8 100644 --- a/apps/backend/prisma/models/auth.prisma +++ b/apps/backend/prisma/models/auth.prisma @@ -6,17 +6,19 @@ model User { email String @unique emailVerified Boolean @default(false) image String? + onboardingCompleted Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - accounts Account[] - sessions Session[] - passkeys Passkey[] - groupsMember GroupMember[] - groupsOwner Group[] @relation("OwnerGroups") - orgMemberships Member[] - orgInvitations Invitation[] @relation("InvitedBy") - waitlist WaitlistEntry[] + accounts Account[] + sessions Session[] + passkeys Passkey[] + groupsMember GroupMember[] + groupsOwner Group[] @relation("OwnerGroups") + orgMemberships Member[] + orgInvitations Invitation[] @relation("InvitedBy") + waitlist WaitlistEntry[] + merchantAccount MerchantAccount? @@map("users") } diff --git a/apps/backend/prisma/models/domain.prisma b/apps/backend/prisma/models/domain.prisma index 1b13f2c..e800ccf 100644 --- a/apps/backend/prisma/models/domain.prisma +++ b/apps/backend/prisma/models/domain.prisma @@ -1,12 +1,18 @@ // Domain models model Group { - id String @id @default(cuid()) + id String @id @default(cuid()) name String description String? + days WeekDay[] + time String? + capacity Int? + price Decimal? @db.Decimal(10, 2) + billingType BillingType? + dueDay Int? createdById String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade) members GroupMember[] @@ -16,6 +22,21 @@ model Group { @@map("groups") } +enum WeekDay { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + +enum BillingType { + MONTHLY + PER_CLASS +} + model GroupMember { id String @id @default(cuid()) groupId String @@ -103,4 +124,24 @@ enum WaitlistStatus { INVITED JOINED DECLINED +} + +enum PaymentProvider { + MERCADO_PAGO + STRIPE +} + +model MerchantAccount { + id String @id @default(cuid()) + userId String @unique + provider PaymentProvider + accessToken String? + sandbox Boolean @default(true) + connectedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("merchant_accounts") } \ No newline at end of file diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 4faf921..ca64c50 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -13,6 +13,7 @@ import { logger } from '@/logger'; import { authRoutes } from './modules/auth'; import { groupsRoutes } from './modules/groups'; import { healthCheckRoutes } from './modules/health-check'; +import { onboardingRoutes } from './modules/onboarding'; import { paymentsRoutes } from './modules/payments'; import { studentsRoutes } from './modules/students'; import { waitlistRoutes } from './modules/waitlist'; @@ -33,6 +34,7 @@ api.use('*', sessionAuthMiddleware); api.route('/auth', authRoutes); api.route('/health', healthCheckRoutes); api.route('/groups', groupsRoutes); +api.route('/onboarding', onboardingRoutes); api.route('/students', studentsRoutes); api.route('/payments', paymentsRoutes); api.route('/waitlist', waitlistRoutes); diff --git a/apps/backend/src/http/problem-builders.ts b/apps/backend/src/http/problem-builders.ts index 1b63bd7..282283b 100644 --- a/apps/backend/src/http/problem-builders.ts +++ b/apps/backend/src/http/problem-builders.ts @@ -86,4 +86,18 @@ export function noGroupAccessProblem(): ProblemDetails { export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails { return notFoundProblem(`${resourceName} ${id}`); +} + +export function paymentNotSetupProblem(): ProblemDetails { + return conflictProblem({ + detail: 'Vinculá una cuenta de cobro antes de crear tu primer grupo.', + code: 'payment_not_setup', + }); +} + +export function onboardingAlreadyCompletedProblem(): ProblemDetails { + return conflictProblem({ + detail: 'Ya completaste el onboarding de Gruperly.', + code: 'onboarding_already_completed', + }); } \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts b/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts index 7dfa383..c3dceba 100644 --- a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts +++ b/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts @@ -12,22 +12,13 @@ import { organizationNotFoundProblem, } from '@/http/problem-builders'; import { default as prisma, UnitOfWork } from '@/lib/prisma'; -import { type GroupDb, toGroupDto } from '../../lib'; +import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib'; type CreateGroupFromOrganizationDeps = { db?: Pick; unitOfWork?: UnitOfWork; }; -type GroupRecord = { - id: string; - name: string; - description: string | null; - createdById: string; - createdAt: Date; - updatedAt: Date; -}; - export class CreateGroupFromOrganization { constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {} diff --git a/apps/backend/src/modules/groups/features/get-all/use-case.ts b/apps/backend/src/modules/groups/features/get-all/use-case.ts index 6fbcd53..5da6ee2 100644 --- a/apps/backend/src/modules/groups/features/get-all/use-case.ts +++ b/apps/backend/src/modules/groups/features/get-all/use-case.ts @@ -8,21 +8,17 @@ import type { import { ok } from '@gruperly/shared'; import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination'; import prisma from '@/lib/prisma'; -import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib'; +import { + buildGroupWhereForUser, + type GroupDb, + type GroupRecord, + toGroupDto, +} from '../../lib'; type ListGroupsDeps = { db?: Pick; }; -type GroupRecord = { - id: string; - name: string; - description: string | null; - createdById: string; - createdAt: Date; - updatedAt: Date; -}; - export class ListGroups { constructor(private readonly deps: ListGroupsDeps = {}) {} diff --git a/apps/backend/src/modules/groups/lib/helpers.ts b/apps/backend/src/modules/groups/lib/helpers.ts index b136d99..a2a44be 100644 --- a/apps/backend/src/modules/groups/lib/helpers.ts +++ b/apps/backend/src/modules/groups/lib/helpers.ts @@ -1,15 +1,23 @@ import type { PrismaClient } from '@generated/prisma/client'; -import type { GroupDto } from '@gruperly/shared'; +import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared'; export type GroupDb = Pick; -type GroupRecord = { +type PriceLike = { toString(): string }; + +export type GroupRecord = { id: string; name: string; description: string | null; createdById: string; createdAt: Date; updatedAt: Date; + days: WeekDay[] | null; + time: string | null; + capacity: number | null; + price: PriceLike | number | null; + billingType: BillingType | null; + dueDay: number | null; }; export function toGroupDto(record: GroupRecord): GroupDto { @@ -20,6 +28,12 @@ export function toGroupDto(record: GroupRecord): GroupDto { createdById: record.createdById, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), + days: record.days, + time: record.time, + capacity: record.capacity, + price: record.price == null ? null : Number(record.price.toString()), + billingType: record.billingType, + dueDay: record.dueDay, }; } diff --git a/apps/backend/src/modules/onboarding/features/first-group/route.ts b/apps/backend/src/modules/onboarding/features/first-group/route.ts new file mode 100644 index 0000000..662a3ae --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/first-group/route.ts @@ -0,0 +1,20 @@ +import type { CreateFirstGroup } from '@gruperly/shared'; +import { CreateFirstGroupSchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { CreateFirstGroup as CreateFirstGroupUseCase } from './use-case'; + +const route = new Hono(); + +route.post('/', validate.json(CreateFirstGroupSchema), async (c) => { + const user = c.get('user'); + if (!user) return problemJson(c, unauthorizedProblem(c.req.path)); + + const data = c.req.valid('json') as CreateFirstGroup; + const useCase = new CreateFirstGroupUseCase(); + const result = await useCase.execute(data, user.id); + return resultJson(c, result, { status: 201 }); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/features/first-group/use-case.ts b/apps/backend/src/modules/onboarding/features/first-group/use-case.ts new file mode 100644 index 0000000..a2d8e4b --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/first-group/use-case.ts @@ -0,0 +1,88 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import { Role } from '@generated/prisma/client'; +import type { + CreateFirstGroup as CreateFirstGroupInput, + CreateFirstGroupResult, + ProblemDetails, + Result, +} from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { + onboardingAlreadyCompletedProblem, + paymentNotSetupProblem, +} from '@/http/problem-builders'; +import { problemDetails } from '@/http/problem-details'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { toOnboardingGroupDto } from '../../lib'; + +type CreateFirstGroupDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +export class CreateFirstGroup { + constructor(private readonly deps: CreateFirstGroupDeps = {}) {} + + async execute( + data: CreateFirstGroupInput, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + return err(problemDetails({ title: 'Not Found', status: 404, code: 'user_not_found', detail: 'Usuario no encontrado.' })); + } + if (user.onboardingCompleted) { + return err(onboardingAlreadyCompletedProblem()); + } + + const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } }); + if (!merchantAccount) { + return err(paymentNotSetupProblem()); + } + + const transaction = unitOfWork.executeResult( + async (tx: Prisma.TransactionClient) => { + const created = await tx.group.create({ + data: { + name: data.name, + createdById: userId, + days: data.days, + time: data.time, + capacity: data.capacity, + price: data.price, + billingType: data.billingType, + dueDay: data.dueDay, + }, + }); + + await tx.groupMember.create({ + data: { + groupId: created.id, + userId, + role: Role.OWNER, + }, + }); + + await tx.user.update({ + where: { id: userId }, + data: { onboardingCompleted: true }, + }); + + return ok(created); + }, + ); + + const result = await transaction; + if (!result.ok) { + return result; + } + + return ok({ + group: toOnboardingGroupDto(result.value), + onboardingCompleted: true, + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/features/payment-setup/route.ts b/apps/backend/src/modules/onboarding/features/payment-setup/route.ts new file mode 100644 index 0000000..83f94ec --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/payment-setup/route.ts @@ -0,0 +1,20 @@ +import type { ConnectPayment } from '@gruperly/shared'; +import { ConnectPaymentSchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { PaymentSetup } from './use-case'; + +const route = new Hono(); + +route.post('/', validate.json(ConnectPaymentSchema), async (c) => { + const user = c.get('user'); + if (!user) return problemJson(c, unauthorizedProblem(c.req.path)); + + const data = c.req.valid('json') as ConnectPayment; + const useCase = new PaymentSetup(); + const result = await useCase.execute(data, user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/features/payment-setup/use-case.ts b/apps/backend/src/modules/onboarding/features/payment-setup/use-case.ts new file mode 100644 index 0000000..d266212 --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/payment-setup/use-case.ts @@ -0,0 +1,38 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { ConnectPayment, ConnectPaymentResult, ProblemDetails, Result } from '@gruperly/shared'; +import { ok } from '@gruperly/shared'; +import prisma from '@/lib/prisma'; + +type PaymentSetupDeps = { db?: PrismaClient }; + +export class PaymentSetup { + constructor(private readonly deps: PaymentSetupDeps = {}) {} + + async execute( + data: ConnectPayment, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + + await db.merchantAccount.upsert({ + where: { userId }, + create: { + userId, + provider: data.provider, + accessToken: data.accessToken, + sandbox: data.sandbox, + }, + update: { + provider: data.provider, + accessToken: data.accessToken, + sandbox: data.sandbox, + }, + }); + + return ok({ + provider: data.provider, + sandbox: data.sandbox, + connected: true, + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/features/status/route.ts b/apps/backend/src/modules/onboarding/features/status/route.ts new file mode 100644 index 0000000..0d66649 --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/status/route.ts @@ -0,0 +1,16 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { GetOnboardingStatus } from './use-case'; + +const route = new Hono(); + +route.get('/', async (c) => { + const user = c.get('user'); + if (!user) return problemJson(c, unauthorizedProblem(c.req.path)); + + const useCase = new GetOnboardingStatus(); + const result = await useCase.execute(user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/features/status/use-case.ts b/apps/backend/src/modules/onboarding/features/status/use-case.ts new file mode 100644 index 0000000..00852f5 --- /dev/null +++ b/apps/backend/src/modules/onboarding/features/status/use-case.ts @@ -0,0 +1,38 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { OnboardingStatusDto, ProblemDetails, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { problemDetails } from '@/http/problem-details'; +import prisma from '@/lib/prisma'; + +type GetOnboardingStatusDeps = { db?: PrismaClient }; + +export class GetOnboardingStatus { + constructor(private readonly deps: GetOnboardingStatusDeps = {}) {} + + async execute(userId: string): Promise> { + const db = this.deps.db ?? prisma; + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + return err( + problemDetails({ + title: 'Not Found', + status: 404, + code: 'user_not_found', + detail: 'Usuario no encontrado.', + }), + ); + } + + if (user.onboardingCompleted) { + return ok({ step: 'COMPLETED', paymentConnected: true, completed: true }); + } + + const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } }); + if (merchantAccount) { + return ok({ step: 'PAYMENT_CONNECTED', paymentConnected: true, completed: false }); + } + + return ok({ step: 'NOT_STARTED', paymentConnected: false, completed: false }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/index.ts b/apps/backend/src/modules/onboarding/index.ts new file mode 100644 index 0000000..6c628e3 --- /dev/null +++ b/apps/backend/src/modules/onboarding/index.ts @@ -0,0 +1 @@ +export { default as onboardingRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/lib/helpers.ts b/apps/backend/src/modules/onboarding/lib/helpers.ts new file mode 100644 index 0000000..e12b447 --- /dev/null +++ b/apps/backend/src/modules/onboarding/lib/helpers.ts @@ -0,0 +1,35 @@ +import type { BillingType, OnboardingGroupDto, WeekDay } from '@gruperly/shared'; + +type PriceLike = { toString(): string }; + +export type OnboardingGroupRecord = { + id: string; + name: string; + description: string | null; + createdById: string; + createdAt: Date; + updatedAt: Date; + days: WeekDay[] | null; + time: string | null; + capacity: number | null; + price: PriceLike | number | null; + billingType: BillingType | null; + dueDay: number | null; +}; + +export function toOnboardingGroupDto(record: OnboardingGroupRecord): OnboardingGroupDto { + return { + id: record.id, + name: record.name, + description: record.description, + createdById: record.createdById, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + days: record.days, + time: record.time, + capacity: record.capacity, + price: record.price == null ? null : Number(record.price.toString()), + billingType: record.billingType, + dueDay: record.dueDay, + }; +} \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/lib/index.ts b/apps/backend/src/modules/onboarding/lib/index.ts new file mode 100644 index 0000000..c7c7ce0 --- /dev/null +++ b/apps/backend/src/modules/onboarding/lib/index.ts @@ -0,0 +1 @@ +export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers'; \ No newline at end of file diff --git a/apps/backend/src/modules/onboarding/routes.ts b/apps/backend/src/modules/onboarding/routes.ts new file mode 100644 index 0000000..5fb471f --- /dev/null +++ b/apps/backend/src/modules/onboarding/routes.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono'; +import firstGroupRoute from './features/first-group/route'; +import paymentSetupRoute from './features/payment-setup/route'; +import statusRoute from './features/status/route'; + +const routes = new Hono(); + +routes.route('/status', statusRoute); +routes.route('/payment-setup', paymentSetupRoute); +routes.route('/first-group', firstGroupRoute); + +export default routes; \ No newline at end of file diff --git a/apps/backend/test/groups.test.ts b/apps/backend/test/groups.test.ts index 4930fec..c70631f 100644 --- a/apps/backend/test/groups.test.ts +++ b/apps/backend/test/groups.test.ts @@ -36,6 +36,12 @@ const group = { createdById: userId, createdAt: new Date('2026-08-01T10:00:00.000Z'), updatedAt: new Date('2026-08-01T10:00:00.000Z'), + days: null, + time: null, + capacity: null, + price: null, + billingType: null, + dueDay: null, }; function makeApp(userValue: unknown) { diff --git a/apps/backend/test/onboarding.test.ts b/apps/backend/test/onboarding.test.ts new file mode 100644 index 0000000..8ca6d3d --- /dev/null +++ b/apps/backend/test/onboarding.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; +import { Hono } from 'hono'; + +const db = { + user: { + findUnique: mock(), + update: mock(), + }, + merchantAccount: { + findUnique: mock(), + upsert: mock(), + }, + group: { + create: mock(), + }, + groupMember: { + create: mock(), + }, +}; + +mock.module('@/lib/prisma', () => ({ + default: db, + getPrismaClient: mock(), + UnitOfWork: class { + executeResult = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + }, +})); + +import prisma from '@/lib/prisma'; +import { onboardingRoutes } from '@/modules/onboarding'; + +const userId = 'user-1'; + +function makeApp(userValue: unknown) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', userValue as never); + await next(); + }); + app.route('/onboarding', onboardingRoutes); + return app; +} + +const group = { + id: 'group-1', + name: 'Clase de Yoga', + description: null, + createdById: userId, + createdAt: new Date('2026-08-01T10:00:00.000Z'), + updatedAt: new Date('2026-08-01T10:00:00.000Z'), + days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], + time: '09:00', + capacity: 20, + price: 150.5, + billingType: 'MONTHLY', + dueDay: 5, +}; + +const firstGroupPayload = { + name: group.name, + days: group.days, + time: group.time, + capacity: group.capacity, + price: group.price, + billingType: group.billingType, + dueDay: group.dueDay, +}; + +describe('onboarding routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /onboarding/status', () => { + it('returns NOT_STARTED for a user that has not started', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); + prisma.merchantAccount.findUnique.mockResolvedValue(null); + + const res = await makeApp({ id: userId }).request('/onboarding/status'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + step: 'NOT_STARTED', + paymentConnected: false, + completed: false, + }); + }); + + it('returns PAYMENT_CONNECTED when the merchant account exists', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); + prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); + + const res = await makeApp({ id: userId }).request('/onboarding/status'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + step: 'PAYMENT_CONNECTED', + paymentConnected: true, + completed: false, + }); + }); + + it('returns COMPLETED when onboarding is already done', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true }); + + const res = await makeApp({ id: userId }).request('/onboarding/status'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + step: 'COMPLETED', + paymentConnected: true, + completed: true, + }); + }); + + it('rejects without a session', async () => { + const res = await makeApp(null).request('/onboarding/status'); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: 'unauthorized' }); + }); + }); + + describe('POST /onboarding/payment-setup', () => { + it('connects a merchant account in sandbox mode', async () => { + prisma.merchantAccount.upsert.mockResolvedValue({ id: 'ma-1', userId }); + + const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', { + method: 'POST', + body: JSON.stringify({ provider: 'MERCADO_PAGO', sandbox: true }), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + provider: 'MERCADO_PAGO', + sandbox: true, + connected: true, + }); + expect(prisma.merchantAccount.upsert).toHaveBeenCalledWith({ + where: { userId }, + create: { userId, provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true }, + update: { provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true }, + }); + }); + + it('rejects an unknown provider', async () => { + const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', { + method: 'POST', + body: JSON.stringify({ provider: 'PAYPAL' }), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(400); + }); + + it('rejects without a session', async () => { + const res = await makeApp(null).request('/onboarding/payment-setup', { + method: 'POST', + body: JSON.stringify({ provider: 'STRIPE', sandbox: true }), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(401); + }); + }); + + describe('POST /onboarding/first-group', () => { + it('creates the first group and completes onboarding', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); + prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); + prisma.group.create.mockResolvedValue(group); + + const res = await makeApp({ id: userId }).request('/onboarding/first-group', { + method: 'POST', + body: JSON.stringify(firstGroupPayload), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ + group: { + id: 'group-1', + name: 'Clase de Yoga', + description: null, + createdById: userId, + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), + days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], + time: '09:00', + capacity: 20, + price: 150.5, + billingType: 'MONTHLY', + dueDay: 5, + }, + onboardingCompleted: true, + }); + expect(prisma.group.create).toHaveBeenCalledWith({ + data: { + name: 'Clase de Yoga', + createdById: userId, + days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], + time: '09:00', + capacity: 20, + price: 150.5, + billingType: 'MONTHLY', + dueDay: 5, + }, + }); + expect(prisma.groupMember.create).toHaveBeenCalledWith({ + data: { groupId: 'group-1', userId, role: 'OWNER' }, + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: userId }, + data: { onboardingCompleted: true }, + }); + }); + + it('rejects when the payment has not been set up', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); + prisma.merchantAccount.findUnique.mockResolvedValue(null); + + const res = await makeApp({ id: userId }).request('/onboarding/first-group', { + method: 'POST', + body: JSON.stringify(firstGroupPayload), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: 'payment_not_setup' }); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('rejects when onboarding is already completed', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true }); + + const res = await makeApp({ id: userId }).request('/onboarding/first-group', { + method: 'POST', + body: JSON.stringify(firstGroupPayload), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: 'onboarding_already_completed' }); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('rejects invalid payloads', async () => { + prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); + prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); + + const res = await makeApp({ id: userId }).request('/onboarding/first-group', { + method: 'POST', + body: JSON.stringify({ + name: 'Ab', + days: [], + time: '25:99', + capacity: 0, + price: -1, + billingType: 'WEEKLY', + dueDay: 31, + }), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(400); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('rejects without a session', async () => { + const res = await makeApp(null).request('/onboarding/first-group', { + method: 'POST', + body: JSON.stringify(firstGroupPayload), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(res.status).toBe(401); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/onboarding/ConfirmationStep.tsx b/apps/web/src/components/onboarding/ConfirmationStep.tsx new file mode 100644 index 0000000..4d30615 --- /dev/null +++ b/apps/web/src/components/onboarding/ConfirmationStep.tsx @@ -0,0 +1,84 @@ +import { useNavigate } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' +import type { OnboardingGroupDto } from '@gruperly/shared' +import { ArrowRight, CheckCircle2, CreditCard } from 'lucide-react' +import { useAuth } from '../../context/AuthProvider' +import { Badge, Button } from '../ui' +import { BILLING_LABELS, formatPrice, formatSchedule } from './constants' + +type ConfirmationStepProps = { + group: OnboardingGroupDto + providerName?: string +} + +export function ConfirmationStep({ group, providerName }: ConfirmationStepProps) { + const navigate = useNavigate() + const queryClient = useQueryClient() + const { refresh } = useAuth() + + const goToDashboard = () => { + // Revalida la caché (estado del onboarding, sesión, grupos) antes de redirigir. + void queryClient.invalidateQueries() + void refresh() + void navigate({ to: '/' }) + } + + return ( +
+
+ + + +

¡Todo listo!

+

+ Tu grupo se creó y ya podés empezar a cobrar a tus alumnos. +

+
+ +
+
+
+

{group.name}

+

+ {formatSchedule(group.days ?? [], group.time)} +

+
+ Activo +
+ +
+
+
Precio
+
+ {formatPrice(group.price)} + {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} +
+
+
+
Cupo
+
{group.capacity ?? '—'} alumnos
+
+
+
Vencimiento
+
Día {group.dueDay ?? '—'} de cada mes
+
+
+
+ + {providerName ? ( +
+ +

+ Vas a cobrar con {providerName} en modo + prueba. +

+
+ ) : null} + + +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/FirstGroupStep.tsx b/apps/web/src/components/onboarding/FirstGroupStep.tsx new file mode 100644 index 0000000..d7d862e --- /dev/null +++ b/apps/web/src/components/onboarding/FirstGroupStep.tsx @@ -0,0 +1,236 @@ +import { useMutation } from '@tanstack/react-query' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared' +import { CreateFirstGroupSchema } from '@gruperly/shared' +import { ArrowLeft, Check, Loader2 } from 'lucide-react' +import { createFirstGroup } from '../../lib/api' +import { cn } from '../../lib/utils' +import { Button, Input, Label } from '../ui' +import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants' + +const defaultValues: CreateFirstGroup = { + name: '', + days: [], + time: '09:00', + capacity: 1, + price: 0, + billingType: 'MONTHLY', + dueDay: 1, +} + +type FirstGroupStepProps = { + onBack: () => void + onCompleted: (group: OnboardingGroupDto) => void +} + +export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) { + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(CreateFirstGroupSchema), + defaultValues, + mode: 'onTouched', + }) + + const days = watch('days') + const billingType = watch('billingType') + const dueDay = watch('dueDay') + + const create = useMutation({ + mutationFn: (values: CreateFirstGroup) => createFirstGroup(values), + onSuccess: (result) => onCompleted(result.group), + }) + + const toggleDay = (day: WeekDay) => { + const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day] + setValue('days', next, { shouldValidate: true }) + } + + const selectBillingType = (type: BillingType) => { + setValue('billingType', type, { shouldValidate: true }) + } + + return ( +
create.mutate(values))} noValidate className="space-y-5"> +
+

Tu primer grupo

+

+ Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos. +

+
+ +
+ + + {errors.name ?

{errors.name.message}

: null} +
+ +
+ +
+ {WEEK_DAY_CHIPS.map(({ value, label }) => { + const isActive = days.includes(value) + return ( + + ) + })} +
+ {errors.days ?

{errors.days.message}

: null} +
+ +
+ + + {errors.time ?

{errors.time.message}

: null} +
+ +
+
+ + + {errors.capacity ? ( +

{errors.capacity.message}

+ ) : null} +
+ +
+ +
+ + $ + + +
+ {errors.price ? ( +

{errors.price.message}

+ ) : null} +
+
+ +
+ +
+ {BILLING_TYPES.map(({ value, label, hint }) => { + const isActive = billingType === value + return ( + + ) + })} +
+
+ +
+ + +

+ Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. +

+ {errors.dueDay ? ( +

{errors.dueDay.message}

+ ) : null} +
+ + {create.isError ? ( +

+ No pudimos crear el grupo. Revisá los datos e intentá de nuevo. +

+ ) : null} + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/PaymentStep.tsx b/apps/web/src/components/onboarding/PaymentStep.tsx new file mode 100644 index 0000000..eda5b64 --- /dev/null +++ b/apps/web/src/components/onboarding/PaymentStep.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import type { ConnectPaymentResult, PaymentProvider } from '@gruperly/shared' +import { + ArrowLeft, + ArrowRight, + BadgeCheck, + Check, + CheckCircle2, + CreditCard, + Loader2, + ShieldCheck, +} from 'lucide-react' +import { connectPayment } from '../../lib/api' +import { cn } from '../../lib/utils' +import { Badge, Button } from '../ui' +import { PAYMENT_PROVIDERS } from './constants' + +type PaymentStepProps = { + initialResult: ConnectPaymentResult | null + onConnected: (result: ConnectPaymentResult) => void + onBack: () => void +} + +export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepProps) { + const [selected, setSelected] = useState('MERCADO_PAGO') + const [connected, setConnected] = useState(initialResult) + + const connect = useMutation({ + mutationFn: () => connectPayment({ provider: selected, sandbox: true }), + onSuccess: (result) => { + setConnected(result) + onConnected(result) + }, + }) + + if (connected) { + return ( +
+
+

Tu cobro está conectado

+

+ Ya podés dejar listo tu primer grupo para cobrar con{' '} + {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}. +

+
+ +
+ + + +

Cuenta conectada

+
+ + {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name} + + Modo prueba +
+
+ +
+ + +
+
+ ) + } + + return ( +
+
+

Elegí tu procesador de cobro

+

+ Los pagos de tus alumnos van a llegar por esta plataforma. +

+
+ +
+ {PAYMENT_PROVIDERS.map((provider) => { + const isSelected = selected === provider.value + return ( + + ) + })} +
+ +
+ +

+ Por ahora la conexión se hace en modo prueba (sandbox). Más adelante vas a poder + vincular tu cuenta real de {selected === 'STRIPE' ? 'Stripe' : 'Mercado Pago'}. +

+
+ + {connect.isError ? ( +

+ No pudimos conectar la cuenta. Intentalo de nuevo. +

+ ) : null} + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/Stepper.tsx b/apps/web/src/components/onboarding/Stepper.tsx new file mode 100644 index 0000000..7434c4c --- /dev/null +++ b/apps/web/src/components/onboarding/Stepper.tsx @@ -0,0 +1,59 @@ +import { Check } from 'lucide-react' +import { cn } from '../../lib/utils' + +export type StepperProps = { + steps: readonly { label: string }[] + current: number +} + +export function Stepper({ steps, current }: StepperProps) { + return ( +
    + {steps.map((step, index) => { + const isDone = index < current + const isActive = index === current + + return ( +
  1. +
    + + {isDone ? ( + + ) : ( + {index + 1} + )} + + + {step.label} + +
    + + {index < steps.length - 1 ? ( + + ) : null} +
  2. + ) + })} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/WelcomeStep.tsx b/apps/web/src/components/onboarding/WelcomeStep.tsx new file mode 100644 index 0000000..77abbe2 --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeStep.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react' +import { Button } from '../ui' + +type WelcomeStepProps = { + name: string + onNext: () => void +} + +const STEPS_TO_SETUP = [ + { + icon: Wallet, + title: 'Conectá tu cuenta de cobro', + description: 'Mercado Pago o Stripe, en modo prueba por ahora.', + }, + { + icon: Users, + title: 'Creá tu primer grupo', + description: 'Días, horario, precio y cupo de tu clase.', + }, + { + icon: Rocket, + title: 'Empezá a cobrar', + description: 'Todo listo para sumar alumnos y cobrar al instante.', + }, +] + +export function WelcomeStep({ name, onNext }: WelcomeStepProps) { + return ( +
+
+ + + +

¡Hola, {name}!

+

+ Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos. +

+
+ +
    + {STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => ( +
  1. + + + +
    +

    {title}

    +

    {description}

    +
    +
  2. + ))} +
+ + +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/constants.ts b/apps/web/src/components/onboarding/constants.ts new file mode 100644 index 0000000..308199a --- /dev/null +++ b/apps/web/src/components/onboarding/constants.ts @@ -0,0 +1,44 @@ +import type { BillingType, PaymentProvider, WeekDay } from '@gruperly/shared' +export { BILLING_LABELS, WEEK_DAY_FULL_LABELS, formatPrice, formatSchedule } from '../../lib/format' + +export const WEEK_DAYS: readonly WeekDay[] = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +] + +export const WEEK_DAY_CHIPS: readonly { value: WeekDay; label: string }[] = [ + { 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' }, +] + +export const PAYMENT_PROVIDERS: readonly { + value: PaymentProvider + name: string + description: string +}[] = [ + { + value: 'MERCADO_PAGO', + name: 'Mercado Pago', + description: 'El procesador más usado en Latinoamérica', + }, + { + value: 'STRIPE', + name: 'Stripe', + description: 'Cobrá con tarjetas e internacionalmente', + }, +] + +export const BILLING_TYPES: readonly { value: BillingType; label: string; hint: string }[] = [ + { value: 'MONTHLY', label: 'Mensual', hint: 'Un cobro por mes' }, + { value: 'PER_CLASS', label: 'Por clase', hint: 'Cada clase que asista' }, +] \ No newline at end of file diff --git a/apps/web/src/components/onboarding/index.ts b/apps/web/src/components/onboarding/index.ts new file mode 100644 index 0000000..02224ec --- /dev/null +++ b/apps/web/src/components/onboarding/index.ts @@ -0,0 +1,5 @@ +export { Stepper, type StepperProps } from './Stepper' +export { WelcomeStep } from './WelcomeStep' +export { PaymentStep } from './PaymentStep' +export { FirstGroupStep } from './FirstGroupStep' +export { ConfirmationStep } from './ConfirmationStep' \ No newline at end of file diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3ba5e04..b215de4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -22,15 +22,23 @@ /* Border radius por defecto */ --radius-xl: 0.75rem; - /* Animaciones */ +/* Animaciones */ --animate-fade-in: fade-in 0.2s ease-out; + --animate-step-enter: step-enter 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes step-enter { from { opacity: 0; + transform: translateX(1.5rem); } to { opacity: 1; + transform: translateX(0); } } } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..17d0311 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,65 @@ +import type { + ConnectPayment, + ConnectPaymentResult, + CreateFirstGroup, + CreateFirstGroupResult, + GroupList, + OnboardingStatusDto, + ProblemDetails, +} from '@gruperly/shared' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly problem: ProblemDetails | null, + ) { + super(problem?.title ?? `Error ${status}`) + this.name = 'ApiError' + } +} + +type JsonBody = Record | unknown[] + +async function apiFetch(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers) + headers.set('Content-Type', 'application/json') + + const res = await fetch(`${API_URL}${path}`, { + ...init, + headers, + credentials: 'include', + }) + + if (!res.ok) { + let problem: ProblemDetails | null = null + try { + const body = (await res.json()) as Partial + if (body && typeof body === 'object' && typeof body.title === 'string') { + problem = body as ProblemDetails + } + } catch { + // Sin cuerpo JSON: usamos el error genérico. + } + throw new ApiError(res.status, problem) + } + + return (await res.json()) as T +} + +export const getOnboardingStatus = () => apiFetch('/api/v1/onboarding/status') + +export const connectPayment = (payload: ConnectPayment) => + apiFetch('/api/v1/onboarding/payment-setup', { + method: 'POST', + body: JSON.stringify(payload), + }) + +export const createFirstGroup = (payload: CreateFirstGroup) => + apiFetch('/api/v1/onboarding/first-group', { + method: 'POST', + body: JSON.stringify(payload), + }) + +export const getGroups = () => apiFetch('/api/v1/groups') \ No newline at end of file diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..aa961b3 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,26 @@ +import type { BillingType, WeekDay } from '@gruperly/shared' + +export const WEEK_DAY_FULL_LABELS: Record = { + MONDAY: 'Lunes', + TUESDAY: 'Martes', + WEDNESDAY: 'Miércoles', + THURSDAY: 'Jueves', + FRIDAY: 'Viernes', + SATURDAY: 'Sábado', + SUNDAY: 'Domingo', +} + +export const BILLING_LABELS: Record = { + MONTHLY: 'Mensual', + PER_CLASS: 'Por clase', +} + +export function formatSchedule(days: readonly WeekDay[], time: string | null): string { + const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day]) + return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ') +} + +export function formatPrice(price: number | null): string { + if (price == null) return '—' + return price.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' }) +} \ No newline at end of file diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2889fd2..a81d111 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,23 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from '@tanstack/react-router' import { router } from './router' import { AuthProvider } from './context/AuthProvider' import './index.css' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: 1 }, + }, +}) + ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ) \ No newline at end of file diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 7463128..b87e3dd 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -10,6 +10,7 @@ import { OrganizationsPage } from './routes/organizations' import { LoginPage } from './routes/auth/login' import { SignupPage } from './routes/auth/signup' import { VerifyEmailPage } from './routes/auth/verify-email' +import { OnboardingView } from './routes/onboarding' const rootRoute = createRootRoute({ component: () => , @@ -33,6 +34,12 @@ const verifyEmailRoute = createRoute({ component: VerifyEmailPage, }) +const onboardingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/onboarding', + component: OnboardingView, +}) + // Capa con la navegación de la app autenticada (Sidebar + BottomNav). const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, @@ -86,6 +93,7 @@ const routeTree = rootRoute.addChildren([ loginRoute, signupRoute, verifyEmailRoute, + onboardingRoute, appLayoutRoute.addChildren([ indexRoute, groupsRoute, diff --git a/apps/web/src/routes/groups.tsx b/apps/web/src/routes/groups.tsx index 5ec543c..4326b2e 100644 --- a/apps/web/src/routes/groups.tsx +++ b/apps/web/src/routes/groups.tsx @@ -1,8 +1,109 @@ -export function GroupsView() { +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { CalendarClock, Loader2, Plus, Users } from 'lucide-react' +import type { GroupDto } from '@gruperly/shared' +import { Badge, Button } from '../components/ui' +import { getGroups } from '../lib/api' +import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format' + +function GroupCard({ group }: { group: GroupDto }) { + const hasSchedule = (group.days?.length ?? 0) > 0 + return ( -
-

Grupos

-

Tus grupos de cobranza.

-
+
+
+
+

{group.name}

+ {group.description ? ( +

{group.description}

+ ) : null} +
+ Activo +
+ + {hasSchedule ? ( +
+
+ + {formatSchedule(group.days ?? [], group.time)} +
+
+ + + Cupo {group.capacity ?? '—'} alumnos + {group.price != null + ? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}` + : ''} + {group.dueDay != null ? ` · vence el día ${group.dueDay}` : ''} + +
+
+ ) : ( +

Aún sin plan de cobro configurado.

+ )} +
) } + +export function GroupsView() { + const navigate = useNavigate() + const groupsQuery = useQuery({ + queryKey: ['groups'], + queryFn: getGroups, + }) + + return ( +
+
+
+

Grupos

+

Tus grupos de cobranza.

+
+ +
+ + {groupsQuery.isPending ? ( +
+ +
+ ) : null} + + {groupsQuery.isError ? ( +
+

No pudimos cargar tus grupos.

+ +
+ ) : null} + + {groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? ( +
+

Todavía no tenés grupos

+

+ Crea tu primer grupo para empezar a cobrar. +

+ +
+ ) : null} + + {groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? ( +
+ {groupsQuery.data.data.map((group) => ( + + ))} +
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx new file mode 100644 index 0000000..dd81a29 --- /dev/null +++ b/apps/web/src/routes/onboarding.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { Loader2 } from 'lucide-react' +import type { ConnectPaymentResult, OnboardingGroupDto } from '@gruperly/shared' +import { Logo, LogoIcon } from '../components/brand' +import { + ConfirmationStep, + FirstGroupStep, + PaymentStep, + Stepper, + WelcomeStep, +} from '../components/onboarding' +import { PAYMENT_PROVIDERS } from '../components/onboarding/constants' +import { Button } from '../components/ui' +import { useAuth } from '../context/AuthProvider' +import { getOnboardingStatus } from '../lib/api' + +const STEPS = [ + { label: 'Bienvenida' }, + { label: 'Cobros' }, + { label: 'Tu grupo' }, + { label: 'Listo' }, +] + +export function OnboardingView() { + const { user, isPending: authPending } = useAuth() + const navigate = useNavigate() + + const [step, setStep] = useState(0) + const [paymentResult, setPaymentResult] = useState(null) + const [createdGroup, setCreatedGroup] = useState(null) + + const statusQuery = useQuery({ + queryKey: ['onboarding', 'status'], + queryFn: getOnboardingStatus, + enabled: !!user, + }) + + useEffect(() => { + const status = statusQuery.data + if (!status) return + if (status.completed) { + void navigate({ to: '/' }) + return + } + // Si interrumpió después de conectar el cobro, retomamos en el paso del grupo. + if (status.step === 'PAYMENT_CONNECTED') { + setStep((current) => (current === 0 ? 2 : current)) + } + }, [statusQuery.data, navigate]) + + if (authPending) { + return ( +
+ +
+ ) + } + + if (!user) { + return ( +
+
+ + + +

Tu cuenta, lista

+

+ Iniciá sesión para configurar tus cobros y crear tu primer grupo. +

+ +
+
+ ) + } + + if (statusQuery.isPending) { + return ( +
+ +
+ ) + } + + const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a' + const providerName = paymentResult + ? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name + : undefined + + return ( +
+
+ + +
+ + + +
+ {step === 0 ? ( + setStep(1)} /> + ) : null} + {step === 1 ? ( + { + setPaymentResult(result) + setStep(2) + }} + onBack={() => setStep(0)} + /> + ) : null} + {step === 2 ? ( + setStep(1)} + onCompleted={(group) => { + setCreatedGroup(group) + setStep(3) + }} + /> + ) : null} + {step === 3 && createdGroup ? ( + + ) : null} +
+
+ ) +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d377954..24678cb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,7 +1,9 @@ export * from './lib/problem-details.js'; export * from './lib/result.js'; +export * from './schemas/enums.js'; export * from './schemas/groups.js'; export * from './schemas/health-check.js'; +export * from './schemas/onboarding.js'; export * from './schemas/pagination.js'; export * from './schemas/payments.js'; export * from './schemas/problem-details.js'; diff --git a/packages/shared/src/schemas/enums.ts b/packages/shared/src/schemas/enums.ts new file mode 100644 index 0000000..d6416c4 --- /dev/null +++ b/packages/shared/src/schemas/enums.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +// Días de la semana en que se dictan las clases. +export const weekdaySchema = z.enum([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +]); +export type WeekDay = z.output; + +// Procesadores de pago soportados. +export const paymentProviderSchema = z.enum(['MERCADO_PAGO', 'STRIPE']); +export type PaymentProvider = z.output; + +// Frecuencia de cobro de un grupo. +export const billingTypeSchema = z.enum(['MONTHLY', 'PER_CLASS']); +export type BillingType = z.output; \ No newline at end of file diff --git a/packages/shared/src/schemas/groups.ts b/packages/shared/src/schemas/groups.ts index 6b86f08..61b729d 100644 --- a/packages/shared/src/schemas/groups.ts +++ b/packages/shared/src/schemas/groups.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { billingTypeSchema, weekdaySchema } from './enums.js'; import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js'; const isoDateTimeSchema = z.string().datetime(); @@ -13,6 +14,13 @@ export const GroupDtoSchema = z.object({ createdById: z.string(), createdAt: isoDateTimeSchema, updatedAt: isoDateTimeSchema, + // Horario y cobro: quedan vacíos si el grupo aún no tiene configurado el plan. + days: z.array(weekdaySchema).nullable(), + time: z.string().nullable(), + capacity: z.number().int().min(1).nullable(), + price: z.number().min(0).nullable(), + billingType: billingTypeSchema.nullable(), + dueDay: z.number().int().min(1).max(28).nullable(), }); export type GroupDto = z.output; diff --git a/packages/shared/src/schemas/onboarding.ts b/packages/shared/src/schemas/onboarding.ts new file mode 100644 index 0000000..a210edf --- /dev/null +++ b/packages/shared/src/schemas/onboarding.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { billingTypeSchema, paymentProviderSchema, weekdaySchema } from './enums.js'; +import { GroupDtoSchema } from './groups.js'; + +export type { BillingType, PaymentProvider, WeekDay } from './enums.js'; +export { billingTypeSchema, paymentProviderSchema, weekdaySchema } from './enums.js'; + +// Paso 2: vincula la cuenta de cobro del profesor. +export const ConnectPaymentSchema = z.strictObject({ + provider: paymentProviderSchema, + // Para el MVP se conecta en modo simulación / sandbox. + sandbox: z.boolean().default(true), + accessToken: z.string().optional(), +}); +export type ConnectPayment = z.output; + +export const ConnectPaymentResultSchema = z.object({ + provider: paymentProviderSchema, + sandbox: z.boolean(), + connected: z.boolean(), +}); +export type ConnectPaymentResult = z.output; + +// Paso 3: crea el primer grupo del profesor. +export const CreateFirstGroupSchema = z.strictObject({ + name: z + .string() + .trim() + .min(3, 'El nombre debe tener al menos 3 caracteres') + .max(60, 'El nombre es demasiado largo (máximo 60 caracteres)'), + days: z.array(weekdaySchema).min(1, 'Elegí al menos un día de clase'), + time: z + .string() + .regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Elegí un horario válido (ej: 09:00)'), + capacity: z + .number({ invalid_type_error: 'Ingresá un cupo válido' }) + .int({ message: 'El cupo debe ser un número entero' }) + .min(1, 'El cupo debe ser al menos 1'), + price: z + .number({ invalid_type_error: 'Ingresá un precio válido' }) + .min(0, 'El precio no puede ser negativo'), + billingType: billingTypeSchema, + dueDay: z + .number({ invalid_type_error: 'Ingresá un día válido' }) + .int({ message: 'El día debe ser un número entero' }) + .min(1, 'El día de vencimiento debe estar entre 1 y 28') + .max(28, 'El día de vencimiento debe estar entre 1 y 28'), +}); +export type CreateFirstGroup = z.output; + +export const OnboardingGroupDtoSchema = GroupDtoSchema.extend({ + days: z.array(weekdaySchema).nullable(), + time: z.string().nullable(), + capacity: z.number().int().min(1).nullable(), + price: z.number().min(0).nullable(), + billingType: billingTypeSchema.nullable(), + dueDay: z.number().int().min(1).max(28).nullable(), +}); +export type OnboardingGroupDto = z.output; + +export const CreateFirstGroupResultSchema = z.object({ + group: OnboardingGroupDtoSchema, + onboardingCompleted: z.boolean(), +}); +export type CreateFirstGroupResult = z.output; + +// Estado del onboarding para retomar el flujo si se interrumpió. +export const onboardingStepSchema = z.enum(['NOT_STARTED', 'PAYMENT_CONNECTED', 'COMPLETED']); +export type OnboardingStep = z.output; + +export const OnboardingStatusDtoSchema = z.object({ + step: onboardingStepSchema, + paymentConnected: z.boolean(), + completed: z.boolean(), +}); +export type OnboardingStatusDto = z.output; \ No newline at end of file