feat: basic onboarding flow\
This commit is contained in:
@@ -6,6 +6,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
|
onboardingCompleted Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ model User {
|
|||||||
orgMemberships Member[]
|
orgMemberships Member[]
|
||||||
orgInvitations Invitation[] @relation("InvitedBy")
|
orgInvitations Invitation[] @relation("InvitedBy")
|
||||||
waitlist WaitlistEntry[]
|
waitlist WaitlistEntry[]
|
||||||
|
merchantAccount MerchantAccount?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ model Group {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
|
days WeekDay[]
|
||||||
|
time String?
|
||||||
|
capacity Int?
|
||||||
|
price Decimal? @db.Decimal(10, 2)
|
||||||
|
billingType BillingType?
|
||||||
|
dueDay Int?
|
||||||
createdById String
|
createdById String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -16,6 +22,21 @@ model Group {
|
|||||||
@@map("groups")
|
@@map("groups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WeekDay {
|
||||||
|
MONDAY
|
||||||
|
TUESDAY
|
||||||
|
WEDNESDAY
|
||||||
|
THURSDAY
|
||||||
|
FRIDAY
|
||||||
|
SATURDAY
|
||||||
|
SUNDAY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BillingType {
|
||||||
|
MONTHLY
|
||||||
|
PER_CLASS
|
||||||
|
}
|
||||||
|
|
||||||
model GroupMember {
|
model GroupMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
groupId String
|
groupId String
|
||||||
@@ -104,3 +125,23 @@ enum WaitlistStatus {
|
|||||||
JOINED
|
JOINED
|
||||||
DECLINED
|
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")
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { logger } from '@/logger';
|
|||||||
import { authRoutes } from './modules/auth';
|
import { authRoutes } from './modules/auth';
|
||||||
import { groupsRoutes } from './modules/groups';
|
import { groupsRoutes } from './modules/groups';
|
||||||
import { healthCheckRoutes } from './modules/health-check';
|
import { healthCheckRoutes } from './modules/health-check';
|
||||||
|
import { onboardingRoutes } from './modules/onboarding';
|
||||||
import { paymentsRoutes } from './modules/payments';
|
import { paymentsRoutes } from './modules/payments';
|
||||||
import { studentsRoutes } from './modules/students';
|
import { studentsRoutes } from './modules/students';
|
||||||
import { waitlistRoutes } from './modules/waitlist';
|
import { waitlistRoutes } from './modules/waitlist';
|
||||||
@@ -33,6 +34,7 @@ api.use('*', sessionAuthMiddleware);
|
|||||||
api.route('/auth', authRoutes);
|
api.route('/auth', authRoutes);
|
||||||
api.route('/health', healthCheckRoutes);
|
api.route('/health', healthCheckRoutes);
|
||||||
api.route('/groups', groupsRoutes);
|
api.route('/groups', groupsRoutes);
|
||||||
|
api.route('/onboarding', onboardingRoutes);
|
||||||
api.route('/students', studentsRoutes);
|
api.route('/students', studentsRoutes);
|
||||||
api.route('/payments', paymentsRoutes);
|
api.route('/payments', paymentsRoutes);
|
||||||
api.route('/waitlist', waitlistRoutes);
|
api.route('/waitlist', waitlistRoutes);
|
||||||
|
|||||||
@@ -87,3 +87,17 @@ export function noGroupAccessProblem(): ProblemDetails {
|
|||||||
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
|
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
|
||||||
return notFoundProblem(`${resourceName} ${id}`);
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,22 +12,13 @@ import {
|
|||||||
organizationNotFoundProblem,
|
organizationNotFoundProblem,
|
||||||
} from '@/http/problem-builders';
|
} from '@/http/problem-builders';
|
||||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||||
import { type GroupDb, toGroupDto } from '../../lib';
|
import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
|
||||||
|
|
||||||
type CreateGroupFromOrganizationDeps = {
|
type CreateGroupFromOrganizationDeps = {
|
||||||
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
|
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
|
||||||
unitOfWork?: UnitOfWork;
|
unitOfWork?: UnitOfWork;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GroupRecord = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
createdById: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CreateGroupFromOrganization {
|
export class CreateGroupFromOrganization {
|
||||||
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
|
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,17 @@ import type {
|
|||||||
import { ok } from '@gruperly/shared';
|
import { ok } from '@gruperly/shared';
|
||||||
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib';
|
import {
|
||||||
|
buildGroupWhereForUser,
|
||||||
|
type GroupDb,
|
||||||
|
type GroupRecord,
|
||||||
|
toGroupDto,
|
||||||
|
} from '../../lib';
|
||||||
|
|
||||||
type ListGroupsDeps = {
|
type ListGroupsDeps = {
|
||||||
db?: Pick<GroupDb, 'group'>;
|
db?: Pick<GroupDb, 'group'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GroupRecord = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
createdById: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ListGroups {
|
export class ListGroups {
|
||||||
constructor(private readonly deps: ListGroupsDeps = {}) {}
|
constructor(private readonly deps: ListGroupsDeps = {}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import type { PrismaClient } from '@generated/prisma/client';
|
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<PrismaClient, 'group' | 'groupMember' | 'organization'>;
|
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
|
||||||
|
|
||||||
type GroupRecord = {
|
type PriceLike = { toString(): string };
|
||||||
|
|
||||||
|
export type GroupRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
export function toGroupDto(record: GroupRecord): GroupDto {
|
||||||
@@ -20,6 +28,12 @@ export function toGroupDto(record: GroupRecord): GroupDto {
|
|||||||
createdById: record.createdById,
|
createdById: record.createdById,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
updatedAt: record.updatedAt.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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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<PrismaClient, 'user' | 'merchantAccount'>;
|
||||||
|
unitOfWork?: UnitOfWork;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CreateFirstGroup {
|
||||||
|
constructor(private readonly deps: CreateFirstGroupDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
data: CreateFirstGroupInput,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Result<CreateFirstGroupResult, ProblemDetails>> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<Result<ConnectPaymentResult, ProblemDetails>> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal file
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal file
@@ -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;
|
||||||
@@ -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<Result<OnboardingStatusDto, ProblemDetails>> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/onboarding/index.ts
Normal file
1
apps/backend/src/modules/onboarding/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as onboardingRoutes } from './routes';
|
||||||
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal file
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';
|
||||||
12
apps/backend/src/modules/onboarding/routes.ts
Normal file
12
apps/backend/src/modules/onboarding/routes.ts
Normal file
@@ -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;
|
||||||
@@ -36,6 +36,12 @@ const group = {
|
|||||||
createdById: userId,
|
createdById: userId,
|
||||||
createdAt: new Date('2026-08-01T10:00:00.000Z'),
|
createdAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||||
updatedAt: 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) {
|
function makeApp(userValue: unknown) {
|
||||||
|
|||||||
281
apps/backend/test/onboarding.test.ts
Normal file
281
apps/backend/test/onboarding.test.ts
Normal file
@@ -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<unknown>) => cb(db));
|
||||||
|
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
apps/web/src/components/onboarding/ConfirmationStep.tsx
Normal file
84
apps/web/src/components/onboarding/ConfirmationStep.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header className="space-y-2 text-center">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-success-soft">
|
||||||
|
<CheckCircle2 className="size-7 text-success" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Tu grupo se creó y ya podés empezar a cobrar a tus alumnos.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-white p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||||
|
<p className="mt-0.5 text-sm text-foreground/60">
|
||||||
|
{formatSchedule(group.days ?? [], group.time)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="success">Activo</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-4 divide-y divide-border text-sm">
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<dt className="text-foreground/60">Precio</dt>
|
||||||
|
<dd className="font-semibold text-primary">
|
||||||
|
{formatPrice(group.price)}
|
||||||
|
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<dt className="text-foreground/60">Cupo</dt>
|
||||||
|
<dd className="font-semibold text-primary">{group.capacity ?? '—'} alumnos</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<dt className="text-foreground/60">Vencimiento</dt>
|
||||||
|
<dd className="font-semibold text-primary">Día {group.dueDay ?? '—'} de cada mes</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providerName ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-primary-soft px-4 py-3">
|
||||||
|
<CreditCard className="size-4 shrink-0 text-accent" />
|
||||||
|
<p className="text-xs text-primary">
|
||||||
|
Vas a cobrar con <span className="font-semibold">{providerName}</span> en modo
|
||||||
|
prueba.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button variant="primary" className="w-full" onClick={goToDashboard}>
|
||||||
|
Ir a mi Panel
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
236
apps/web/src/components/onboarding/FirstGroupStep.tsx
Normal file
236
apps/web/src/components/onboarding/FirstGroupStep.tsx
Normal file
@@ -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<CreateFirstGroup>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Nombre del grupo</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Ej: Yoga Vinyasa · Nivel 1"
|
||||||
|
invalid={!!errors.name}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Días de clase</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{WEEK_DAY_CHIPS.map(({ value, label }) => {
|
||||||
|
const isActive = days.includes(value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
onClick={() => toggleDay(value)}
|
||||||
|
className={cn(
|
||||||
|
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-accent bg-accent text-white'
|
||||||
|
: 'border-border bg-white text-primary hover:bg-primary-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="time">Horario</Label>
|
||||||
|
<Input
|
||||||
|
id="time"
|
||||||
|
type="time"
|
||||||
|
invalid={!!errors.time}
|
||||||
|
{...register('time')}
|
||||||
|
/>
|
||||||
|
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="capacity">Cupo máximo</Label>
|
||||||
|
<Input
|
||||||
|
id="capacity"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
invalid={!!errors.capacity}
|
||||||
|
{...register('capacity', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.capacity ? (
|
||||||
|
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="price">Precio</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
className="pl-7"
|
||||||
|
invalid={!!errors.price}
|
||||||
|
{...register('price', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.price ? (
|
||||||
|
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tipo de cobro</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{BILLING_TYPES.map(({ value, label, hint }) => {
|
||||||
|
const isActive = billingType === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
onClick={() => selectBillingType(value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border px-3 py-2.5 text-left transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-accent bg-accent-soft'
|
||||||
|
: 'border-border bg-white hover:bg-primary-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block text-sm font-semibold',
|
||||||
|
isActive ? 'text-accent' : 'text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-foreground/50">{hint}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dueDay">Día de vencimiento</Label>
|
||||||
|
<Input
|
||||||
|
id="dueDay"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
step={1}
|
||||||
|
invalid={!!errors.dueDay}
|
||||||
|
{...register('dueDay', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-foreground/50">
|
||||||
|
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
|
||||||
|
</p>
|
||||||
|
{errors.dueDay ? (
|
||||||
|
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{create.isError ? (
|
||||||
|
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||||
|
No pudimos crear el grupo. Revisá los datos e intentá de nuevo.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={onBack} disabled={create.isPending}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={create.isPending}
|
||||||
|
>
|
||||||
|
{create.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-4" />
|
||||||
|
)}
|
||||||
|
{create.isPending ? 'Creando…' : 'Crear grupo'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
apps/web/src/components/onboarding/PaymentStep.tsx
Normal file
171
apps/web/src/components/onboarding/PaymentStep.tsx
Normal file
@@ -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<PaymentProvider>('MERCADO_PAGO')
|
||||||
|
const [connected, setConnected] = useState<ConnectPaymentResult | null>(initialResult)
|
||||||
|
|
||||||
|
const connect = useMutation({
|
||||||
|
mutationFn: () => connectPayment({ provider: selected, sandbox: true }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setConnected(result)
|
||||||
|
onConnected(result)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold text-primary">Tu cobro está conectado</h2>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Ya podés dejar listo tu primer grupo para cobrar con{' '}
|
||||||
|
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center">
|
||||||
|
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-white">
|
||||||
|
<CheckCircle2 className="size-6 text-success" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-success">Cuenta conectada</p>
|
||||||
|
<div className="mt-1 flex items-center justify-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="success">Modo prueba</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setConnected(null)}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Cambiar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onConnected(connected)}
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold text-primary">Elegí tu procesador de cobro</h2>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Los pagos de tus alumnos van a llegar por esta plataforma.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div role="radiogroup" aria-label="Procesador de pago" className="space-y-3">
|
||||||
|
{PAYMENT_PROVIDERS.map((provider) => {
|
||||||
|
const isSelected = selected === provider.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
onClick={() => setSelected(provider.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-xl border bg-white p-4 text-left transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent ring-2 ring-accent/20'
|
||||||
|
: 'border-border hover:bg-primary-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2',
|
||||||
|
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard
|
||||||
|
className={cn('size-5', isSelected ? 'text-accent' : 'text-primary/40')}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-semibold text-primary">
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-foreground/60">
|
||||||
|
{provider.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||||
|
isSelected ? 'border-accent bg-accent text-white' : 'border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-xl bg-warning-soft px-4 py-3">
|
||||||
|
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
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'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connect.isError ? (
|
||||||
|
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||||
|
No pudimos conectar la cuenta. Intentalo de nuevo.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={connect.isPending}
|
||||||
|
onClick={() => connect.mutate()}
|
||||||
|
>
|
||||||
|
{connect.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<BadgeCheck className="size-4" />
|
||||||
|
)}
|
||||||
|
{connect.isPending ? 'Conectando…' : 'Conectar cuenta'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
apps/web/src/components/onboarding/Stepper.tsx
Normal file
59
apps/web/src/components/onboarding/Stepper.tsx
Normal file
@@ -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 (
|
||||||
|
<ol aria-label="Progreso del alta" className="flex items-center">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isDone = index < current
|
||||||
|
const isActive = index === current
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.label}
|
||||||
|
className={cn('flex items-center', index < steps.length - 1 ? 'flex-1' : '')}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
|
||||||
|
isDone && 'border-accent bg-accent text-white',
|
||||||
|
isActive && 'border-accent text-accent ring-4 ring-accent/15',
|
||||||
|
!isDone && !isActive && 'border-border text-foreground/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<Check className="size-4" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'hidden text-xs font-medium sm:block',
|
||||||
|
isActive ? 'text-accent' : 'text-foreground/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index < steps.length - 1 ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mx-2 mb-0 h-px flex-1 rounded-full transition-colors sm:mb-4',
|
||||||
|
isDone ? 'bg-accent' : 'bg-border',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
apps/web/src/components/onboarding/WelcomeStep.tsx
Normal file
63
apps/web/src/components/onboarding/WelcomeStep.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header className="space-y-2 text-center">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||||
|
<Rocket className="size-7 text-accent" />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-bold text-primary">¡Hola, {name}!</h1>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => (
|
||||||
|
<li
|
||||||
|
key={title}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border bg-white p-4"
|
||||||
|
>
|
||||||
|
<span className="rounded-lg bg-accent-soft p-2">
|
||||||
|
<Icon className="size-5 text-accent" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-primary">{title}</p>
|
||||||
|
<p className="text-xs text-foreground/60">{description}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Button variant="primary" size="md" className="w-full" onClick={onNext}>
|
||||||
|
Comenzar
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
apps/web/src/components/onboarding/constants.ts
Normal file
44
apps/web/src/components/onboarding/constants.ts
Normal file
@@ -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' },
|
||||||
|
]
|
||||||
5
apps/web/src/components/onboarding/index.ts
Normal file
5
apps/web/src/components/onboarding/index.ts
Normal file
@@ -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'
|
||||||
@@ -24,13 +24,21 @@
|
|||||||
|
|
||||||
/* Animaciones */
|
/* Animaciones */
|
||||||
--animate-fade-in: fade-in 0.2s ease-out;
|
--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 {
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes step-enter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateX(1.5rem);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
apps/web/src/lib/api.ts
Normal file
65
apps/web/src/lib/api.ts
Normal file
@@ -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<string, unknown> | unknown[]
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<ProblemDetails>
|
||||||
|
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<OnboardingStatusDto>('/api/v1/onboarding/status')
|
||||||
|
|
||||||
|
export const connectPayment = (payload: ConnectPayment) =>
|
||||||
|
apiFetch<ConnectPaymentResult>('/api/v1/onboarding/payment-setup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createFirstGroup = (payload: CreateFirstGroup) =>
|
||||||
|
apiFetch<CreateFirstGroupResult>('/api/v1/onboarding/first-group', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||||
26
apps/web/src/lib/format.ts
Normal file
26
apps/web/src/lib/format.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { BillingType, WeekDay } from '@gruperly/shared'
|
||||||
|
|
||||||
|
export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = {
|
||||||
|
MONDAY: 'Lunes',
|
||||||
|
TUESDAY: 'Martes',
|
||||||
|
WEDNESDAY: 'Miércoles',
|
||||||
|
THURSDAY: 'Jueves',
|
||||||
|
FRIDAY: 'Viernes',
|
||||||
|
SATURDAY: 'Sábado',
|
||||||
|
SUNDAY: 'Domingo',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BILLING_LABELS: Record<BillingType, string> = {
|
||||||
|
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' })
|
||||||
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { AuthProvider } from './context/AuthProvider'
|
import { AuthProvider } from './context/AuthProvider'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 30_000, retry: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -10,6 +10,7 @@ import { OrganizationsPage } from './routes/organizations'
|
|||||||
import { LoginPage } from './routes/auth/login'
|
import { LoginPage } from './routes/auth/login'
|
||||||
import { SignupPage } from './routes/auth/signup'
|
import { SignupPage } from './routes/auth/signup'
|
||||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||||
|
import { OnboardingView } from './routes/onboarding'
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => <Outlet />,
|
component: () => <Outlet />,
|
||||||
@@ -33,6 +34,12 @@ const verifyEmailRoute = createRoute({
|
|||||||
component: VerifyEmailPage,
|
component: VerifyEmailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onboardingRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/onboarding',
|
||||||
|
component: OnboardingView,
|
||||||
|
})
|
||||||
|
|
||||||
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
|
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
|
||||||
const appLayoutRoute = createRoute({
|
const appLayoutRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -86,6 +93,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
loginRoute,
|
loginRoute,
|
||||||
signupRoute,
|
signupRoute,
|
||||||
verifyEmailRoute,
|
verifyEmailRoute,
|
||||||
|
onboardingRoute,
|
||||||
appLayoutRoute.addChildren([
|
appLayoutRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
groupsRoute,
|
groupsRoute,
|
||||||
|
|||||||
@@ -1,8 +1,109 @@
|
|||||||
|
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 (
|
||||||
|
<article className="rounded-xl border border-border bg-white p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||||
|
{group.description ? (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge variant="success">Activo</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSchedule ? (
|
||||||
|
<dl className="mt-4 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-foreground/70">
|
||||||
|
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||||
|
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-foreground/70">
|
||||||
|
<Users className="size-4 shrink-0 text-accent" />
|
||||||
|
<span>
|
||||||
|
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}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-foreground/50">Aún sin plan de cobro configurado.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function GroupsView() {
|
export function GroupsView() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ['groups'],
|
||||||
|
queryFn: getGroups,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Crear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupsQuery.isPending ? (
|
||||||
|
<div className="mt-8 flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{groupsQuery.isError ? (
|
||||||
|
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||||
|
<p>No pudimos cargar tus grupos.</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void groupsQuery.refetch()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
|
||||||
|
<div className="mt-8 rounded-xl border border-dashed border-border bg-white px-4 py-12 text-center">
|
||||||
|
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||||
|
<p className="mt-1 text-sm text-foreground/60">
|
||||||
|
Crea tu primer grupo para empezar a cobrar.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="mt-5"
|
||||||
|
onClick={() => void navigate({ to: '/onboarding' })}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Crear tu primer grupo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
{groupsQuery.data.data.map((group) => (
|
||||||
|
<GroupCard key={group.id} group={group} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
131
apps/web/src/routes/onboarding.tsx
Normal file
131
apps/web/src/routes/onboarding.tsx
Normal file
@@ -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<ConnectPaymentResult | null>(null)
|
||||||
|
const [createdGroup, setCreatedGroup] = useState<OnboardingGroupDto | null>(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 (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm space-y-4 text-center">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||||
|
<LogoIcon className="size-8 text-accent" />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-bold text-primary">Tu cuenta, lista</h1>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Iniciá sesión para configurar tus cobros y crear tu primer grupo.
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" className="w-full" onClick={() => void navigate({ to: '/login' })}>
|
||||||
|
Iniciar sesión
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a'
|
||||||
|
const providerName = paymentResult
|
||||||
|
? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh w-full max-w-md flex-col px-4 pb-12 pt-8 sm:pt-12">
|
||||||
|
<header className="mb-6 flex items-center justify-center gap-2">
|
||||||
|
<LogoIcon className="size-7" />
|
||||||
|
<Logo className="text-xl" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Stepper steps={STEPS} current={step} />
|
||||||
|
|
||||||
|
<div key={step} className="mt-8 animate-step-enter">
|
||||||
|
{step === 0 ? (
|
||||||
|
<WelcomeStep name={firstName} onNext={() => setStep(1)} />
|
||||||
|
) : null}
|
||||||
|
{step === 1 ? (
|
||||||
|
<PaymentStep
|
||||||
|
initialResult={paymentResult}
|
||||||
|
onConnected={(result) => {
|
||||||
|
setPaymentResult(result)
|
||||||
|
setStep(2)
|
||||||
|
}}
|
||||||
|
onBack={() => setStep(0)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{step === 2 ? (
|
||||||
|
<FirstGroupStep
|
||||||
|
onBack={() => setStep(1)}
|
||||||
|
onCompleted={(group) => {
|
||||||
|
setCreatedGroup(group)
|
||||||
|
setStep(3)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{step === 3 && createdGroup ? (
|
||||||
|
<ConfirmationStep group={createdGroup} providerName={providerName} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
export * from './lib/problem-details.js';
|
export * from './lib/problem-details.js';
|
||||||
export * from './lib/result.js';
|
export * from './lib/result.js';
|
||||||
|
export * from './schemas/enums.js';
|
||||||
export * from './schemas/groups.js';
|
export * from './schemas/groups.js';
|
||||||
export * from './schemas/health-check.js';
|
export * from './schemas/health-check.js';
|
||||||
|
export * from './schemas/onboarding.js';
|
||||||
export * from './schemas/pagination.js';
|
export * from './schemas/pagination.js';
|
||||||
export * from './schemas/payments.js';
|
export * from './schemas/payments.js';
|
||||||
export * from './schemas/problem-details.js';
|
export * from './schemas/problem-details.js';
|
||||||
|
|||||||
21
packages/shared/src/schemas/enums.ts
Normal file
21
packages/shared/src/schemas/enums.ts
Normal file
@@ -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<typeof weekdaySchema>;
|
||||||
|
|
||||||
|
// Procesadores de pago soportados.
|
||||||
|
export const paymentProviderSchema = z.enum(['MERCADO_PAGO', 'STRIPE']);
|
||||||
|
export type PaymentProvider = z.output<typeof paymentProviderSchema>;
|
||||||
|
|
||||||
|
// Frecuencia de cobro de un grupo.
|
||||||
|
export const billingTypeSchema = z.enum(['MONTHLY', 'PER_CLASS']);
|
||||||
|
export type BillingType = z.output<typeof billingTypeSchema>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { billingTypeSchema, weekdaySchema } from './enums.js';
|
||||||
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
|
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
|
||||||
|
|
||||||
const isoDateTimeSchema = z.string().datetime();
|
const isoDateTimeSchema = z.string().datetime();
|
||||||
@@ -13,6 +14,13 @@ export const GroupDtoSchema = z.object({
|
|||||||
createdById: z.string(),
|
createdById: z.string(),
|
||||||
createdAt: isoDateTimeSchema,
|
createdAt: isoDateTimeSchema,
|
||||||
updatedAt: 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<typeof GroupDtoSchema>;
|
export type GroupDto = z.output<typeof GroupDtoSchema>;
|
||||||
|
|
||||||
|
|||||||
76
packages/shared/src/schemas/onboarding.ts
Normal file
76
packages/shared/src/schemas/onboarding.ts
Normal file
@@ -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<typeof ConnectPaymentSchema>;
|
||||||
|
|
||||||
|
export const ConnectPaymentResultSchema = z.object({
|
||||||
|
provider: paymentProviderSchema,
|
||||||
|
sandbox: z.boolean(),
|
||||||
|
connected: z.boolean(),
|
||||||
|
});
|
||||||
|
export type ConnectPaymentResult = z.output<typeof ConnectPaymentResultSchema>;
|
||||||
|
|
||||||
|
// 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<typeof CreateFirstGroupSchema>;
|
||||||
|
|
||||||
|
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<typeof OnboardingGroupDtoSchema>;
|
||||||
|
|
||||||
|
export const CreateFirstGroupResultSchema = z.object({
|
||||||
|
group: OnboardingGroupDtoSchema,
|
||||||
|
onboardingCompleted: z.boolean(),
|
||||||
|
});
|
||||||
|
export type CreateFirstGroupResult = z.output<typeof CreateFirstGroupResultSchema>;
|
||||||
|
|
||||||
|
// 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<typeof onboardingStepSchema>;
|
||||||
|
|
||||||
|
export const OnboardingStatusDtoSchema = z.object({
|
||||||
|
step: onboardingStepSchema,
|
||||||
|
paymentConnected: z.boolean(),
|
||||||
|
completed: z.boolean(),
|
||||||
|
});
|
||||||
|
export type OnboardingStatusDto = z.output<typeof OnboardingStatusDtoSchema>;
|
||||||
Reference in New Issue
Block a user