feat: basic onboarding flow\
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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<GroupDb, 'group' | 'groupMember' | 'organization'>;
|
||||
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 = {}) {}
|
||||
|
||||
|
||||
@@ -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<GroupDb, 'group'>;
|
||||
};
|
||||
|
||||
type GroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class ListGroups {
|
||||
constructor(private readonly deps: ListGroupsDeps = {}) {}
|
||||
|
||||
|
||||
@@ -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<PrismaClient, 'group' | 'groupMember' | 'organization'>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
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) {
|
||||
|
||||
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'
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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: () => <Outlet />,
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</section>
|
||||
<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() {
|
||||
const navigate = useNavigate()
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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/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';
|
||||
|
||||
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 { 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<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