Compare commits

5 Commits

Author SHA1 Message Date
Jose Selesan
fa30fe2cff Renamed Student to Attendee 2026-09-17 18:58:00 -03:00
Jose Selesan
e8bca3a5ae feat: implement AppLayoutGuard for onboarding redirection 2026-09-17 18:41:28 -03:00
Jose Selesan
6100c998be feat: add brand assets, design tokens and light/dark theme
- Use real logo/isotype SVGs across app and emails
- Token-based color system aligned with landing (brand scale + semantic tokens)
- ThemeProvider (light/dark/system) with header toggle and settings card
- Embed logo in transactional emails; centralize email colors
2026-09-16 17:35:24 -03:00
Jose Selesan
35ab3f0c06 Fix signout 2026-09-16 16:17:34 -03:00
Jose Selesan
a92e9e77c3 feat: basic onboarding flow\ 2026-09-15 15:41:45 -03:00
79 changed files with 2343 additions and 239 deletions

View File

@@ -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/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/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
- `src/logger.ts` — pino + pino-pretty. - `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`). - `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/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). - `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).

View File

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

View File

@@ -6,6 +6,7 @@ model User {
email String @unique email String @unique
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
onboardingCompleted Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -17,6 +18,7 @@ model User {
orgMemberships Member[] orgMemberships Member[]
orgInvitations Invitation[] @relation("InvitedBy") orgInvitations Invitation[] @relation("InvitedBy")
waitlist WaitlistEntry[] waitlist WaitlistEntry[]
merchantAccount MerchantAccount?
@@map("users") @@map("users")
} }

View File

@@ -4,18 +4,39 @@ model Group {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String? description String?
days WeekDay[]
time String?
capacity Int?
price Decimal? @db.Decimal(10, 2)
billingType BillingType?
dueDay Int?
createdById String createdById String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade) owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members GroupMember[] members GroupMember[]
students Student[] attendees Attendee[]
payments Payment[] payments Payment[]
@@map("groups") @@map("groups")
} }
enum WeekDay {
MONDAY
TUESDAY
WEDNESDAY
THURSDAY
FRIDAY
SATURDAY
SUNDAY
}
enum BillingType {
MONTHLY
PER_CLASS
}
model GroupMember { model GroupMember {
id String @id @default(cuid()) id String @id @default(cuid())
groupId String groupId String
@@ -36,7 +57,7 @@ enum Role {
MEMBER MEMBER
} }
model Student { model Attendee {
id String @id @default(cuid()) id String @id @default(cuid())
groupId String groupId String
fullName String fullName String
@@ -52,13 +73,13 @@ model Student {
payments Payment[] payments Payment[]
@@index([groupId]) @@index([groupId])
@@map("students") @@map("attendees")
} }
model Payment { model Payment {
id String @id @default(cuid()) id String @id @default(cuid())
groupId String groupId String
studentId String attendeeId String
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
currency String @default("MXN") currency String @default("MXN")
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
@@ -68,10 +89,10 @@ model Payment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
@@index([groupId]) @@index([groupId])
@@index([studentId]) @@index([attendeeId])
@@map("payments") @@map("payments")
} }
@@ -104,3 +125,23 @@ enum WaitlistStatus {
JOINED JOINED
DECLINED DECLINED
} }
enum PaymentProvider {
MERCADO_PAGO
STRIPE
}
model MerchantAccount {
id String @id @default(cuid())
userId String @unique
provider PaymentProvider
accessToken String?
sandbox Boolean @default(true)
connectedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("merchant_accounts")
}

View File

@@ -10,11 +10,12 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers'; import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
import { sessionAuthMiddleware } from '@/http/session-auth'; import { sessionAuthMiddleware } from '@/http/session-auth';
import { logger } from '@/logger'; import { logger } from '@/logger';
import { attendeesRoutes } from './modules/attendees';
import { authRoutes } from './modules/auth'; import { authRoutes } from './modules/auth';
import { groupsRoutes } from './modules/groups'; import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check'; import { healthCheckRoutes } from './modules/health-check';
import { onboardingRoutes } from './modules/onboarding';
import { paymentsRoutes } from './modules/payments'; import { paymentsRoutes } from './modules/payments';
import { studentsRoutes } from './modules/students';
import { waitlistRoutes } from './modules/waitlist'; import { waitlistRoutes } from './modules/waitlist';
const app = new Hono<BackendEnv>(); const app = new Hono<BackendEnv>();
@@ -33,7 +34,8 @@ api.use('*', sessionAuthMiddleware);
api.route('/auth', authRoutes); api.route('/auth', authRoutes);
api.route('/health', healthCheckRoutes); api.route('/health', healthCheckRoutes);
api.route('/groups', groupsRoutes); api.route('/groups', groupsRoutes);
api.route('/students', studentsRoutes); api.route('/onboarding', onboardingRoutes);
api.route('/attendees', attendeesRoutes);
api.route('/payments', paymentsRoutes); api.route('/payments', paymentsRoutes);
api.route('/waitlist', waitlistRoutes); api.route('/waitlist', waitlistRoutes);

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -87,3 +87,17 @@ export function noGroupAccessProblem(): ProblemDetails {
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails { export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
return notFoundProblem(`${resourceName} ${id}`); return notFoundProblem(`${resourceName} ${id}`);
} }
export function paymentNotSetupProblem(): ProblemDetails {
return conflictProblem({
detail: 'Vinculá una cuenta de cobro antes de crear tu primer grupo.',
code: 'payment_not_setup',
});
}
export function onboardingAlreadyCompletedProblem(): ProblemDetails {
return conflictProblem({
detail: 'Ya completaste el onboarding de Gruperly.',
code: 'onboarding_already_completed',
});
}

View File

@@ -14,7 +14,7 @@ export const corsMiddleware: MiddlewareHandler = honoCors({
return ''; return '';
}, },
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'], allowHeaders: ['Content-Type', 'Authorization', 'Cache-Control'],
maxAge: 600, maxAge: 600,
credentials: true, credentials: true,
}); });

View File

@@ -1,9 +1,31 @@
import { readFileSync } from 'node:fs'
import nodemailer, { type Transporter } from 'nodemailer' import nodemailer, { type Transporter } from 'nodemailer'
type VerificationEmailData = { email: string; url: string; name?: string } type VerificationEmailData = { email: string; url: string; name?: string }
type PasswordResetEmailData = { email: string; url: string; name?: string } type PasswordResetEmailData = { email: string; url: string; name?: string }
type InvitationEmailData = { email: string; url: string; organizationName: 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 { interface EmailProvider {
sendVerificationEmail(data: VerificationEmailData): Promise<void> sendVerificationEmail(data: VerificationEmailData): Promise<void>
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void> sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
@@ -30,11 +52,15 @@ class SMTPEmailProvider implements EmailProvider {
} }
private layout(title: string, body: string) { 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 ` return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: ${TEXT_MUTED};">
<h2 style="margin: 0 0 16px;">Gruperly</h2> <div style="text-align:center;margin-bottom:20px;">${header}</div>
<h3 style="margin: 0 0 8px;">${title}</h3> <h3 style="margin: 0 0 8px; color: ${TEXT_STRONG};">${title}</h3>
<p style="line-height: 1.6; color: #334155;">${body}</p> <p style="line-height: 1.6; color: ${TEXT_MUTED};">${body}</p>
</div> </div>
` `
} }
@@ -49,7 +75,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendVerificationEmail({ email, url, name }: VerificationEmailData) { 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( await this.send(
email, email,
'Verifica tu email en Gruperly', 'Verifica tu email en Gruperly',
@@ -61,7 +87,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) { 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( await this.send(
email, email,
'Restablece tu contraseña en Gruperly', 'Restablece tu contraseña en Gruperly',
@@ -73,7 +99,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) { 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( await this.send(
email, email,
`Te invitaron a ${organizationName} en Gruperly`, `Te invitaron a ${organizationName} en Gruperly`,

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

View File

@@ -1,14 +1,14 @@
import type { PrismaClient } from '@generated/prisma/client'; 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 { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination'; import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
type ListStudentsDeps = { type ListAttendeesDeps = {
db?: Pick<PrismaClient, 'student'>; db?: Pick<PrismaClient, 'attendee'>;
}; };
type StudentRecord = { type AttendeeRecord = {
id: string; id: string;
groupId: string; groupId: string;
fullName: string; fullName: string;
@@ -21,7 +21,7 @@ type StudentRecord = {
updatedAt: Date; updatedAt: Date;
}; };
function toStudentDto(record: StudentRecord): StudentDto { function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
return { return {
id: record.id, id: record.id,
groupId: record.groupId, groupId: record.groupId,
@@ -36,22 +36,22 @@ function toStudentDto(record: StudentRecord): StudentDto {
}; };
} }
export class ListStudents { export class ListAttendees {
constructor(private readonly deps: ListStudentsDeps = {}) {} 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 db = this.deps.db ?? prisma;
const [records, total] = await Promise.all([ const [records, total] = await Promise.all([
db.student.findMany({ db.attendee.findMany({
skip: getPaginationOffset(query), skip: getPaginationOffset(query),
take: query.pageSize, take: query.pageSize,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
db.student.count(), db.attendee.count(),
]); ]);
return ok({ return ok({
data: records.map(toStudentDto), data: records.map(toAttendeeDto),
pagination: getPaginationMetadata(query, total), pagination: getPaginationMetadata(query, total),
}); });
} }

View File

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

View File

@@ -12,22 +12,13 @@ import {
organizationNotFoundProblem, organizationNotFoundProblem,
} from '@/http/problem-builders'; } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma'; import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupDb, toGroupDto } from '../../lib'; import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupFromOrganizationDeps = { type CreateGroupFromOrganizationDeps = {
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>; db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
unitOfWork?: UnitOfWork; unitOfWork?: UnitOfWork;
}; };
type GroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
};
export class CreateGroupFromOrganization { export class CreateGroupFromOrganization {
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {} constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}

View File

@@ -8,21 +8,17 @@ import type {
import { ok } from '@gruperly/shared'; import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination'; import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib'; import {
buildGroupWhereForUser,
type GroupDb,
type GroupRecord,
toGroupDto,
} from '../../lib';
type ListGroupsDeps = { type ListGroupsDeps = {
db?: Pick<GroupDb, 'group'>; db?: Pick<GroupDb, 'group'>;
}; };
type GroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
};
export class ListGroups { export class ListGroups {
constructor(private readonly deps: ListGroupsDeps = {}) {} constructor(private readonly deps: ListGroupsDeps = {}) {}

View File

@@ -1,15 +1,23 @@
import type { PrismaClient } from '@generated/prisma/client'; import type { PrismaClient } from '@generated/prisma/client';
import type { GroupDto } from '@gruperly/shared'; import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>; export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
type GroupRecord = { type PriceLike = { toString(): string };
export type GroupRecord = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
days: WeekDay[] | null;
time: string | null;
capacity: number | null;
price: PriceLike | number | null;
billingType: BillingType | null;
dueDay: number | null;
}; };
export function toGroupDto(record: GroupRecord): GroupDto { export function toGroupDto(record: GroupRecord): GroupDto {
@@ -20,6 +28,12 @@ export function toGroupDto(record: GroupRecord): GroupDto {
createdById: record.createdById, createdById: record.createdById,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
days: record.days,
time: record.time,
capacity: record.capacity,
price: record.price == null ? null : Number(record.price.toString()),
billingType: record.billingType,
dueDay: record.dueDay,
}; };
} }

View File

@@ -0,0 +1,20 @@
import type { CreateFirstGroup } from '@gruperly/shared';
import { CreateFirstGroupSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateFirstGroup as CreateFirstGroupUseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateFirstGroupSchema), async (c) => {
const user = c.get('user');
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
const data = c.req.valid('json') as CreateFirstGroup;
const useCase = new CreateFirstGroupUseCase();
const result = await useCase.execute(data, user.id);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,88 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateFirstGroup as CreateFirstGroupInput,
CreateFirstGroupResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
onboardingAlreadyCompletedProblem,
paymentNotSetupProblem,
} from '@/http/problem-builders';
import { problemDetails } from '@/http/problem-details';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { toOnboardingGroupDto } from '../../lib';
type CreateFirstGroupDeps = {
db?: Pick<PrismaClient, 'user' | 'merchantAccount'>;
unitOfWork?: UnitOfWork;
};
export class CreateFirstGroup {
constructor(private readonly deps: CreateFirstGroupDeps = {}) {}
async execute(
data: CreateFirstGroupInput,
userId: string,
): Promise<Result<CreateFirstGroupResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return err(problemDetails({ title: 'Not Found', status: 404, code: 'user_not_found', detail: 'Usuario no encontrado.' }));
}
if (user.onboardingCompleted) {
return err(onboardingAlreadyCompletedProblem());
}
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (!merchantAccount) {
return err(paymentNotSetupProblem());
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: data.name,
createdById: userId,
days: data.days,
time: data.time,
capacity: data.capacity,
price: data.price,
billingType: data.billingType,
dueDay: data.dueDay,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
await tx.user.update({
where: { id: userId },
data: { onboardingCompleted: true },
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({
group: toOnboardingGroupDto(result.value),
onboardingCompleted: true,
});
}
}

View File

@@ -0,0 +1,20 @@
import type { ConnectPayment } from '@gruperly/shared';
import { ConnectPaymentSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { PaymentSetup } from './use-case';
const route = new Hono();
route.post('/', validate.json(ConnectPaymentSchema), async (c) => {
const user = c.get('user');
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
const data = c.req.valid('json') as ConnectPayment;
const useCase = new PaymentSetup();
const result = await useCase.execute(data, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,38 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ConnectPayment, ConnectPaymentResult, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
type PaymentSetupDeps = { db?: PrismaClient };
export class PaymentSetup {
constructor(private readonly deps: PaymentSetupDeps = {}) {}
async execute(
data: ConnectPayment,
userId: string,
): Promise<Result<ConnectPaymentResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
await db.merchantAccount.upsert({
where: { userId },
create: {
userId,
provider: data.provider,
accessToken: data.accessToken,
sandbox: data.sandbox,
},
update: {
provider: data.provider,
accessToken: data.accessToken,
sandbox: data.sandbox,
},
});
return ok({
provider: data.provider,
sandbox: data.sandbox,
connected: true,
});
}
}

View File

@@ -0,0 +1,16 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { GetOnboardingStatus } from './use-case';
const route = new Hono();
route.get('/', async (c) => {
const user = c.get('user');
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
const useCase = new GetOnboardingStatus();
const result = await useCase.execute(user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,38 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { OnboardingStatusDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { problemDetails } from '@/http/problem-details';
import prisma from '@/lib/prisma';
type GetOnboardingStatusDeps = { db?: PrismaClient };
export class GetOnboardingStatus {
constructor(private readonly deps: GetOnboardingStatusDeps = {}) {}
async execute(userId: string): Promise<Result<OnboardingStatusDto, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return err(
problemDetails({
title: 'Not Found',
status: 404,
code: 'user_not_found',
detail: 'Usuario no encontrado.',
}),
);
}
if (user.onboardingCompleted) {
return ok({ step: 'COMPLETED', paymentConnected: true, completed: true });
}
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (merchantAccount) {
return ok({ step: 'PAYMENT_CONNECTED', paymentConnected: true, completed: false });
}
return ok({ step: 'NOT_STARTED', paymentConnected: false, completed: false });
}
}

View File

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

View File

@@ -0,0 +1,35 @@
import type { BillingType, OnboardingGroupDto, WeekDay } from '@gruperly/shared';
type PriceLike = { toString(): string };
export type OnboardingGroupRecord = {
id: string;
name: string;
description: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date;
days: WeekDay[] | null;
time: string | null;
capacity: number | null;
price: PriceLike | number | null;
billingType: BillingType | null;
dueDay: number | null;
};
export function toOnboardingGroupDto(record: OnboardingGroupRecord): OnboardingGroupDto {
return {
id: record.id,
name: record.name,
description: record.description,
createdById: record.createdById,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
days: record.days,
time: record.time,
capacity: record.capacity,
price: record.price == null ? null : Number(record.price.toString()),
billingType: record.billingType,
dueDay: record.dueDay,
};
}

View File

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

View File

@@ -0,0 +1,12 @@
import { Hono } from 'hono';
import firstGroupRoute from './features/first-group/route';
import paymentSetupRoute from './features/payment-setup/route';
import statusRoute from './features/status/route';
const routes = new Hono();
routes.route('/status', statusRoute);
routes.route('/payment-setup', paymentSetupRoute);
routes.route('/first-group', firstGroupRoute);
export default routes;

View File

@@ -11,7 +11,7 @@ type ListPaymentsDeps = {
type PaymentRecord = { type PaymentRecord = {
id: string; id: string;
groupId: string; groupId: string;
studentId: string; attendeeId: string;
amount: { toString(): string }; amount: { toString(): string };
currency: string; currency: string;
status: PaymentDto['status']; status: PaymentDto['status'];
@@ -25,7 +25,7 @@ function toPaymentDto(record: PaymentRecord): PaymentDto {
return { return {
id: record.id, id: record.id,
groupId: record.groupId, groupId: record.groupId,
studentId: record.studentId, attendeeId: record.attendeeId,
amount: Number(record.amount.toString()), amount: Number(record.amount.toString()),
currency: record.currency, currency: record.currency,
status: record.status, status: record.status,

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Hono } from 'hono';
mock.module('@/lib/prisma', () => ({ mock.module('@/lib/prisma', () => ({
default: { default: {
student: { attendee: {
findMany: mock(), findMany: mock(),
count: mock(), count: mock(),
}, },
@@ -13,13 +13,13 @@ mock.module('@/lib/prisma', () => ({
})); }));
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { studentsRoutes } from '@/modules/students'; import { attendeesRoutes } from '@/modules/attendees';
const app = new Hono(); const app = new Hono();
app.route('/students', studentsRoutes); app.route('/attendees', attendeesRoutes);
const student = { const attendee = {
id: 'student-1', id: 'attendee-1',
groupId: 'group-1', groupId: 'group-1',
fullName: 'Ana Pérez', fullName: 'Ana Pérez',
email: 'ana@example.com', email: 'ana@example.com',
@@ -31,41 +31,41 @@ const student = {
updatedAt: new Date('2026-08-01T10:00:00.000Z'), updatedAt: new Date('2026-08-01T10:00:00.000Z'),
}; };
describe('students routes', () => { describe('attendees routes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('lists students with pagination', async () => { it('lists attendees with pagination', async () => {
prisma.student.findMany.mockResolvedValue([student]); prisma.attendee.findMany.mockResolvedValue([attendee]);
prisma.student.count.mockResolvedValue(21); 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(res.status).toBe(200);
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
data: [ data: [
{ {
...student, ...attendee,
createdAt: student.createdAt.toISOString(), createdAt: attendee.createdAt.toISOString(),
updatedAt: student.updatedAt.toISOString(), updatedAt: attendee.updatedAt.toISOString(),
}, },
], ],
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 }, pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
}); });
expect(prisma.student.findMany).toHaveBeenCalledWith({ expect(prisma.attendee.findMany).toHaveBeenCalledWith({
skip: 10, skip: 10,
take: 10, take: 10,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
expect(prisma.student.count).toHaveBeenCalledWith(); expect(prisma.attendee.count).toHaveBeenCalledWith();
}); });
it('uses default pagination', async () => { it('uses default pagination', async () => {
prisma.student.findMany.mockResolvedValue([]); prisma.attendee.findMany.mockResolvedValue([]);
prisma.student.count.mockResolvedValue(0); prisma.attendee.count.mockResolvedValue(0);
const res = await app.request('/students'); const res = await app.request('/attendees');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
@@ -75,7 +75,7 @@ describe('students routes', () => {
}); });
it('rejects invalid pagination query parameters', async () => { 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(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' }); expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });

View File

@@ -36,6 +36,12 @@ const group = {
createdById: userId, createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'), createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'), updatedAt: new Date('2026-08-01T10:00:00.000Z'),
days: null,
time: null,
capacity: null,
price: null,
billingType: null,
dueDay: null,
}; };
function makeApp(userValue: unknown) { function makeApp(userValue: unknown) {

View File

@@ -0,0 +1,281 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
user: {
findUnique: mock(),
update: mock(),
},
merchantAccount: {
findUnique: mock(),
upsert: mock(),
},
group: {
create: mock(),
},
groupMember: {
create: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { onboardingRoutes } from '@/modules/onboarding';
const userId = 'user-1';
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/onboarding', onboardingRoutes);
return app;
}
const group = {
id: 'group-1',
name: 'Clase de Yoga',
description: null,
createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
};
const firstGroupPayload = {
name: group.name,
days: group.days,
time: group.time,
capacity: group.capacity,
price: group.price,
billingType: group.billingType,
dueDay: group.dueDay,
};
describe('onboarding routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /onboarding/status', () => {
it('returns NOT_STARTED for a user that has not started', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'NOT_STARTED',
paymentConnected: false,
completed: false,
});
});
it('returns PAYMENT_CONNECTED when the merchant account exists', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'PAYMENT_CONNECTED',
paymentConnected: true,
completed: false,
});
});
it('returns COMPLETED when onboarding is already done', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
const res = await makeApp({ id: userId }).request('/onboarding/status');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
step: 'COMPLETED',
paymentConnected: true,
completed: true,
});
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/status');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
});
describe('POST /onboarding/payment-setup', () => {
it('connects a merchant account in sandbox mode', async () => {
prisma.merchantAccount.upsert.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'MERCADO_PAGO', sandbox: true }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
provider: 'MERCADO_PAGO',
sandbox: true,
connected: true,
});
expect(prisma.merchantAccount.upsert).toHaveBeenCalledWith({
where: { userId },
create: { userId, provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
update: { provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
});
});
it('rejects an unknown provider', async () => {
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'PAYPAL' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify({ provider: 'STRIPE', sandbox: true }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});
describe('POST /onboarding/first-group', () => {
it('creates the first group and completes onboarding', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
prisma.group.create.mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
group: {
id: 'group-1',
name: 'Clase de Yoga',
description: null,
createdById: userId,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
},
onboardingCompleted: true,
});
expect(prisma.group.create).toHaveBeenCalledWith({
data: {
name: 'Clase de Yoga',
createdById: userId,
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '09:00',
capacity: 20,
price: 150.5,
billingType: 'MONTHLY',
dueDay: 5,
},
});
expect(prisma.groupMember.create).toHaveBeenCalledWith({
data: { groupId: 'group-1', userId, role: 'OWNER' },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: userId },
data: { onboardingCompleted: true },
});
});
it('rejects when the payment has not been set up', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects when onboarding is already completed', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: 'onboarding_already_completed' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects invalid payloads', async () => {
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify({
name: 'Ab',
days: [],
time: '25:99',
capacity: 0,
price: -1,
billingType: 'WEEKLY',
dueDay: 31,
}),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects without a session', async () => {
const res = await makeApp(null).request('/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(firstGroupPayload),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});
});

View File

@@ -20,8 +20,8 @@ app.route('/payments', paymentsRoutes);
const payment = { const payment = {
id: 'payment-1', id: 'payment-1',
groupId: 'group-1', groupId: 'group-1',
studentId: 'student-1', attendeeId: 'attendee-1',
amount: { toString: () => '150.50' } as { toString(): string }, amount: { toString: () => '150.50' } as { toString(): string },
currency: 'MXN', currency: 'MXN',
status: 'pending', status: 'pending',
@@ -47,8 +47,8 @@ describe('payments routes', () => {
data: [ data: [
{ {
id: 'payment-1', id: 'payment-1',
groupId: 'group-1', groupId: 'group-1',
studentId: 'student-1', attendeeId: 'attendee-1',
amount: 150.5, amount: 150.5,
currency: 'MXN', currency: 'MXN',
status: 'pending', status: 'pending',

View File

@@ -35,7 +35,7 @@ describe('session auth', () => {
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true); expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).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/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 () => { it('allows public requests without a session', async () => {

View File

@@ -4,6 +4,25 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gruperly</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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

View 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

View 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

View 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

View 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

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Logo, LogoIcon } from '../brand' import { Logo } from '../brand'
export type AuthShellProps = { export type AuthShellProps = {
title: string title: string
@@ -11,11 +11,10 @@ export function AuthShell({ title, subtitle, children }: AuthShellProps) {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8"> <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="w-full max-w-sm">
<div className="mb-6 flex items-center justify-center gap-2"> <div className="mb-6 flex items-center justify-center">
<LogoIcon className="size-9" /> <Logo className="h-10" />
<Logo className="text-xl" />
</div> </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> <h1 className="text-xl font-bold text-primary">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null} {subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
<div className="mt-5">{children}</div> <div className="mt-5">{children}</div>

View File

@@ -1,21 +1,33 @@
import { forwardRef, type HTMLAttributes } from 'react' import { forwardRef, type SVGProps } from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
export type LogoProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & { export type LogoProps = SVGProps<SVGSVGElement>
className?: string
}
export const Logo = forwardRef<HTMLSpanElement, LogoProps>( export const Logo = forwardRef<SVGSVGElement, LogoProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
return ( return (
<span <svg
ref={ref} 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} {...props}
> >
gruperl <title>Gruperly</title>
<span className="text-accent">y</span> <path
</span> 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>
) )
}, },
) )

View File

@@ -10,26 +10,20 @@ export const LogoIcon = forwardRef<SVGSVGElement, LogoIconProps>(
return ( return (
<svg <svg
ref={ref} ref={ref}
viewBox="0 0 40 40" viewBox="718.5 93 144 144"
fill="none"
role="img" role="img"
aria-label="Gruperly" aria-label="Gruperly"
className={cn('size-8', className)} className={cn('size-8', className)}
{...props} {...props}
> >
<rect <title>Gruperly</title>
width="40" <path
height="40" className="fill-brand-night dark:fill-brand-cloud"
rx="10" 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"
className="fill-accent-soft"
/> />
<path <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-brand-celeste"
className="fill-accent" 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"
/>
<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"
/> />
</svg> </svg>
) )

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

View File

@@ -6,7 +6,7 @@ export function BottomNav() {
const location = useLocation() const location = useLocation()
return ( 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"> <div className="mx-auto grid max-w-md grid-cols-4">
{NAV_ITEMS.map(({ label, to, icon: Icon }) => { {NAV_ITEMS.map(({ label, to, icon: Icon }) => {
const isActive = location.pathname === to const isActive = location.pathname === to
@@ -16,7 +16,7 @@ export function BottomNav() {
to={to} to={to}
className={cn( className={cn(
'flex flex-col items-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors', '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 <Icon

View File

@@ -1,20 +1,39 @@
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 { Breadcrumb } from './Breadcrumb'
import { UserMenu } from './UserMenu' 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() { export function Header() {
return ( return (
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur"> <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="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"> <div className="flex min-w-0 items-center">
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio"> <a href="/" className="flex items-center lg:hidden" aria-label="Gruperly inicio">
<LogoIcon className="size-7" /> <Logo className="h-8" />
<Logo className="text-lg tracking-tight" />
</a> </a>
<Breadcrumb /> <Breadcrumb />
</div> </div>
<div className="flex items-center gap-2">
<ThemeToggle />
<UserMenu /> <UserMenu />
</div> </div>
</div>
</header> </header>
) )
} }

View File

@@ -1,16 +1,15 @@
import { Link, useLocation } from '@tanstack/react-router' import { Link, useLocation } from '@tanstack/react-router'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { Logo, LogoIcon } from '../brand' import { Logo } from '../brand'
import { NAV_ITEMS } from './nav-items' import { NAV_ITEMS } from './nav-items'
export function Sidebar() { export function Sidebar() {
const location = useLocation() const location = useLocation()
return ( 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"> <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 gap-2 px-1" aria-label="Gruperly inicio"> <a href="/" className="mb-8 flex items-center px-1" aria-label="Gruperly inicio">
<LogoIcon className="size-8" /> <Logo className="h-10" />
<Logo className="text-lg tracking-tight" />
</a> </a>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
@@ -23,7 +22,7 @@ export function Sidebar() {
className={cn( className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors', 'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
isActive isActive
? 'bg-accent-soft text-accent' ? 'bg-accent-soft text-accent-fg'
: 'text-foreground/60 hover:bg-primary-soft hover:text-primary', : 'text-foreground/60 hover:bg-primary-soft hover:text-primary',
)} )}
> >

View File

@@ -78,15 +78,15 @@ export function UserMenu() {
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden"> <div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
<div <div
aria-hidden="true" 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)} 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 <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-label="Cerrar menú" 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" /> <X className="size-5" />
</button> </button>
@@ -105,7 +105,7 @@ export function UserMenu() {
</div> </div>
</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 <Link
role="menuitem" role="menuitem"
to="/profile" to="/profile"
@@ -132,7 +132,7 @@ export function UserMenu() {
onClick={handleSignOut} onClick={handleSignOut}
className={cn( className={cn(
itemClass, 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" /> <LogOut className="size-5 text-foreground/60" />
@@ -147,7 +147,7 @@ export function UserMenu() {
: open ? ( : open ? (
<div <div
role="menu" 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"> <div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p> <p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>

View File

@@ -0,0 +1,84 @@
import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import type { OnboardingGroupDto } from '@gruperly/shared'
import { ArrowRight, CheckCircle2, CreditCard } from 'lucide-react'
import { useAuth } from '../../context/AuthProvider'
import { Badge, Button } from '../ui'
import { BILLING_LABELS, formatPrice, formatSchedule } from './constants'
type ConfirmationStepProps = {
group: OnboardingGroupDto
providerName?: string
}
export function ConfirmationStep({ group, providerName }: ConfirmationStepProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { refresh } = useAuth()
const goToDashboard = () => {
// Revalida la caché (estado del onboarding, sesión, grupos) antes de redirigir.
void queryClient.invalidateQueries()
void refresh()
void navigate({ to: '/' })
}
return (
<section className="space-y-6">
<header className="space-y-2 text-center">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-success-soft">
<CheckCircle2 className="size-7 text-success" />
</span>
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
<p className="text-sm text-foreground/60">
Tu grupo se creó y ya podés empezar a cobrar a tus 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>
)
}

View File

@@ -0,0 +1,236 @@
import { useMutation } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared'
import { CreateFirstGroupSchema } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
import { createFirstGroup } from '../../lib/api'
import { cn } from '../../lib/utils'
import { Button, Input, Label } from '../ui'
import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants'
const defaultValues: CreateFirstGroup = {
name: '',
days: [],
time: '09:00',
capacity: 1,
price: 0,
billingType: 'MONTHLY',
dueDay: 1,
}
type FirstGroupStepProps = {
onBack: () => void
onCompleted: (group: OnboardingGroupDto) => void
}
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateFirstGroup>({
resolver: zodResolver(CreateFirstGroupSchema),
defaultValues,
mode: 'onTouched',
})
const days = watch('days')
const billingType = watch('billingType')
const dueDay = watch('dueDay')
const create = useMutation({
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
onSuccess: (result) => onCompleted(result.group),
})
const toggleDay = (day: WeekDay) => {
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
setValue('days', next, { shouldValidate: true })
}
const selectBillingType = (type: BillingType) => {
setValue('billingType', type, { shouldValidate: true })
}
return (
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
<header className="space-y-1">
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
<p className="text-sm text-foreground/60">
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
</p>
</header>
<div>
<Label htmlFor="name">Nombre del grupo</Label>
<Input
id="name"
placeholder="Ej: Yoga Vinyasa · Nivel 1"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label>Días de clase</Label>
<div className="flex flex-wrap gap-2">
{WEEK_DAY_CHIPS.map(({ value, label }) => {
const isActive = days.includes(value)
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => toggleDay(value)}
className={cn(
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
isActive
? 'border-accent bg-accent text-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>
)
}

View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import type { ConnectPaymentResult, PaymentProvider } from '@gruperly/shared'
import {
ArrowLeft,
ArrowRight,
BadgeCheck,
Check,
CheckCircle2,
CreditCard,
Loader2,
ShieldCheck,
} from 'lucide-react'
import { connectPayment } from '../../lib/api'
import { cn } from '../../lib/utils'
import { Badge, Button } from '../ui'
import { PAYMENT_PROVIDERS } from './constants'
type PaymentStepProps = {
initialResult: ConnectPaymentResult | null
onConnected: (result: ConnectPaymentResult) => void
onBack: () => void
}
export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepProps) {
const [selected, setSelected] = useState<PaymentProvider>('MERCADO_PAGO')
const [connected, setConnected] = useState<ConnectPaymentResult | null>(initialResult)
const connect = useMutation({
mutationFn: () => connectPayment({ provider: selected, sandbox: true }),
onSuccess: (result) => {
setConnected(result)
onConnected(result)
},
})
if (connected) {
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-2xl font-bold text-primary">Tu cobro está conectado</h2>
<p className="text-sm text-foreground/60">
Ya podés dejar listo tu primer grupo para cobrar con{' '}
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}.
</p>
</header>
<div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-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>
)
}

View File

@@ -0,0 +1,59 @@
import { Check } from 'lucide-react'
import { cn } from '../../lib/utils'
export type StepperProps = {
steps: readonly { label: string }[]
current: number
}
export function Stepper({ steps, current }: StepperProps) {
return (
<ol aria-label="Progreso del alta" className="flex items-center">
{steps.map((step, index) => {
const isDone = index < current
const isActive = index === current
return (
<li
key={step.label}
className={cn('flex items-center', index < steps.length - 1 ? 'flex-1' : '')}
>
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
isDone && 'border-accent bg-accent text-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>
)
}

View File

@@ -0,0 +1,63 @@
import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react'
import { Button } from '../ui'
type WelcomeStepProps = {
name: string
onNext: () => void
}
const STEPS_TO_SETUP = [
{
icon: Wallet,
title: 'Conectá tu cuenta de cobro',
description: 'Mercado Pago o Stripe, en modo prueba por ahora.',
},
{
icon: Users,
title: 'Creá tu primer grupo',
description: 'Días, horario, precio y cupo de tu clase.',
},
{
icon: Rocket,
title: 'Empezá a cobrar',
description: 'Todo listo para sumar 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>
)
}

View File

@@ -0,0 +1,44 @@
import type { BillingType, PaymentProvider, WeekDay } from '@gruperly/shared'
export { BILLING_LABELS, WEEK_DAY_FULL_LABELS, formatPrice, formatSchedule } from '../../lib/format'
export const WEEK_DAYS: readonly WeekDay[] = [
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
]
export const WEEK_DAY_CHIPS: readonly { value: WeekDay; label: string }[] = [
{ value: 'MONDAY', label: 'Lun' },
{ value: 'TUESDAY', label: 'Mar' },
{ value: 'WEDNESDAY', label: 'Mié' },
{ value: 'THURSDAY', label: 'Jue' },
{ value: 'FRIDAY', label: 'Vie' },
{ value: 'SATURDAY', label: 'Sáb' },
{ value: 'SUNDAY', label: 'Dom' },
]
export const PAYMENT_PROVIDERS: readonly {
value: PaymentProvider
name: string
description: string
}[] = [
{
value: 'MERCADO_PAGO',
name: 'Mercado Pago',
description: 'El procesador más usado en Latinoamérica',
},
{
value: 'STRIPE',
name: 'Stripe',
description: 'Cobrá con tarjetas e internacionalmente',
},
]
export const BILLING_TYPES: readonly { value: BillingType; label: string; hint: string }[] = [
{ value: 'MONTHLY', label: 'Mensual', hint: 'Un cobro por mes' },
{ value: 'PER_CLASS', label: 'Por clase', hint: 'Cada clase que asista' },
]

View File

@@ -0,0 +1,5 @@
export { Stepper, type StepperProps } from './Stepper'
export { WelcomeStep } from './WelcomeStep'
export { PaymentStep } from './PaymentStep'
export { FirstGroupStep } from './FirstGroupStep'
export { ConfirmationStep } from './ConfirmationStep'

View File

@@ -22,7 +22,7 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
<div <div
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@@ -10,9 +10,9 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
} }
const variants: Record<ButtonVariant, string> = { 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', 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> = { const sizes: Record<ButtonSize, string> = {

View File

@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
<input <input
ref={ref} ref={ref}
className={cn( 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', invalid && 'border-danger focus:border-danger focus:ring-danger/30',
className, className,
)} )}

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

View File

@@ -1,51 +1,144 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @custom-variant dark (&:where(.dark, .dark *));
/* 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;
/* Badges de estado */ @theme inline {
--color-success: #10b981; /* Escala de marca (identidad fija, igual a la landing) */
--color-success-soft: #dcfce7; --color-brand-50: var(--brand-50);
--color-warning: #f59e0b; --color-brand-100: var(--brand-100);
--color-warning-soft: #fef3c7; --color-brand-200: var(--brand-200);
--color-danger: #ef4444; --color-brand-300: var(--brand-300);
--color-danger-soft: #fee2e2; --color-brand-400: var(--brand-400);
--color-border: #e2e8f0; --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 */ /* Border radius por defecto */
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
/* Tipografía (igual que la landing) */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Animaciones */ /* Animaciones */
--animate-fade-in: fade-in 0.2s ease-out; --animate-fade-in: fade-in 0.2s ease-out;
--animate-step-enter: step-enter 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes step-enter {
from { from {
opacity: 0; opacity: 0;
transform: translateX(1.5rem);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0);
} }
} }
} }
@layer base { :root {
:root {
color-scheme: light; 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; @apply border-border;
} }
body { body {
@apply bg-background text-primary antialiased; @apply bg-background text-primary antialiased transition-colors;
min-height: 100dvh; min-height: 100dvh;
} }
} }

65
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,65 @@
import type {
ConnectPayment,
ConnectPaymentResult,
CreateFirstGroup,
CreateFirstGroupResult,
GroupList,
OnboardingStatusDto,
ProblemDetails,
} from '@gruperly/shared'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
export class ApiError extends Error {
constructor(
readonly status: number,
readonly problem: ProblemDetails | null,
) {
super(problem?.title ?? `Error ${status}`)
this.name = 'ApiError'
}
}
type JsonBody = Record<string, unknown> | unknown[]
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
headers.set('Content-Type', 'application/json')
const res = await fetch(`${API_URL}${path}`, {
...init,
headers,
credentials: 'include',
})
if (!res.ok) {
let problem: ProblemDetails | null = null
try {
const body = (await res.json()) as Partial<ProblemDetails>
if (body && typeof body === 'object' && typeof body.title === 'string') {
problem = body as ProblemDetails
}
} catch {
// Sin cuerpo JSON: usamos el error genérico.
}
throw new ApiError(res.status, problem)
}
return (await res.json()) as T
}
export const getOnboardingStatus = () => apiFetch<OnboardingStatusDto>('/api/v1/onboarding/status')
export const connectPayment = (payload: ConnectPayment) =>
apiFetch<ConnectPaymentResult>('/api/v1/onboarding/payment-setup', {
method: 'POST',
body: JSON.stringify(payload),
})
export const createFirstGroup = (payload: CreateFirstGroup) =>
apiFetch<CreateFirstGroupResult>('/api/v1/onboarding/first-group', {
method: 'POST',
body: JSON.stringify(payload),
})
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')

View File

@@ -0,0 +1,26 @@
import type { BillingType, WeekDay } from '@gruperly/shared'
export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = {
MONDAY: 'Lunes',
TUESDAY: 'Martes',
WEDNESDAY: 'Miércoles',
THURSDAY: 'Jueves',
FRIDAY: 'Viernes',
SATURDAY: 'Sábado',
SUNDAY: 'Domingo',
}
export const BILLING_LABELS: Record<BillingType, string> = {
MONTHLY: 'Mensual',
PER_CLASS: 'Por clase',
}
export function formatSchedule(days: readonly WeekDay[], time: string | null): string {
const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day])
return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ')
}
export function formatPrice(price: number | null): string {
if (price == null) return '—'
return price.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' })
}

View File

@@ -1,14 +1,26 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { router } from './router' import { router } from './router'
import { AuthProvider } from './context/AuthProvider' import { AuthProvider } from './context/AuthProvider'
import { ThemeProvider } from './context/ThemeProvider'
import './index.css' import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
},
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider> </AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -1,5 +1,5 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' 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 { HomeView } from './routes/home'
import { GroupsView } from './routes/groups' import { GroupsView } from './routes/groups'
import { PaymentsView } from './routes/payments' import { PaymentsView } from './routes/payments'
@@ -10,6 +10,7 @@ import { OrganizationsPage } from './routes/organizations'
import { LoginPage } from './routes/auth/login' import { LoginPage } from './routes/auth/login'
import { SignupPage } from './routes/auth/signup' import { SignupPage } from './routes/auth/signup'
import { VerifyEmailPage } from './routes/auth/verify-email' import { VerifyEmailPage } from './routes/auth/verify-email'
import { OnboardingView } from './routes/onboarding'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: () => <Outlet />, component: () => <Outlet />,
@@ -33,11 +34,18 @@ const verifyEmailRoute = createRoute({
component: VerifyEmailPage, component: VerifyEmailPage,
}) })
const onboardingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/onboarding',
component: OnboardingView,
})
// Capa con la navegación de la app autenticada (Sidebar + BottomNav). // Capa con la navegación de la app autenticada (Sidebar + BottomNav).
// El guard redirige a /onboarding a quien no completó el onboarding.
const appLayoutRoute = createRoute({ const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
id: 'app', id: 'app',
component: RootLayout, component: AppLayoutGuard,
}) })
const indexRoute = createRoute({ const indexRoute = createRoute({
@@ -86,6 +94,7 @@ const routeTree = rootRoute.addChildren([
loginRoute, loginRoute,
signupRoute, signupRoute,
verifyEmailRoute, verifyEmailRoute,
onboardingRoute,
appLayoutRoute.addChildren([ appLayoutRoute.addChildren([
indexRoute, indexRoute,
groupsRoute, groupsRoute,

View File

@@ -74,7 +74,7 @@ export function VerifyEmailPage() {
{status === 'success' ? ( {status === 'success' ? (
<Link <Link
to="/" 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 Ir a Gruperly
</Link> </Link>

View File

@@ -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() { export function GroupsView() {
const navigate = useNavigate()
const groupsQuery = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
return ( return (
<section> <section>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-primary">Grupos</h1> <h1 className="text-2xl font-bold text-primary">Grupos</h1>
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p> <p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
</div>
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
<Plus className="size-4" />
Crear
</Button>
</div>
{groupsQuery.isPending ? (
<div className="mt-8 flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-foreground/40" />
</div>
) : null}
{groupsQuery.isError ? (
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
<p>No pudimos cargar tus grupos.</p>
<Button variant="outline" size="sm" onClick={() => void groupsQuery.refetch()}>
Reintentar
</Button>
</div>
) : null}
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
<div className="mt-8 rounded-xl border border-dashed border-border bg-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> </section>
) )
} }

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

View File

@@ -69,7 +69,7 @@ function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
} }
return ( 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) => ( {organizations.map((org) => (
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3"> <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"> <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> <p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
) : null} ) : 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"> <div className="mb-4 flex items-center gap-2">
<Plus className="size-4 text-accent" /> <Plus className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Crear grupo</h2> <h2 className="text-base font-semibold text-primary">Crear grupo</h2>
@@ -182,7 +182,7 @@ export function OrganizationsPage() {
</form> </form>
</div> </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> <h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} /> <OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
</div> </div>

View File

@@ -11,7 +11,7 @@ export function ProfileView() {
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p> <p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
</div> </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"> <div className="flex items-center gap-4">
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" /> <Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -42,7 +42,7 @@ function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
} }
return ( 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) => ( {passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3"> <li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2"> <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> <p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
) : null} ) : 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"> <div className="mb-4 flex items-center gap-2">
<KeyRound className="size-4 text-accent" /> <KeyRound className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2> <h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
@@ -165,7 +165,7 @@ export function SecurityPage() {
</form> </form>
</div> </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="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Fingerprint className="size-4 text-accent" /> <Fingerprint className="size-4 text-accent" />

View File

@@ -1,7 +1,64 @@
import { Link } from '@tanstack/react-router' 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 { signOut } from '../lib/auth-client'
import { Button } from '../components/ui' 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() { export function SettingsView() {
const handleSignOut = async () => { 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> <p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
</div> </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 <Link
to="/settings/organizations" to="/settings/organizations"
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
> >
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" /> <Building2 className="size-4 text-accent-fg" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Grupos</p> <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"> <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"> <span className="rounded-lg bg-accent-soft p-2">
<Fingerprint className="size-4 text-accent" /> <Fingerprint className="size-4 text-accent-fg" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Seguridad</p> <p className="text-sm font-medium text-primary">Seguridad</p>
@@ -43,6 +100,8 @@ export function SettingsView() {
</Link> </Link>
</div> </div>
<AppearanceCard />
<Button variant="outline" onClick={handleSignOut}> <Button variant="outline" onClick={handleSignOut}>
Cerrar sesión Cerrar sesión
</Button> </Button>

View File

@@ -1,9 +1,11 @@
export * from './lib/problem-details.js'; export * from './lib/problem-details.js';
export * from './lib/result.js'; export * from './lib/result.js';
export * from './schemas/attendees.js';
export * from './schemas/enums.js';
export * from './schemas/groups.js'; export * from './schemas/groups.js';
export * from './schemas/health-check.js'; export * from './schemas/health-check.js';
export * from './schemas/onboarding.js';
export * from './schemas/pagination.js'; export * from './schemas/pagination.js';
export * from './schemas/payments.js'; export * from './schemas/payments.js';
export * from './schemas/problem-details.js'; export * from './schemas/problem-details.js';
export * from './schemas/students.js';
export * from './schemas/waitlist.js'; export * from './schemas/waitlist.js';

View File

@@ -6,7 +6,7 @@ const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10); const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema(); const paginationSchema = createPaginationSchema();
export const StudentDtoSchema = z.object({ export const AttendeeDtoSchema = z.object({
id: z.string(), id: z.string(),
groupId: z.string(), groupId: z.string(),
fullName: z.string(), fullName: z.string(),
@@ -18,16 +18,16 @@ export const StudentDtoSchema = z.object({
createdAt: isoDateTimeSchema, createdAt: isoDateTimeSchema,
updatedAt: 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, page: pageSchema,
pageSize: pageSizeSchema, pageSize: pageSizeSchema,
}); });
export type StudentQuery = z.output<typeof StudentQuerySchema>; export type AttendeeQuery = z.output<typeof AttendeeQuerySchema>;
export const StudentListSchema = z.object({ export const AttendeeListSchema = z.object({
data: z.array(StudentDtoSchema), data: z.array(AttendeeDtoSchema),
pagination: paginationSchema, pagination: paginationSchema,
}); });
export type StudentList = z.output<typeof StudentListSchema>; export type AttendeeList = z.output<typeof AttendeeListSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
// Días de la semana en que se dictan las clases.
export const weekdaySchema = z.enum([
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
]);
export type WeekDay = z.output<typeof weekdaySchema>;
// Procesadores de pago soportados.
export const paymentProviderSchema = z.enum(['MERCADO_PAGO', 'STRIPE']);
export type PaymentProvider = z.output<typeof paymentProviderSchema>;
// Frecuencia de cobro de un grupo.
export const billingTypeSchema = z.enum(['MONTHLY', 'PER_CLASS']);
export type BillingType = z.output<typeof billingTypeSchema>;

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { billingTypeSchema, weekdaySchema } from './enums.js';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js'; import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime(); const isoDateTimeSchema = z.string().datetime();
@@ -13,6 +14,13 @@ export const GroupDtoSchema = z.object({
createdById: z.string(), createdById: z.string(),
createdAt: isoDateTimeSchema, createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema, updatedAt: isoDateTimeSchema,
// Horario y cobro: quedan vacíos si el grupo aún no tiene configurado el plan.
days: z.array(weekdaySchema).nullable(),
time: z.string().nullable(),
capacity: z.number().int().min(1).nullable(),
price: z.number().min(0).nullable(),
billingType: billingTypeSchema.nullable(),
dueDay: z.number().int().min(1).max(28).nullable(),
}); });
export type GroupDto = z.output<typeof GroupDtoSchema>; export type GroupDto = z.output<typeof GroupDtoSchema>;

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import { billingTypeSchema, paymentProviderSchema, weekdaySchema } from './enums.js';
import { GroupDtoSchema } from './groups.js';
export type { BillingType, PaymentProvider, WeekDay } from './enums.js';
export { billingTypeSchema, paymentProviderSchema, weekdaySchema } from './enums.js';
// Paso 2: vincula la cuenta de cobro del profesor.
export const ConnectPaymentSchema = z.strictObject({
provider: paymentProviderSchema,
// Para el MVP se conecta en modo simulación / sandbox.
sandbox: z.boolean().default(true),
accessToken: z.string().optional(),
});
export type ConnectPayment = z.output<typeof ConnectPaymentSchema>;
export const ConnectPaymentResultSchema = z.object({
provider: paymentProviderSchema,
sandbox: z.boolean(),
connected: z.boolean(),
});
export type ConnectPaymentResult = z.output<typeof ConnectPaymentResultSchema>;
// Paso 3: crea el primer grupo del profesor.
export const CreateFirstGroupSchema = z.strictObject({
name: z
.string()
.trim()
.min(3, 'El nombre debe tener al menos 3 caracteres')
.max(60, 'El nombre es demasiado largo (máximo 60 caracteres)'),
days: z.array(weekdaySchema).min(1, 'Elegí al menos un día de clase'),
time: z
.string()
.regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Elegí un horario válido (ej: 09:00)'),
capacity: z
.number({ invalid_type_error: 'Ingresá un cupo válido' })
.int({ message: 'El cupo debe ser un número entero' })
.min(1, 'El cupo debe ser al menos 1'),
price: z
.number({ invalid_type_error: 'Ingresá un precio válido' })
.min(0, 'El precio no puede ser negativo'),
billingType: billingTypeSchema,
dueDay: z
.number({ invalid_type_error: 'Ingresá un día válido' })
.int({ message: 'El día debe ser un número entero' })
.min(1, 'El día de vencimiento debe estar entre 1 y 28')
.max(28, 'El día de vencimiento debe estar entre 1 y 28'),
});
export type CreateFirstGroup = z.output<typeof CreateFirstGroupSchema>;
export const OnboardingGroupDtoSchema = GroupDtoSchema.extend({
days: z.array(weekdaySchema).nullable(),
time: z.string().nullable(),
capacity: z.number().int().min(1).nullable(),
price: z.number().min(0).nullable(),
billingType: billingTypeSchema.nullable(),
dueDay: z.number().int().min(1).max(28).nullable(),
});
export type OnboardingGroupDto = z.output<typeof OnboardingGroupDtoSchema>;
export const CreateFirstGroupResultSchema = z.object({
group: OnboardingGroupDtoSchema,
onboardingCompleted: z.boolean(),
});
export type CreateFirstGroupResult = z.output<typeof CreateFirstGroupResultSchema>;
// Estado del onboarding para retomar el flujo si se interrumpió.
export const onboardingStepSchema = z.enum(['NOT_STARTED', 'PAYMENT_CONNECTED', 'COMPLETED']);
export type OnboardingStep = z.output<typeof onboardingStepSchema>;
export const OnboardingStatusDtoSchema = z.object({
step: onboardingStepSchema,
paymentConnected: z.boolean(),
completed: z.boolean(),
});
export type OnboardingStatusDto = z.output<typeof OnboardingStatusDtoSchema>;

View File

@@ -11,7 +11,7 @@ export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCEL
export const PaymentDtoSchema = z.object({ export const PaymentDtoSchema = z.object({
id: z.string(), id: z.string(),
groupId: z.string(), groupId: z.string(),
studentId: z.string(), attendeeId: z.string(),
amount: z.coerce.number().positive(), amount: z.coerce.number().positive(),
currency: z.string(), currency: z.string(),
status: paymentStatusSchema, status: paymentStatusSchema,

View File

@@ -41,7 +41,7 @@ gruperly/
│ │ ├── src/ │ │ ├── src/
│ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...) │ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...)
│ │ │ ├── lib/ # Prisma (cliente + UnitOfWork), pagination, email, helpers │ │ │ ├── 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>/ │ │ │ │ └── <módulo>/
│ │ │ │ ├── routes.ts │ │ │ │ ├── routes.ts
│ │ │ │ └── features/<accion>/{route,use-case}.ts │ │ │ │ └── features/<accion>/{route,use-case}.ts
@@ -73,7 +73,7 @@ gruperly/
│ │ ├── src/ │ │ ├── src/
│ │ │ ├── schemas/ # Validaciones Zod compartidas │ │ │ ├── schemas/ # Validaciones Zod compartidas
│ │ │ │ ├── group.schema.ts │ │ │ │ ├── group.schema.ts
│ │ │ │ ├── student.schema.ts │ │ │ │ ├── attendee.schema.ts
│ │ │ │ └── payment.schema.ts │ │ │ │ └── payment.schema.ts
│ │ │ └── index.ts │ │ │ └── index.ts
│ │ ├── tsconfig.json │ │ ├── tsconfig.json