# RegiTicket — Estado de desarrollo (actualizado 2026-05-06)

## Checkout público P3 (parcial · nuevo)

Implementación inicial del flujo de compra pública en `/eventos/{slug}` — replica la UX/estructura del plugin Tribe (selector de tickets + footer fijo + modal de asistentes), backend listo para Stripe / PayPal en próxima fase.

### Servicio `App\Services\FeeCalculator`
Centraliza la regla de comisión (regla de negocio crítica `.cursorrules § Modelo de comisión`):
```
platform_fee  = $2.99 + (subtotal × 0.05)
net_organizer = subtotal − platform_fee
```
- `calculate(float $subtotal): array{subtotal, platform_fee, net_organizer}`
- Subtotales negativos se normalizan a 0 (defensivo).
- Cubierto por `Tests\Unit\FeeCalculatorTest` (5 tests · casos $25, $100, $0, negativo, redondeo).

### Vista pública `landing.event-show`
- Header del evento: featured image, título, fecha, venue.
- Bloque `Entradas` con cada `TicketType` activo:
  - Título + descripción + tipo de ticket badge.
  - Precio con `sale_price` mostrando tachado el regular.
  - Disponibilidad calculada con `effectiveCapacity()` y `quantity_sold`.
  - Controles `−` / `input` / `+` (`min:0`, `max:100` o disponibles) en componente Alpine `rtCheckout`.
- Footer **sticky** (`position: sticky; bottom: 0`) con cantidad y total en vivo.
- Botón "Obtener Entradas" deshabilitado mientras `totalQty === 0`.

### Modal de asistentes (Alpine.js, sin Vue/React)
- Al pulsar "Obtener Entradas" se abre un modal a pantalla completa con un bloque por asistente.
- Para cada asistente:
  - **Nombre** y **Correo** (obligatorios cuando `iac !== none`).
  - Render dinámico de los `CustomField` del ticket: `text`, `email`, `select`, `checkbox`, `textarea`, `date`.
  - Pre-llena el primer asistente con datos del usuario logueado si existe.
- Resumen del pedido a la derecha (subtotal, platform fee, total final).
- Submit AJAX (`fetch` con CSRF) + estado loading + manejo de errores 422.

### Backend
- **`App\Http\Requests\StoreCheckoutOrderRequest`**
  - Forma: `attendees[ticket_type_id][idx] = { holder_name, holder_email, meta }`.
  - `attendeesPayload()` sanea (trim, casts) y devuelve estructura limpia.
- **`App\Http\Controllers\CheckoutController`**
  - `store(StoreCheckoutOrderRequest, Event)`:
    1. Verifica `EventStatus::Published`.
    2. Carga `TicketType[]` con `customFields`, valida que todos pertenezcan al evento y estén activos.
    3. Valida disponibilidad (`effectiveCapacity − quantity_sold ≥ qty`).
    4. Para cada asistente: nombre/email obligatorios si `iac !== none`, custom fields requeridos por `slug`.
    5. `FeeCalculator::calculate()` para fee/neto.
    6. `DB::transaction`: crea `Order(status=pending)` + `Attendee[]` (cada uno con `security_code = Str::uuid()`).
    7. Responde JSON `{redirect}` para AJAX o `redirect()->to(...)` clásico.
  - `confirmation(Event, Order)`: muestra resumen — sólo accesible al comprador (sesión flash) o al usuario autenticado dueño.
- **Rutas** (`routes/web.php`)
  - `POST /eventos/{slug}/checkout` (`landing.event.checkout`, `throttle:10,1`).
  - `GET /eventos/{slug}/checkout/{order}/confirmacion` (`landing.event.checkout.confirmation`).

### Vista `landing.checkout-confirmation`
- Muestra subtotal / platform fee / total final.
- Lista de asistentes con `holder_name`, `holder_email`, `ticket_type` y `security_code` (servirá como respaldo del QR cuando se integre el pago).

### CSS (`public/css/landing.css`)
Nuevos prefijos para evitar colisión con Bootstrap:
- `.rt-tickets-*` (selector + footer sticky)
- `.rt-checkout-modal-*` (overlay, dialog, form fields)
- `.rt-checkout-summary-*` (resumen lateral)
- `.rt-checkout-ticket-block` (bloque por asistente dentro del modal)
- Bumpado `?v=9` en cache busting.

### Tests `Tests\Feature\PublicCheckoutTest` (5 tests)
1. Página pública renderiza estructura del selector.
2. POST checkout crea Order pending con attendees + fee correcto (regla $25 × 2 → fee $5.49 / neto $44.51).
3. Falla cuando falta un custom field requerido (no se persiste Order).
4. Rechaza cantidad mayor a disponibilidad.
5. Evento no publicado responde 404.

### Pendiente para cerrar P3
- Integración Stripe Elements + PayPal JS SDK en el modal (paso 4 del wizard).
- Generación de QR + PDF de la entrada por `Attendee::security_code`.
- Email de confirmación al `holder_email` y al comprador con detalle de la orden.
- `OrderPaid` listener que decrementa `TicketType::quantity_sold` y dispara payout.

---

## Configuración editable de BIB (nuevo)

El formulario de evento ahora permite al organizador / admin / super_admin definir el rango de BIB y reservar números ya entregados presencialmente. Persistido en `events.metadata.sport.bib`.

### Campos nuevos en el formulario (Paso 1 → Configuración de carrera → BIB)
- **Modo de asignación**: `later` / `manual` / `auto_sequential` (cuando `!== 'later'` se despliega el resto del bloque).
- **Número de inicio** `bib_start_number` (entero, default 1). Permite arrancar desde 100, 500, 1000, etc.
- **Número final** `bib_end_number` (entero, opcional). Vacío = sin tope.
- **Padding** `bib_padding` (0 / 3 / 4 / 5 dígitos). El render aplicará ceros a la izquierda en listados, dorsales y QR.
- **BIBs reservados** `bib_reserved_ranges` (textarea libre). Acepta combinaciones de números y rangos: `1-50, 75, 90-99`.

### UX en vivo
- Componente Alpine `rtBibConfig` en `public/js/dashboard-event-form-widgets.js`:
  - Calcula `nextAvailable` (primer BIB libre del rango) en tiempo real con padding aplicado.
  - Muestra contador "61 reservados" y botón **Normalizar** que colapsa la entrada del usuario (ej. `5, 1, 2, 3, 4, 8` → `1-5, 8`).
- El parser PHP `App\Support\BibRangeParser` espeja la lógica del cliente y es la fuente de verdad para persistencia.

### Persistencia
```json
"sport": {
  "bib_assignment": "auto_sequential",
  "bib": {
    "start_number": 100,
    "end_number": 1999,
    "padding": 4,
    "reserved": "1-50, 75, 90-99",
    "reserved_parsed": [1,2,...,50,75,90,91,...,99]
  }
}
```
`reserved_parsed` se calcula en el `EventController::mergedEventMetadata` con `BibRangeParser::parse()` y se canonicaliza el texto del usuario para guardarlo limpio.

### Validación (`SaveEventRequest`)
- `bib_start_number` integer 0..999999, `bib_end_number` integer 0..999999 con `gte:bib_start_number`.
- `bib_padding` integer 0..6.
- `bib_reserved_ranges` string, máx 2000 chars.
- `bib_end_number` se normaliza a `null` si llega vacío en `prepareForValidation`.

### Caso real cubierto
> Organizador vende presencialmente del 1 al 50 (entregó dorsales físicos), reserva el 75 para un patrocinador y del 90 al 99 para staff/voluntarios. El registro online debe arrancar en 100 con padding `0100`, `0101`...

El seeder `DemoRaceEventSeeder` materializa exactamente este caso.

### Bug colateral corregido
- `race_route_type`: validación esperaba `asfalto/pista/mixto/indoor` pero el `<select>` mostraba `road/track/mixed/tread` → ambos lados ahora alineados a los valores del select.
- `EventController::syncTicketTypes`: `currency` y `ticket_kind` ahora null-coalesce (`?? null`) antes de `?:` (previene `Undefined array key` en payloads mínimos).

---

## Evento de prueba de carrera (recién sembrado)

**Slug**: `coqui-run-2026-5k-10k-21k` · **HTTP 200** en `/eventos/coqui-run-2026-5k-10k-21k`

Sembrado vía nuevo seeder dedicado:
```bash
docker exec regiticket-laravel.test-1 php artisan db:seed --class=DemoRaceEventSeeder
```

Contenido generado:
- **Organizador**: Coquí Running Club (`organizador-carreras@regiticket.local`, password `password`), aprobado, primary_event_type = `athletics`.
- **Venue**: Parque Luis Muñoz Rivera (San Juan, PR) con coordenadas reales.
- **Categoría**: Carrera.
- **Evento**: "Coquí Run 2026 — 5K, 10K y 21K", publicado, sticky + featured, fecha 2026-09-12 06:00 PR.
- **Metadata `sport` completo**:
  - distances: 5K / 10K / 21K
  - chip_timing: disposable · route_type: asfalto · time_limit: 180 min
  - bib_assignment: auto_sequential
  - tallas: XS / S / M / L / XL / 2XL (cut: dama_caballero)
  - age_groups: Juvenil 15-19, Open 20-29, Master A 30-39, Master B 40-49, Master C 50-59, Veteranos 60+
- **9 ticket types** = 3 distancias × 3 tiers (Promo Lanzamiento → Pre-Venta → Regular) con `capacity_strategy = shared_pool` y SKUs únicos.
- **81 custom fields** (9 por ticket): fecha nacimiento, género, talla, nacionalidad, club, categoría competencia, mejor tiempo, contacto emergencia, reglamento.

| Distancia | Promo (sale) | Pre-Venta | Regular | Cupo |
|-----------|-------------:|----------:|--------:|-----:|
| 5K        | $25 (de $30) | $30       | $35     | 800  |
| 10K       | $30 (de $35) | $35       | $40     | 600  |
| 21K       | $45 (de $50) | $55       | $65     | 400  |

El seeder es **idempotente**: borra y recrea el evento, hace `updateOrCreate` del organizador y venue.

---

## Suite de tests
**40 tests / 111 assertions — TODO VERDE**

```
Unit/BibRangeParserTest   ✓ 10 tests
Unit/ExampleTest          ✓ 1 test
Unit/FeeCalculatorTest    ✓ 5 tests  ← regla $2.99 + 5%
EventModuleTest           ✓ 7 tests  ← incluye configuración de BIB con reservados
ExampleTest               ✓ 1 test
FullEventRegressionTest   ✓ 2 tests
LandingEventsTest         ✓ 4 tests
OrganizerOnboardingTest   ✓ 5 tests
PublicCheckoutTest        ✓ 5 tests  ← flujo de compra pública (selector + Order pending + Attendees)
```

---

## Trabajo completado en esta sesión (mayo 2026)

### Formulario de eventos (`dashboard/events`)

#### Corrección crítica Alpine.js
- `x-data` movido a `<div class="card-body">` para que todos los bloques `x-show` estén dentro del scope.
- Antes: bloques de tipo (music/festival/corporate/race) nunca se mostraban.

#### Nav de pasos compacto
- Nuevo componente `.rt-steps-bar` / `.rt-step-pill` en CSS y Blade.
- CSS agregado a `dashboard-sb-admin-overrides.css`.

#### Campos de carrera (Paso 1 — Bloque sport)
- Distancias con quick-tags (3K–42K)
- Tipo de ruta (asfalto, trail, pista, mixto, indoor)
- Tiempo límite del curso
- Chip/cronometraje (sin chip, desechable, reutilizable, bib_chip)
- Modo BIB
- **Tallas** (XS→3XL) con corte (Unisex/Dama-Caballero/Ambos)
- Categorías de edad con quick-tags (Juvenil, Open, Master…)

#### Mapa de venue (Paso 1 — Música/Festival/Corporativo)
- `public/js/dashboard-venue-map.js` — Alpine component `rtVenueMapBuilder`
  - Drag & drop de zonas y marcadores
  - Resize desde esquina inferior derecha
  - Plantillas: Floor/GA, VIP, Platino, Balcón, Palcos, General, Camerinos
  - Marcadores: Escenario, Entrada, Salida, Bar, Comida, Baños
  - Color picker por zona, edición de capacidad y nombre
  - Serialización a JSON en hidden input `venue_map_json`
- `resources/views/dashboard/events/partials/venue-map-builder.blade.php`
  - Toolbox izquierdo + canvas derecho
  - Panel de propiedades de zona/marcador seleccionado
  - Instrucciones de uso inline
- CSS completo en `dashboard-sb-admin-overrides.css`:
  - `.rt-map-canvas-wrap` (ratio 900:550 con padding-top)
  - `.rt-map-zone`, `.rt-map-zone__label/.cap`
  - `.rt-map-resize-handle` con pseudo-element de flecha
  - `.rt-map-marker`, `.rt-map-color-swatch`
  - Responsive mobile

#### Persistencia completa (`EventController::mergedEventMetadata`)
Ahora guarda en `events.metadata`:
- `sport.chip_timing` ← `chip_timing_mode`
- `sport.route_type` ← `race_route_type`
- `sport.time_limit` ← `race_time_limit`
- `sport.age_groups` ← `age_groups`
- `sport.tallas` (enabled, sizes[], cut)
- `venue_map` ← `venue_map_json` (decodificado)

#### Validación (`SaveEventRequest`)
Nuevos campos con reglas:
- `event_primary_type` — default `'other'` en `prepareForValidation`
- `chip_timing_mode`, `race_route_type`, `race_time_limit`
- `tallas_enabled` (boolean normalizado), `tallas[]`, `tallas_cut`
- `age_groups`, `venue_map_json`
- `music_*`, `festival_*`, `corporate_*` (todos los campos de tipo)
- `custom_fields[].required` normalizado con `FILTER_VALIDATE_BOOLEAN`

#### Bugs corregidos
- `custom_fields[].required = ''` causaba ValidationException (fallo silencioso)
- `uniqid('t', true)` generaba slugs con `.` que `Str::slug()` eliminaba → mismatch en test
- `event_primary_type` requerido sin default → fallo de validación para requests sin ese campo

---

## Índice de eventos WordPress analizado

El editor de entradas WordPress (The Events Calendar + WooCommerce Tickets) del evento CSILO 5K/21K muestra:

### Estructura de entradas que debemos igualar
- **Múltiples niveles de precio por distancia** (Promo Lanzamiento, Pre Venta I, Pre Venta II, Regular)
- **IAC** (Individual Attendee Collection) — datos por corredor:
  - Apellidos, Fecha de nacimiento, Género
  - Nacionalidad, Dirección (línea 1, ciudad, estado, ZIP, país)
  - Mejor tiempo 21K, Club/Team
  - Competencia (Élite / Age Group)
  - Shirt size (dropdown de tallas)
  - PR Half Marathons Award (dropdown de logros)
  - Certifico reglas (checkbox requerido)
- **Capacidad** por tipo de entrada con contador de vendidos
- **SKU** por variante de precio

### Estado actual en RegiTicket
El Paso 2 Entradas ya soporta: nombre, descripción, precio, precio oferta, SKU, cupo (per_ticket / shared_pool / unlimited), IAC (none/optional/required), custom fields (text, textarea, select, checkbox, date), fechas de venta.

**Pendiente**: poder clonar un tipo de entrada con diferente precio (tiers) dentro del mismo "grupo de distancia".

---

## Pendiente inmediato (P2)

### Alta prioridad
1. **BIB Module — Fase 2**: tabla `attendee_bib_assignments` con unicidad por `(event_id, bib_number)`, asignación masiva desde panel, reasignación, liberar BIBs cuando se cancela una orden, override por distancia (5K usa 100-999, 10K usa 1000-1999, 21K usa 2000-2999). Hoy tenemos Fase 1 funcional: el evento ya conoce su rango y reservados.
2. **Carga manual de asistentes**: CSV upload + form manual (diferenciador clave vs competencia). Cuando se cargue un asistente, asignarle el primer BIB libre vía `BibRangeParser::firstAvailable()`.
3. **Tiers de precio por entrada**: permitir múltiples precios/fechas dentro del mismo tipo (Promo → Pre venta → Regular). El seeder ya muestra el patrón con tres ticket_types por distancia, falta UX en el form para clonar.

### Media prioridad
4. **Vista pública** `landing/event-show.blade.php` — ya creada, revisar rendering del mapa de zonas.
5. **Resultados/leaderboards** — modelo para tiempos + divisiones + publicación pública.
6. **Checkout público** con Stripe/PayPal (P3).

---

## Comandos útiles

```bash
# Dentro del contenedor Sail:
docker exec regiticket-laravel.test-1 php artisan test
docker exec regiticket-laravel.test-1 php artisan migrate:fresh --seed
docker exec regiticket-laravel.test-1 php artisan db:seed --class=DemoRaceEventSeeder

# Desde el host (Sail alias):
./vendor/bin/sail test
./vendor/bin/sail artisan migrate:fresh --seed
./vendor/bin/sail artisan db:seed --class=DemoRaceEventSeeder
```

## URLs de desarrollo
- Dashboard: http://localhost/dashboard/events
- Crear evento: http://localhost/dashboard/events/create
- Landing: http://localhost/eventos
- **Evento prueba (público)**: http://localhost/eventos/coqui-run-2026-5k-10k-21k
- **Login organizador prueba**: `organizador-carreras@regiticket.local` / `password`
