feat: basic onboarding flow\

This commit is contained in:
Jose Selesan
2026-09-15 15:41:45 -03:00
parent 874b6e0cff
commit a92e9e77c3
37 changed files with 1775 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {}) {}

View File

@@ -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 = {}) {}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1 @@
export { default as onboardingRoutes } from './routes';

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

View File

@@ -0,0 +1 @@
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';

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

View File

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

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

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

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

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

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

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

View 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' },
]

View 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'

View File

@@ -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
View 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')

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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