Compare commits
5 Commits
main
...
fa30fe2cff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa30fe2cff | ||
|
|
e8bca3a5ae | ||
|
|
6100c998be | ||
|
|
35ab3f0c06 | ||
|
|
a92e9e77c3 |
@@ -30,7 +30,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- `src/modules/<modulo>/` — por módulo: `index.ts`, `routes.ts` y `features/<accion>/{route,use-case}.ts`. **Patrón**: el route valida (`validate.query/json`) y delega en un use-case que devuelve `Result<T, ProblemDetails>` (`ok`/`err` desde `@gruperly/shared`); el route responde con `resultJson` (o `problemJson`).
|
||||
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
||||
- `src/logger.ts` — pino + pino-pretty.
|
||||
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `students`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
|
||||
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
|
||||
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
|
||||
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
|
||||
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Rename Student model (students table) to Attendee (attendees table)
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "students" RENAME TO "attendees";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" RENAME COLUMN "studentId" TO "attendeeId";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "students_groupId_idx" RENAME TO "attendees_groupId_idx";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "payments_studentId_idx" RENAME TO "payments_attendeeId_idx";
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "payments" RENAME CONSTRAINT "payments_studentId_fkey" TO "payments_attendeeId_fkey";
|
||||
@@ -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,21 +1,42 @@
|
||||
// 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[]
|
||||
students Student[]
|
||||
payments Payment[]
|
||||
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
members GroupMember[]
|
||||
attendees Attendee[]
|
||||
payments Payment[]
|
||||
|
||||
@@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
|
||||
@@ -36,42 +57,42 @@ enum Role {
|
||||
MEMBER
|
||||
}
|
||||
|
||||
model Student {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
fullName String
|
||||
email String?
|
||||
phone String?
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model Attendee {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
fullName String
|
||||
email String?
|
||||
phone String?
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
payments Payment[]
|
||||
|
||||
@@index([groupId])
|
||||
@@map("students")
|
||||
@@map("attendees")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
studentId String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("MXN")
|
||||
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
|
||||
dueDate DateTime
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
attendeeId String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("MXN")
|
||||
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
|
||||
dueDate DateTime
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([groupId])
|
||||
@@index([studentId])
|
||||
@@index([attendeeId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
@@ -104,3 +125,23 @@ enum WaitlistStatus {
|
||||
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")
|
||||
}
|
||||
@@ -10,11 +10,12 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
|
||||
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
||||
import { sessionAuthMiddleware } from '@/http/session-auth';
|
||||
import { logger } from '@/logger';
|
||||
import { attendeesRoutes } from './modules/attendees';
|
||||
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';
|
||||
|
||||
const app = new Hono<BackendEnv>();
|
||||
@@ -33,7 +34,8 @@ api.use('*', sessionAuthMiddleware);
|
||||
api.route('/auth', authRoutes);
|
||||
api.route('/health', healthCheckRoutes);
|
||||
api.route('/groups', groupsRoutes);
|
||||
api.route('/students', studentsRoutes);
|
||||
api.route('/onboarding', onboardingRoutes);
|
||||
api.route('/attendees', attendeesRoutes);
|
||||
api.route('/payments', paymentsRoutes);
|
||||
api.route('/waitlist', waitlistRoutes);
|
||||
|
||||
|
||||
BIN
apps/backend/src/assets/logotipo-y-organica.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -87,3 +87,17 @@ 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',
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const corsMiddleware: MiddlewareHandler = honoCors({
|
||||
return '';
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'Cache-Control'],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import nodemailer, { type Transporter } from 'nodemailer'
|
||||
|
||||
type VerificationEmailData = { email: string; url: string; name?: string }
|
||||
type PasswordResetEmailData = { email: string; url: string; name?: string }
|
||||
type InvitationEmailData = { email: string; url: string; organizationName: string }
|
||||
|
||||
// Colores de marca (mismo sistema que la web: index.css de apps/web).
|
||||
const BRAND_600 = '#2544ea'
|
||||
const TEXT_STRONG = '#0f172a'
|
||||
const TEXT_MUTED = '#475569'
|
||||
const ON_ACCENT = '#ffffff'
|
||||
const LINK_BG = BRAND_600
|
||||
|
||||
// Logotipo embebido como data URI para que los mails no dependan de hosting.
|
||||
function loadLogoTypoDataUri(): string {
|
||||
try {
|
||||
const base64 = readFileSync(
|
||||
new URL('../assets/logotipo-y-organica.png', import.meta.url),
|
||||
).toString('base64')
|
||||
return `data:image/png;base64,${base64}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const LOGO_DATA_URI = loadLogoTypoDataUri()
|
||||
|
||||
interface EmailProvider {
|
||||
sendVerificationEmail(data: VerificationEmailData): Promise<void>
|
||||
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
|
||||
@@ -30,11 +52,15 @@ class SMTPEmailProvider implements EmailProvider {
|
||||
}
|
||||
|
||||
private layout(title: string, body: string) {
|
||||
const header = LOGO_DATA_URI
|
||||
? `<img src="${LOGO_DATA_URI}" alt="Gruperly" width="252" height="83" style="height:36px;width:auto;display:block;margin:0 auto;" />`
|
||||
: '<h2 style="margin:0 0 16px;">Gruperly</h2>'
|
||||
|
||||
return `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;">
|
||||
<h2 style="margin: 0 0 16px;">Gruperly</h2>
|
||||
<h3 style="margin: 0 0 8px;">${title}</h3>
|
||||
<p style="line-height: 1.6; color: #334155;">${body}</p>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: ${TEXT_MUTED};">
|
||||
<div style="text-align:center;margin-bottom:20px;">${header}</div>
|
||||
<h3 style="margin: 0 0 8px; color: ${TEXT_STRONG};">${title}</h3>
|
||||
<p style="line-height: 1.6; color: ${TEXT_MUTED};">${body}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -49,7 +75,7 @@ class SMTPEmailProvider implements EmailProvider {
|
||||
}
|
||||
|
||||
async sendVerificationEmail({ email, url, name }: VerificationEmailData) {
|
||||
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
|
||||
const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
|
||||
await this.send(
|
||||
email,
|
||||
'Verifica tu email en Gruperly',
|
||||
@@ -61,7 +87,7 @@ class SMTPEmailProvider implements EmailProvider {
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) {
|
||||
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
|
||||
const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
|
||||
await this.send(
|
||||
email,
|
||||
'Restablece tu contraseña en Gruperly',
|
||||
@@ -73,7 +99,7 @@ class SMTPEmailProvider implements EmailProvider {
|
||||
}
|
||||
|
||||
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) {
|
||||
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
|
||||
const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
|
||||
await this.send(
|
||||
email,
|
||||
`Te invitaron a ${organizationName} en Gruperly`,
|
||||
|
||||
18
apps/backend/src/modules/attendees/features/get-all/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { AttendeeQuery } from '@gruperly/shared';
|
||||
import { AttendeeQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { ListAttendees } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/', validate.query(AttendeeQuerySchema), async (c) => {
|
||||
const query = c.req.valid('query') as AttendeeQuery;
|
||||
const useCase = new ListAttendees();
|
||||
const result: Awaited<ReturnType<typeof useCase.execute>> = await useCase.execute(query);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { ProblemDetails, Result, StudentDto, StudentList, StudentQuery } from '@gruperly/shared';
|
||||
import type { AttendeeDto, AttendeeList, AttendeeQuery, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type ListStudentsDeps = {
|
||||
db?: Pick<PrismaClient, 'student'>;
|
||||
type ListAttendeesDeps = {
|
||||
db?: Pick<PrismaClient, 'attendee'>;
|
||||
};
|
||||
|
||||
type StudentRecord = {
|
||||
type AttendeeRecord = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
fullName: string;
|
||||
@@ -21,7 +21,7 @@ type StudentRecord = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function toStudentDto(record: StudentRecord): StudentDto {
|
||||
function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
|
||||
return {
|
||||
id: record.id,
|
||||
groupId: record.groupId,
|
||||
@@ -36,22 +36,22 @@ function toStudentDto(record: StudentRecord): StudentDto {
|
||||
};
|
||||
}
|
||||
|
||||
export class ListStudents {
|
||||
constructor(private readonly deps: ListStudentsDeps = {}) {}
|
||||
export class ListAttendees {
|
||||
constructor(private readonly deps: ListAttendeesDeps = {}) {}
|
||||
|
||||
async execute(query: StudentQuery): Promise<Result<StudentList, ProblemDetails>> {
|
||||
async execute(query: AttendeeQuery): Promise<Result<AttendeeList, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const [records, total] = await Promise.all([
|
||||
db.student.findMany({
|
||||
db.attendee.findMany({
|
||||
skip: getPaginationOffset(query),
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.student.count(),
|
||||
db.attendee.count(),
|
||||
]);
|
||||
|
||||
return ok({
|
||||
data: records.map(toStudentDto),
|
||||
data: records.map(toAttendeeDto),
|
||||
pagination: getPaginationMetadata(query, total),
|
||||
});
|
||||
}
|
||||
1
apps/backend/src/modules/attendees/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as attendeesRoutes } from './routes';
|
||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { default as onboardingRoutes } from './routes';
|
||||
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
@@ -0,0 +1 @@
|
||||
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';
|
||||
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;
|
||||
@@ -11,7 +11,7 @@ type ListPaymentsDeps = {
|
||||
type PaymentRecord = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
studentId: string;
|
||||
attendeeId: string;
|
||||
amount: { toString(): string };
|
||||
currency: string;
|
||||
status: PaymentDto['status'];
|
||||
@@ -25,7 +25,7 @@ function toPaymentDto(record: PaymentRecord): PaymentDto {
|
||||
return {
|
||||
id: record.id,
|
||||
groupId: record.groupId,
|
||||
studentId: record.studentId,
|
||||
attendeeId: record.attendeeId,
|
||||
amount: Number(record.amount.toString()),
|
||||
currency: record.currency,
|
||||
status: record.status,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { StudentQuery } from '@gruperly/shared';
|
||||
import { StudentQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { ListStudents } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/', validate.query(StudentQuerySchema), async (c) => {
|
||||
const query = c.req.valid('query') as StudentQuery;
|
||||
const useCase = new ListStudents();
|
||||
const result: Awaited<ReturnType<typeof useCase.execute>> = await useCase.execute(query);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as studentsRoutes } from './routes';
|
||||
@@ -3,7 +3,7 @@ import { Hono } from 'hono';
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
default: {
|
||||
student: {
|
||||
attendee: {
|
||||
findMany: mock(),
|
||||
count: mock(),
|
||||
},
|
||||
@@ -13,13 +13,13 @@ mock.module('@/lib/prisma', () => ({
|
||||
}));
|
||||
|
||||
import prisma from '@/lib/prisma';
|
||||
import { studentsRoutes } from '@/modules/students';
|
||||
import { attendeesRoutes } from '@/modules/attendees';
|
||||
|
||||
const app = new Hono();
|
||||
app.route('/students', studentsRoutes);
|
||||
app.route('/attendees', attendeesRoutes);
|
||||
|
||||
const student = {
|
||||
id: 'student-1',
|
||||
const attendee = {
|
||||
id: 'attendee-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Ana Pérez',
|
||||
email: 'ana@example.com',
|
||||
@@ -31,41 +31,41 @@ const student = {
|
||||
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
};
|
||||
|
||||
describe('students routes', () => {
|
||||
describe('attendees routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists students with pagination', async () => {
|
||||
prisma.student.findMany.mockResolvedValue([student]);
|
||||
prisma.student.count.mockResolvedValue(21);
|
||||
it('lists attendees with pagination', async () => {
|
||||
prisma.attendee.findMany.mockResolvedValue([attendee]);
|
||||
prisma.attendee.count.mockResolvedValue(21);
|
||||
|
||||
const res = await app.request('/students?page=2&pageSize=10');
|
||||
const res = await app.request('/attendees?page=2&pageSize=10');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
data: [
|
||||
{
|
||||
...student,
|
||||
createdAt: student.createdAt.toISOString(),
|
||||
updatedAt: student.updatedAt.toISOString(),
|
||||
...attendee,
|
||||
createdAt: attendee.createdAt.toISOString(),
|
||||
updatedAt: attendee.updatedAt.toISOString(),
|
||||
},
|
||||
],
|
||||
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
|
||||
});
|
||||
expect(prisma.student.findMany).toHaveBeenCalledWith({
|
||||
expect(prisma.attendee.findMany).toHaveBeenCalledWith({
|
||||
skip: 10,
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
expect(prisma.student.count).toHaveBeenCalledWith();
|
||||
expect(prisma.attendee.count).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('uses default pagination', async () => {
|
||||
prisma.student.findMany.mockResolvedValue([]);
|
||||
prisma.student.count.mockResolvedValue(0);
|
||||
prisma.attendee.findMany.mockResolvedValue([]);
|
||||
prisma.attendee.count.mockResolvedValue(0);
|
||||
|
||||
const res = await app.request('/students');
|
||||
const res = await app.request('/attendees');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
@@ -75,7 +75,7 @@ describe('students routes', () => {
|
||||
});
|
||||
|
||||
it('rejects invalid pagination query parameters', async () => {
|
||||
const res = await app.request('/students?page=0&pageSize=25');
|
||||
const res = await app.request('/attendees?page=0&pageSize=25');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,8 @@ app.route('/payments', paymentsRoutes);
|
||||
|
||||
const payment = {
|
||||
id: 'payment-1',
|
||||
groupId: 'group-1',
|
||||
studentId: 'student-1',
|
||||
groupId: 'group-1',
|
||||
attendeeId: 'attendee-1',
|
||||
amount: { toString: () => '150.50' } as { toString(): string },
|
||||
currency: 'MXN',
|
||||
status: 'pending',
|
||||
@@ -47,8 +47,8 @@ describe('payments routes', () => {
|
||||
data: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
groupId: 'group-1',
|
||||
studentId: 'student-1',
|
||||
groupId: 'group-1',
|
||||
attendeeId: 'attendee-1',
|
||||
amount: 150.5,
|
||||
currency: 'MXN',
|
||||
status: 'pending',
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('session auth', () => {
|
||||
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
|
||||
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).toBe(true);
|
||||
expect(isPublicApiRequest('GET', '/api/v1/groups')).toBe(false);
|
||||
expect(isPublicApiRequest('GET', '/api/v1/students')).toBe(false);
|
||||
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows public requests without a session', async () => {
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gruperly</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var isDark =
|
||||
stored === 'dark' ||
|
||||
(stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
12
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
|
||||
<title>Gruperly</title>
|
||||
<style>
|
||||
.branch { fill: #171D54; }
|
||||
.diag { fill: #93B4FD; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.branch { fill: #EEF4FF; }
|
||||
}
|
||||
</style>
|
||||
<path class="branch" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
|
||||
<path class="diag" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
6
apps/web/src/assets/brand/isotipo-y-organica-oscuro.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
|
||||
<title>Gruperly — isotipo y orgánica</title>
|
||||
<desc>Y orgánica con rama corta blanco azulado #EEF4FF y diagonal larga celeste #93B4FD, separadas, sobre fondo transparente.</desc>
|
||||
<path fill="#EEF4FF" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
|
||||
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
6
apps/web/src/assets/brand/isotipo-y-organica.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
|
||||
<title>Gruperly — isotipo y orgánica</title>
|
||||
<desc>Y orgánica con rama corta azul noche #171D54 y diagonal larga celeste #93B4FD, separadas, sobre fondo transparente.</desc>
|
||||
<path fill="#171D54" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
|
||||
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
7
apps/web/src/assets/brand/logotipo-y-organica-oscuro.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="624" height="206" viewBox="250 42 624 206">
|
||||
<title>Gruperly — y orgánica bicolor</title>
|
||||
<desc>Rama corta azul noche y diagonal larga celeste, separadas, con curvas suaves y terminales redondeados.</desc>
|
||||
<path fill="#EEF4FF" d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"/>
|
||||
<path fill="#EEF4FF" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
|
||||
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
7
apps/web/src/assets/brand/logotipo-y-organica.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="624" height="206" viewBox="250 42 624 206">
|
||||
<title>Gruperly — y orgánica bicolor</title>
|
||||
<desc>Rama corta azul noche y diagonal larga celeste, separadas, con curvas suaves y terminales redondeados.</desc>
|
||||
<path fill="#171D54" d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"/>
|
||||
<path fill="#171D54" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
|
||||
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
import { Logo } from '../brand'
|
||||
|
||||
export type AuthShellProps = {
|
||||
title: string
|
||||
@@ -11,11 +11,10 @@ export function AuthShell({ title, subtitle, children }: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<LogoIcon className="size-9" />
|
||||
<Logo className="text-xl" />
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<Logo className="h-10" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-white p-6 shadow-sm">
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-xl font-bold text-primary">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
|
||||
<div className="mt-5">{children}</div>
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { forwardRef, type SVGProps } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export type LogoProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
|
||||
className?: string
|
||||
}
|
||||
export type LogoProps = SVGProps<SVGSVGElement>
|
||||
|
||||
export const Logo = forwardRef<HTMLSpanElement, LogoProps>(
|
||||
export const Logo = forwardRef<SVGSVGElement, LogoProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<span
|
||||
<svg
|
||||
ref={ref}
|
||||
className={cn('font-extrabold text-primary select-none', className)}
|
||||
viewBox="250 42 624 206"
|
||||
role="img"
|
||||
aria-label="Gruperly"
|
||||
className={cn('h-6 w-auto', className)}
|
||||
{...props}
|
||||
>
|
||||
gruperl
|
||||
<span className="text-accent">y</span>
|
||||
</span>
|
||||
<title>Gruperly</title>
|
||||
<path
|
||||
className="fill-brand-night dark:fill-brand-cloud"
|
||||
d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"
|
||||
/>
|
||||
<path
|
||||
className="fill-brand-night dark:fill-brand-cloud"
|
||||
d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"
|
||||
/>
|
||||
<path
|
||||
className="fill-brand-celeste"
|
||||
d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,26 +10,20 @@ export const LogoIcon = forwardRef<SVGSVGElement, LogoIconProps>(
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
viewBox="718.5 93 144 144"
|
||||
role="img"
|
||||
aria-label="Gruperly"
|
||||
className={cn('size-8', className)}
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
width="40"
|
||||
height="40"
|
||||
rx="10"
|
||||
className="fill-accent-soft"
|
||||
<title>Gruperly</title>
|
||||
<path
|
||||
className="fill-brand-night dark:fill-brand-cloud"
|
||||
d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"
|
||||
/>
|
||||
<path
|
||||
d="M17 14.5c0-1.4 3-1.4 3 0V27.5c0 3-2.5 6-6.5 6-3.6 0-6-2.1-6-5 0-1.4 3-1.4 3 0 0 1 .6 2 3 2 2 0 3-1.2 3-3V17z"
|
||||
className="fill-accent"
|
||||
/>
|
||||
<path
|
||||
d="M20 15.5c0-1.4 3-1.4 3 0v6c0 3 2 5 5.5 5 1.4 0 3-1.2 3-3 0-1.4 3-1.4 3 0 0 3-2.6 4.6-6.5 4.6-4 0-5-3-5-6.6v-6z"
|
||||
className="fill-accent"
|
||||
className="fill-brand-celeste"
|
||||
d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
34
apps/web/src/components/layout/AppLayoutGuard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { getOnboardingStatus } from '../../lib/api'
|
||||
import { RootLayout } from './RootLayout'
|
||||
|
||||
export function AppLayoutGuard() {
|
||||
const { user, isPending } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['onboarding', 'status'],
|
||||
queryFn: getOnboardingStatus,
|
||||
enabled: !!user,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !statusQuery.data || statusQuery.data.completed) return
|
||||
void navigate({ to: '/onboarding', replace: true })
|
||||
}, [user, statusQuery.data, navigate])
|
||||
|
||||
if (isPending || (user && statusQuery.isPending)) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <RootLayout />
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function BottomNav() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-white/95 backdrop-blur lg:hidden">
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface/95 backdrop-blur lg:hidden">
|
||||
<div className="mx-auto grid max-w-md grid-cols-4">
|
||||
{NAV_ITEMS.map(({ label, to, icon: Icon }) => {
|
||||
const isActive = location.pathname === to
|
||||
@@ -16,7 +16,7 @@ export function BottomNav() {
|
||||
to={to}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors',
|
||||
isActive ? 'text-accent' : 'text-foreground/50 hover:text-primary',
|
||||
isActive ? 'text-accent-fg' : 'text-foreground/50 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
import { Logo } from '../brand'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
function ThemeToggle() {
|
||||
const { isDark, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio">
|
||||
<LogoIcon className="size-7" />
|
||||
<Logo className="text-lg tracking-tight" />
|
||||
<a href="/" className="flex items-center lg:hidden" aria-label="Gruperly inicio">
|
||||
<Logo className="h-8" />
|
||||
</a>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<UserMenu />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
import { Logo } from '../brand'
|
||||
import { NAV_ITEMS } from './nav-items'
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
|
||||
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio">
|
||||
<LogoIcon className="size-8" />
|
||||
<Logo className="text-lg tracking-tight" />
|
||||
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-surface px-4 py-6 lg:flex">
|
||||
<a href="/" className="mb-8 flex items-center px-1" aria-label="Gruperly inicio">
|
||||
<Logo className="h-10" />
|
||||
</a>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
@@ -23,7 +22,7 @@ export function Sidebar() {
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
? 'bg-accent-soft text-accent-fg'
|
||||
: 'text-foreground/60 hover:bg-primary-soft hover:text-primary',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -78,15 +78,15 @@ export function UserMenu() {
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-fade-in bg-black/20 backdrop-blur-sm"
|
||||
className="absolute inset-0 animate-fade-in bg-overlay backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-white/90 backdrop-blur">
|
||||
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-surface/90 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Cerrar menú"
|
||||
className="absolute right-4 top-4 z-20 rounded-full bg-white/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-white"
|
||||
className="absolute right-4 top-4 z-20 rounded-full bg-surface/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-surface"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
@@ -105,7 +105,7 @@ export function UserMenu() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-white/90 p-2 shadow-xl backdrop-blur">
|
||||
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-surface/90 p-2 shadow-xl backdrop-blur">
|
||||
<Link
|
||||
role="menuitem"
|
||||
to="/profile"
|
||||
@@ -132,7 +132,7 @@ export function UserMenu() {
|
||||
onClick={handleSignOut}
|
||||
className={cn(
|
||||
itemClass,
|
||||
'w-full justify-center border border-border bg-white/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
|
||||
'w-full justify-center border border-border bg-surface/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-5 text-foreground/60" />
|
||||
@@ -147,7 +147,7 @@ export function UserMenu() {
|
||||
: open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-white p-1.5 shadow-lg"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-surface p-1.5 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>
|
||||
|
||||
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 miembros.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface 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 ?? '—'} miembros</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
@@ -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-on-accent'
|
||||
: 'border-border bg-surface 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-surface 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
@@ -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-surface">
|
||||
<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 miembros 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-surface 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-on-accent' : '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
@@ -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-on-accent',
|
||||
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
@@ -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 miembros 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-surface 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
@@ -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
@@ -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,7 +22,7 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-accent text-sm font-semibold text-white',
|
||||
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-accent text-sm font-semibold text-on-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,9 +10,9 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-accent text-white hover:bg-accent-strong',
|
||||
primary: 'bg-accent text-on-accent hover:bg-accent-strong',
|
||||
ghost: 'bg-transparent text-primary hover:bg-primary-soft',
|
||||
outline: 'border border-border bg-white text-primary hover:bg-primary-soft',
|
||||
outline: 'border border-border bg-surface text-primary hover:bg-primary-soft',
|
||||
}
|
||||
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-xl border border-border bg-white px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'h-10 w-full rounded-xl border border-border bg-surface px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
invalid && 'border-danger focus:border-danger focus:ring-danger/30',
|
||||
className,
|
||||
)}
|
||||
|
||||
68
apps/web/src/context/ThemeProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
isDark: boolean
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
|
||||
}
|
||||
|
||||
function systemPrefersDark() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
function resolveDark(theme: Theme) {
|
||||
return theme === 'dark' || (theme === 'system' && systemPrefersDark())
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(readStoredTheme)
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
document.documentElement.classList.toggle('dark', resolveDark(theme))
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}
|
||||
update()
|
||||
|
||||
if (theme !== 'system') return
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
media.addEventListener('change', update)
|
||||
return () => media.removeEventListener('change', update)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => setThemeState(next), [])
|
||||
const value = useMemo(() => ({ theme, isDark, setTheme }), [theme, isDark, setTheme])
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme debe usarse dentro de <ThemeProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,51 +1,144 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colores de marca */
|
||||
--color-background: #f8fafc;
|
||||
--color-primary: #0a2540;
|
||||
--color-primary-soft: #f0f4f9;
|
||||
--color-accent: #1e90ff;
|
||||
--color-accent-strong: #0088ff;
|
||||
--color-accent-soft: #e6f4ff;
|
||||
--color-foreground: #0f172a;
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Badges de estado */
|
||||
--color-success: #10b981;
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-soft: #fef3c7;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-soft: #fee2e2;
|
||||
--color-border: #e2e8f0;
|
||||
@theme inline {
|
||||
/* Escala de marca (identidad fija, igual a la landing) */
|
||||
--color-brand-50: var(--brand-50);
|
||||
--color-brand-100: var(--brand-100);
|
||||
--color-brand-200: var(--brand-200);
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-800: var(--brand-800);
|
||||
--color-brand-900: var(--brand-900);
|
||||
--color-brand-950: var(--brand-950);
|
||||
|
||||
/* Colores de la gráfica del isotipo/logotipo */
|
||||
--color-brand-night: var(--brand-night);
|
||||
--color-brand-celeste: var(--brand-celeste);
|
||||
--color-brand-cloud: var(--brand-cloud);
|
||||
|
||||
/* Tokens semánticos (conmutan con tema claro/oscuro) */
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-soft: var(--primary-soft);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-fg: var(--accent-fg);
|
||||
--color-border: var(--border);
|
||||
--color-on-accent: var(--on-accent);
|
||||
--color-overlay: var(--overlay);
|
||||
--color-success: var(--success);
|
||||
--color-success-soft: var(--success-soft);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-soft: var(--warning-soft);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
|
||||
/* Border radius por defecto */
|
||||
--radius-xl: 0.75rem;
|
||||
|
||||
/* Tipografía (igual que la landing) */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Escala de marca */
|
||||
--brand-50: #eef4ff;
|
||||
--brand-100: #dbe6fe;
|
||||
--brand-200: #bfd3fe;
|
||||
--brand-300: #93b4fd;
|
||||
--brand-400: #608afa;
|
||||
--brand-500: #3b63f5;
|
||||
--brand-600: #2544ea;
|
||||
--brand-700: #1d33d8;
|
||||
--brand-800: #1e2cb0;
|
||||
--brand-900: #1e2b8b;
|
||||
--brand-950: #171d54;
|
||||
--brand-night: #171d54;
|
||||
--brand-celeste: #93b4fd;
|
||||
--brand-cloud: #eef4ff;
|
||||
|
||||
/* Tema claro */
|
||||
--background: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--primary: #0f172a;
|
||||
--primary-soft: #f1f5f9;
|
||||
--foreground: #475569;
|
||||
--accent: #2544ea;
|
||||
--accent-strong: #1d33d8;
|
||||
--accent-soft: #eef4ff;
|
||||
--accent-fg: #2544ea;
|
||||
--border: #e2e8f0;
|
||||
--on-accent: #ffffff;
|
||||
--overlay: rgb(0 0 0 / 0.2);
|
||||
--success: #059669;
|
||||
--success-soft: #ecfdf5;
|
||||
--warning: #b45309;
|
||||
--warning-soft: #fffbeb;
|
||||
--danger: #f43f5e;
|
||||
--danger-soft: #fff1f2;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Tema oscuro (paleta slate/brand de la landing) */
|
||||
--background: #020617;
|
||||
--surface: #0f172a;
|
||||
--primary: #f1f5f9;
|
||||
--primary-soft: #1e293b;
|
||||
--foreground: #94a3b8;
|
||||
--accent: #2544ea;
|
||||
--accent-strong: #1d33d8;
|
||||
--accent-soft: color-mix(in srgb, var(--brand-500) 15%, transparent);
|
||||
--accent-fg: #93b4fd;
|
||||
--border: #1e293b;
|
||||
--on-accent: #ffffff;
|
||||
--overlay: rgb(0 0 0 / 0.4);
|
||||
--success: #34d399;
|
||||
--success-soft: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
--warning: #fcd34d;
|
||||
--warning-soft: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||
--danger: #fb7185;
|
||||
--danger-soft: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-primary antialiased;
|
||||
@apply bg-background text-primary antialiased transition-colors;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
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
@@ -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,26 @@
|
||||
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 { ThemeProvider } from './context/ThemeProvider'
|
||||
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}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { RootLayout } from './components/layout'
|
||||
import { AppLayoutGuard } from './components/layout/AppLayoutGuard'
|
||||
import { HomeView } from './routes/home'
|
||||
import { GroupsView } from './routes/groups'
|
||||
import { PaymentsView } from './routes/payments'
|
||||
@@ -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,11 +34,18 @@ 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).
|
||||
// El guard redirige a /onboarding a quien no completó el onboarding.
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
component: RootLayout,
|
||||
component: AppLayoutGuard,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
@@ -86,6 +94,7 @@ const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
signupRoute,
|
||||
verifyEmailRoute,
|
||||
onboardingRoute,
|
||||
appLayoutRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function VerifyEmailPage() {
|
||||
{status === 'success' ? (
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-white hover:bg-accent-strong"
|
||||
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-on-accent hover:bg-accent-strong"
|
||||
>
|
||||
Ir a Gruperly
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,109 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CalendarClock, Loader2, Plus, Users } from 'lucide-react'
|
||||
import type { GroupDto } from '@gruperly/shared'
|
||||
import { Badge, Button } from '../components/ui'
|
||||
import { getGroups } from '../lib/api'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'
|
||||
|
||||
function GroupCard({ group }: { group: GroupDto }) {
|
||||
const hasSchedule = (group.days?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-border bg-surface 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 ?? '—'} miembros
|
||||
{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>
|
||||
<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 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-surface 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>
|
||||
)
|
||||
}
|
||||
130
apps/web/src/routes/onboarding.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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" />
|
||||
</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">
|
||||
<Logo className="h-6" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{organizations.map((org) => (
|
||||
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
@@ -148,7 +148,7 @@ export function OrganizationsPage() {
|
||||
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Plus className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
|
||||
@@ -182,7 +182,7 @@ export function OrganizationsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
|
||||
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ProfileView() {
|
||||
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -42,7 +42,7 @@ function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
@@ -126,7 +126,7 @@ export function SecurityPage() {
|
||||
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
|
||||
@@ -165,7 +165,7 @@ export function SecurityPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
|
||||
@@ -1,7 +1,64 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Building2, Fingerprint } from 'lucide-react'
|
||||
import { Building2, Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { signOut } from '../lib/auth-client'
|
||||
import { Button } from '../components/ui'
|
||||
import { useTheme, type Theme } from '../context/ThemeProvider'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const THEME_OPTIONS: { value: Theme; label: string; description: string }[] = [
|
||||
{ value: 'light', label: 'Claro', description: 'Interfaz en tonos claros' },
|
||||
{ value: 'dark', label: 'Oscuro', description: 'Interfaz en tonos oscuros' },
|
||||
{ value: 'system', label: 'Sistema', description: 'Sigue la preferencia de tu dispositivo' },
|
||||
]
|
||||
|
||||
const THEME_ICONS: Record<Theme, typeof Sun> = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
}
|
||||
|
||||
function AppearanceCard() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-primary">Apariencia</p>
|
||||
<p className="text-xs text-foreground/50">Tema claro u oscuro</p>
|
||||
</div>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = THEME_ICONS[option.value]
|
||||
const isSelected = theme === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-lg p-2',
|
||||
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('size-4', isSelected ? 'text-accent-fg' : 'text-foreground/70')}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-sm font-medium', isSelected ? 'text-accent-fg' : 'text-primary')}>
|
||||
{option.label}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">{option.description}</p>
|
||||
</div>
|
||||
{isSelected ? <Check className="size-4 text-accent-fg" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const handleSignOut = async () => {
|
||||
@@ -18,13 +75,13 @@ export function SettingsView() {
|
||||
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<Link
|
||||
to="/settings/organizations"
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
|
||||
>
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Building2 className="size-4 text-accent" />
|
||||
<Building2 className="size-4 text-accent-fg" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Grupos</p>
|
||||
@@ -34,7 +91,7 @@ export function SettingsView() {
|
||||
|
||||
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
<Fingerprint className="size-4 text-accent-fg" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Seguridad</p>
|
||||
@@ -43,6 +100,8 @@ export function SettingsView() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AppearanceCard />
|
||||
|
||||
<Button variant="outline" onClick={handleSignOut}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from './lib/problem-details.js';
|
||||
export * from './lib/result.js';
|
||||
export * from './schemas/attendees.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';
|
||||
export * from './schemas/students.js';
|
||||
export * from './schemas/waitlist.js';
|
||||
@@ -6,7 +6,7 @@ const pageSchema = createPageSchema();
|
||||
const pageSizeSchema = createPageSizeSchema(10);
|
||||
const paginationSchema = createPaginationSchema();
|
||||
|
||||
export const StudentDtoSchema = z.object({
|
||||
export const AttendeeDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
groupId: z.string(),
|
||||
fullName: z.string(),
|
||||
@@ -18,16 +18,16 @@ export const StudentDtoSchema = z.object({
|
||||
createdAt: isoDateTimeSchema,
|
||||
updatedAt: isoDateTimeSchema,
|
||||
});
|
||||
export type StudentDto = z.output<typeof StudentDtoSchema>;
|
||||
export type AttendeeDto = z.output<typeof AttendeeDtoSchema>;
|
||||
|
||||
export const StudentQuerySchema = z.object({
|
||||
export const AttendeeQuerySchema = z.object({
|
||||
page: pageSchema,
|
||||
pageSize: pageSizeSchema,
|
||||
});
|
||||
export type StudentQuery = z.output<typeof StudentQuerySchema>;
|
||||
export type AttendeeQuery = z.output<typeof AttendeeQuerySchema>;
|
||||
|
||||
export const StudentListSchema = z.object({
|
||||
data: z.array(StudentDtoSchema),
|
||||
export const AttendeeListSchema = z.object({
|
||||
data: z.array(AttendeeDtoSchema),
|
||||
pagination: paginationSchema,
|
||||
});
|
||||
export type StudentList = z.output<typeof StudentListSchema>;
|
||||
export type AttendeeList = z.output<typeof AttendeeListSchema>;
|
||||
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
@@ -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>;
|
||||
@@ -11,7 +11,7 @@ export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCEL
|
||||
export const PaymentDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
groupId: z.string(),
|
||||
studentId: z.string(),
|
||||
attendeeId: z.string(),
|
||||
amount: z.coerce.number().positive(),
|
||||
currency: z.string(),
|
||||
status: paymentStatusSchema,
|
||||
|
||||
4
stack.md
@@ -41,7 +41,7 @@ gruperly/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...)
|
||||
│ │ │ ├── lib/ # Prisma (cliente + UnitOfWork), pagination, email, helpers
|
||||
│ │ │ ├── modules/ # Módulos: health-check, auth, groups, students, payments, waitlist
|
||||
│ │ │ ├── modules/ # Módulos: health-check, auth, groups, attendees, payments, waitlist
|
||||
│ │ │ │ └── <módulo>/
|
||||
│ │ │ │ ├── routes.ts
|
||||
│ │ │ │ └── features/<accion>/{route,use-case}.ts
|
||||
@@ -73,7 +73,7 @@ gruperly/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── schemas/ # Validaciones Zod compartidas
|
||||
│ │ │ │ ├── group.schema.ts
|
||||
│ │ │ │ ├── student.schema.ts
|
||||
│ │ │ │ ├── attendee.schema.ts
|
||||
│ │ │ │ └── payment.schema.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── tsconfig.json
|
||||
|
||||