[telegram] antes en el backlog encaramos, tareas para accesibilidad en ballbox. siguiendo la filosofía html-first de martín alderson, e se puede hacer algo así en ballbox o siendo next se complica?


**Considering the answer approach**

I think I need a conceptual answer here. Should I mention "know ballbox"? It might be worth checking my memory for that reference. But I believe I can provide a direct answer without needing any tools. Keeping it simple and straightforward seems like the best way forward! Let's think about how to articulate that clearly for the user.

Sí. Se puede.

Next no complica por sí mismo. El problema no es “usar Next”. El problema es hacer UI muy JS-heavy, div soup, y componentes que rompen semántica.

Si quieren línea html-first en Ballbox, sirve así:

- primero HTML semántico real
- forms nativos
- links reales
- buttons reales
- headings ordenados
- labels, fieldsets, tables, lists
- estados y navegación que funcionen sin magia

Next deja hacer eso perfecto. Incluso ayuda en varias cosas:
- server rendering
- forms server-side
- rutas claras
- buen HTML inicial
- menos dependencia de hidratar todo

Donde se complica:
- componentes custom tipo “button que en realidad es div”
- modals, comboboxes, tabs, dropdowns mal armados
- client components por todos lados
- SPA thinking antes que documento/formulario

Filosofía práctica para Ballbox:
1. cada pantalla arranca como documento HTML válido
2. cada acción posible con form/link nativo, mejor
3. JS solo mejora, no reemplaza base
4. ARIA solo cuando semántica nativa no alcanza
5. design system con primitives accesibles de base
6. testear teclado + screen reader en flows críticos

Regla simple:
- si una feature puede ser `a`, `button`, `form`, `input`, `dialog`, usen eso primero
- si necesitan inventar widget, ahí sí sube costo

O sea: en Ballbox se puede muy bien.
Next no es el obstáculo. La arquitectura de componentes sí.

Si querés, te bajo esto a:
- checklist concreta para backlog de accesibilidad en Ballbox
- o lineamientos técnicos para equipo Next/React.


[telegram] hace un plan de implementacion y luego ejecuta a completitud


**Exploring repository needs**

I’m thinking about whether I need to inspect a repo called Ballbox. I should check if it can be executed on the current project, but first, I need to know its location. My plan is to create a to-do item based on follow-up instructions inside the active task. I might consider searching my memory, though it doesn't seem absolutely necessary. I also want to look at the files under /home/sebas to see the Ballbox repos. Let's list the directories there!

todo

Plan and implement html-first accessibility work for Ballbox

Find Ballbox repo/context, define implementation plan for html-first accessibility in Next, then execute changes to completion with validation.

planning and implementing Ballbox accessibility work


Created #1: Plan and implement html-first accessibility work for Ballbox (pending)


Updated #1 (pending → in_progress)


bash

pwd && ls -1 /home/sebas && find /home/sebas -maxdepth 2 -type d \( -iname '*ballbox*' -o -name '.git' \) | sed 's#/\.git##' | sort -u | head -200


/home/sebas
agents-database
AGENTS.md
archive
client_secret_1019422495774-0cvo8lefqh667vgmekec8ruqdnrior71.apps.googleusercontent.com.json
Desktop
Documents
downloads
Downloads
go
media
Music
notes
opencode-web.log
outputs
pi-config
Pictures
playwright-browsers
Public
runtime
Templates
Videos
work
/home/sebas/agents-database
/home/sebas/pi-config


find /home/sebas/work -maxdepth 3 -type d \( -iname '*ballbox*' -o -name '.git' \) | sed 's#/\.git##' | sort -u | head -200


/home/sebas/work/projects/ballbox
/home/sebas/work/projects/bug-bounty
/home/sebas/work/projects/eiffel-monitor
/home/sebas/work/projects/health-system


**Inspecting repository**

I need to inspect the repository and maybe search my memory for helpful information. It might be useful for context. I could read through the AGENTS if necessary, but for now, I think I can just focus on inspecting the repository. There’s a good chance that this will provide the necessary insights I need. Let's dive in and see what I can uncover!

cd /home/sebas/work/projects/ballbox && git status --short && ls -1 && find . -maxdepth 2 \( -name package.json -o -name app -o -name pages -o -name src -o -name components \) | sort

cd /home/sebas/work/projects/ballbox && rg -n "accessib|aria-|role=|dialog|popover|combobox|Tabs|Modal|Dropdown|Select" . --glob '!node_modules' --glob '!**/.next/**' | head -200


AGENTS.md
app
CLAUDE.md
components
components.json
docker-compose.yml
docs
eslint.config.mjs
lib
next.config.mjs
next-env.d.ts
node_modules
package.json
PLAN.md
playwright.config.ts
pnpm-lock.yaml
postcss.config.mjs
prisma
proxy.ts
public
README.md
scripts
test-results
tests
tsconfig.json
tsconfig.tsbuildinfo
vitest.config.ts
./app
./components
./.next/package.json
./package.json


./package.json:42:    "@radix-ui/react-alert-dialog": "1.1.4",
./package.json:48:    "@radix-ui/react-dialog": "1.1.4",
./package.json:54:    "@radix-ui/react-popover": "1.1.4",
./PLAN.md:210:- Selection rule for the next agent: pick the highest-priority task marked `ready` in that doc.
./components/weekly-availability-grid.tsx:155:                const isSelected = selectedCells.has(key);
./components/weekly-availability-grid.tsx:161:                    aria-pressed={isSelected}
./components/weekly-availability-grid.tsx:162:                    title={isBlocked ? blockedLabel : isSelected ? selectedLabel : openLabel}
./components/weekly-availability-grid.tsx:166:                        : isSelected
./components/weekly-availability-grid.tsx:172:                      const nextValue = !isSelected;
./components/weekly-availability-grid.tsx:178:                      if (dragMode !== isSelected) {
./components/weekly-availability-grid.tsx:183:                    {isBlocked ? blockedLabel : isSelected ? selectedLabel : openLabel}
./components/class-offer-detail-client.tsx:12:  initialSelectedSlot?: {
./components/class-offer-detail-client.tsx:33:export function ClassOfferDetailClient({ initialOffer, initialSportclubId = null, initialSelectedSlot = null }: ClassOfferDetailClientProps) {
./components/class-offer-detail-client.tsx:37:  const initialSelectedSlotStart = initialSelectedSlot?.start ?? null;
./components/class-offer-detail-client.tsx:38:  const initialSelectedSlotDurationMinutes = initialSelectedSlot?.durationMinutes ?? null;
./components/class-offer-detail-client.tsx:39:  const initialSelectedSlotCourtId = initialSelectedSlot?.courtId ?? null;
./components/class-offer-detail-client.tsx:54:        if (initialSelectedSlotStart) {
./components/class-offer-detail-client.tsx:55:          url.searchParams.set("slotStart", initialSelectedSlotStart);
./components/class-offer-detail-client.tsx:57:        if (initialSelectedSlotDurationMinutes) {
./components/class-offer-detail-client.tsx:58:          url.searchParams.set("slotDurationMinutes", String(initialSelectedSlotDurationMinutes));
./components/class-offer-detail-client.tsx:60:        if (initialSelectedSlotCourtId) {
./components/class-offer-detail-client.tsx:61:          url.searchParams.set("slotCourtId", initialSelectedSlotCourtId);
./components/class-offer-detail-client.tsx:80:    initialSelectedSlotCourtId,
./components/class-offer-detail-client.tsx:81:    initialSelectedSlotDurationMinutes,
./components/class-offer-detail-client.tsx:82:    initialSelectedSlotStart,
./components/class-offer-detail-client.tsx:90:        key={`${offer.id}:${offer.atcContext.slots.length}:${initialSelectedSlot?.start ?? "no-slot"}`}
./components/class-offer-detail-client.tsx:94:        initialSelectedSlot={initialSelectedSlot}
./components/route-feedback.tsx:48:  const prevAriaDisabled = element.getAttribute("aria-disabled");
./components/route-feedback.tsx:51:  element.setAttribute("aria-disabled", "true");
./components/route-feedback.tsx:59:        element.removeAttribute("aria-disabled");
./components/route-feedback.tsx:61:        element.setAttribute("aria-disabled", prevAriaDisabled);
./components/route-feedback.tsx:71:  const prevAriaDisabled = element.getAttribute("aria-disabled");
./components/route-feedback.tsx:74:  element.setAttribute("aria-disabled", "true");
./components/route-feedback.tsx:82:        element.removeAttribute("aria-disabled");
./components/route-feedback.tsx:84:        element.setAttribute("aria-disabled", prevAriaDisabled);
./components/route-feedback.tsx:230:      aria-hidden="true"
./components/payments-admin-client.tsx:9:  Select,
./components/payments-admin-client.tsx:10:  SelectContent,
./components/payments-admin-client.tsx:11:  SelectItem,
./components/payments-admin-client.tsx:12:  SelectTrigger,
./components/payments-admin-client.tsx:13:  SelectValue,
./components/payments-admin-client.tsx:162:    if (!anyAmountForm.vendingMachineId) return "Select a vending machine.";
./components/payments-admin-client.tsx:163:    if (!anyAmountForm.productId) return "Select a product.";
./components/payments-admin-client.tsx:175:    if (!productAmountForm.vendingMachineId) return "Select a vending machine.";
./components/payments-admin-client.tsx:176:    if (!productAmountForm.productId) return "Select a product.";
./components/payments-admin-client.tsx:301:              <Select
./components/payments-admin-client.tsx:305:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:306:                  <SelectValue placeholder="Vending machine" />
./components/payments-admin-client.tsx:307:                </SelectTrigger>
./components/payments-admin-client.tsx:308:                <SelectContent>
./components/payments-admin-client.tsx:310:                    <SelectItem key={machine.id} value={machine.id}>
./components/payments-admin-client.tsx:312:                    </SelectItem>
./components/payments-admin-client.tsx:314:                </SelectContent>
./components/payments-admin-client.tsx:315:              </Select>
./components/payments-admin-client.tsx:317:              <Select
./components/payments-admin-client.tsx:321:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:322:                  <SelectValue placeholder="Product" />
./components/payments-admin-client.tsx:323:                </SelectTrigger>
./components/payments-admin-client.tsx:324:                <SelectContent>
./components/payments-admin-client.tsx:326:                    <SelectItem key={product.id} value={product.id}>
./components/payments-admin-client.tsx:328:                    </SelectItem>
./components/payments-admin-client.tsx:330:                </SelectContent>
./components/payments-admin-client.tsx:331:              </Select>
./components/payments-admin-client.tsx:339:                aria-invalid={anyAmountValidationError !== null}
./components/payments-admin-client.tsx:342:              <Select
./components/payments-admin-client.tsx:346:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:347:                  <SelectValue placeholder="Currency" />
./components/payments-admin-client.tsx:348:                </SelectTrigger>
./components/payments-admin-client.tsx:349:                <SelectContent>
./components/payments-admin-client.tsx:350:                  <SelectItem value="ARS">ARS</SelectItem>
./components/payments-admin-client.tsx:351:                </SelectContent>
./components/payments-admin-client.tsx:352:              </Select>
./components/payments-admin-client.tsx:407:              <Select
./components/payments-admin-client.tsx:411:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:412:                  <SelectValue placeholder="Vending machine" />
./components/payments-admin-client.tsx:413:                </SelectTrigger>
./components/payments-admin-client.tsx:414:                <SelectContent>
./components/payments-admin-client.tsx:416:                    <SelectItem key={machine.id} value={machine.id}>
./components/payments-admin-client.tsx:418:                    </SelectItem>
./components/payments-admin-client.tsx:420:                </SelectContent>
./components/payments-admin-client.tsx:421:              </Select>
./components/payments-admin-client.tsx:423:              <Select
./components/payments-admin-client.tsx:432:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:433:                  <SelectValue placeholder="Product" />
./components/payments-admin-client.tsx:434:                </SelectTrigger>
./components/payments-admin-client.tsx:435:                <SelectContent>
./components/payments-admin-client.tsx:437:                    <SelectItem key={product.id} value={product.id}>
./components/payments-admin-client.tsx:439:                    </SelectItem>
./components/payments-admin-client.tsx:441:                </SelectContent>
./components/payments-admin-client.tsx:442:              </Select>
./components/payments-admin-client.tsx:450:                aria-invalid={productAmountValidationError !== null}
./components/payments-admin-client.tsx:453:              <Select
./components/payments-admin-client.tsx:457:                <SelectTrigger className="w-full">
./components/payments-admin-client.tsx:458:                  <SelectValue placeholder="Currency" />
./components/payments-admin-client.tsx:459:                </SelectTrigger>
./components/payments-admin-client.tsx:460:                <SelectContent>
./components/payments-admin-client.tsx:461:                  <SelectItem value="ARS">ARS</SelectItem>
./components/payments-admin-client.tsx:462:                </SelectContent>
./components/payments-admin-client.tsx:463:              </Select>
./components/classes-by-date-page-client.tsx:14:type SlotSelection = {
./components/classes-by-date-page-client.tsx:52:  const [selectedZone, setSelectedZone] = useState<string>("");
./components/classes-by-date-page-client.tsx:53:  const [selectedCoachId, setSelectedCoachId] = useState<string>("all");
./components/classes-by-date-page-client.tsx:54:  const [selectedClubId, setSelectedClubId] = useState<string>("all");
./components/classes-by-date-page-client.tsx:55:  const [selection, setSelection] = useState<SlotSelection | null>(null);
./components/classes-by-date-page-client.tsx:62:  const clubsInSelectedZone = useMemo(
./components/classes-by-date-page-client.tsx:119:  const visibleSelectionCoaches = useMemo(() => {
./components/classes-by-date-page-client.tsx:130:  const activeSelectionCoach =
./components/classes-by-date-page-client.tsx:131:    (selectedSlotCoach ? visibleSelectionCoaches.find((coach) => coach.id === selectedSlotCoach.id) : null) ?? null;
./components/classes-by-date-page-client.tsx:148:                setSelectedZone(event.target.value);
./components/classes-by-date-page-client.tsx:149:                setSelectedClubId("all");
./components/classes-by-date-page-client.tsx:150:                setSelection(null);
./components/classes-by-date-page-client.tsx:166:              onChange={(event) => setSelectedCoachId(event.target.value)}
./components/classes-by-date-page-client.tsx:181:              onChange={(event) => setSelectedClubId(event.target.value)}
./components/classes-by-date-page-client.tsx:186:              {clubsInSelectedZone.map((club) => (
./components/classes-by-date-page-client.tsx:243:                          setSelection({ slot, coach: defaultCoach });
./components/classes-by-date-page-client.tsx:279:                onClick={() => setSelection(null)}
./components/classes-by-date-page-client.tsx:289:                {visibleSelectionCoaches.length === 0 ? (
./components/classes-by-date-page-client.tsx:293:                    {visibleSelectionCoaches.map((coach) => {
./components/classes-by-date-page-client.tsx:295:                      const active = activeSelectionCoach?.id === coach.id;
./components/classes-by-date-page-client.tsx:301:                          onClick={() => setSelection({ slot: selectedSlot, coach })}
./components/classes-by-date-page-client.tsx:321:                {activeSelectionCoach ? (
./components/classes-by-date-page-client.tsx:323:                    {activeSelectionCoach.clubs.length === 0 ? (
./components/classes-by-date-page-client.tsx:325:                    ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (
./components/classes-by-date-page-client.tsx:328:                      activeSelectionCoach.clubs
./components/classes-by-date-page-client.tsx:331:                          const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);
./components/authenticated-workspace-navbar.tsx:85:            aria-label="Abrir menu de perfil"
./components/ui/textarea.tsx:10:        'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
./components/ui/input.tsx:13:        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
./components/ui/button.tsx:8:  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
./lib/coach-availability-matching.ts:110:      clubSelectionMode: true,
./lib/coach-availability-matching.ts:114:      clubSelections: {
./lib/coach-availability-matching.ts:256:        coachRecord.clubSelectionMode === "CUSTOM"
./lib/coach-availability-matching.ts:257:          ? coachRecord.clubSelections.map((selection) => selection.clubId)
./components/ui/select.tsx:4:import * as SelectPrimitive from '@radix-ui/react-select'
./components/ui/select.tsx:9:function Select({
./components/ui/select.tsx:11:}: React.ComponentProps<typeof SelectPrimitive.Root>) {
./components/ui/select.tsx:12:  return <SelectPrimitive.Root data-slot="select" {...props} />
./components/ui/select.tsx:15:function SelectGroup({
./components/ui/select.tsx:17:}: React.ComponentProps<typeof SelectPrimitive.Group>) {
./components/ui/select.tsx:18:  return <SelectPrimitive.Group data-slot="select-group" {...props} />
./components/ui/select.tsx:21:function SelectValue({
./components/ui/select.tsx:23:}: React.ComponentProps<typeof SelectPrimitive.Value>) {
./components/ui/select.tsx:24:  return <SelectPrimitive.Value data-slot="select-value" {...props} />
./components/ui/select.tsx:27:function SelectTrigger({
./components/ui/select.tsx:32:}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
./components/ui/select.tsx:36:    <SelectPrimitive.Trigger
./components/ui/select.tsx:40:        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
./components/ui/select.tsx:46:      <SelectPrimitive.Icon asChild>
./components/ui/select.tsx:48:      </SelectPrimitive.Icon>
./components/ui/select.tsx:49:    </SelectPrimitive.Trigger>
./components/ui/select.tsx:53:function SelectContent({
./components/ui/select.tsx:58:}: React.ComponentProps<typeof SelectPrimitive.Content>) {
./components/ui/select.tsx:60:    <SelectPrimitive.Portal>
./components/ui/select.tsx:61:      <SelectPrimitive.Content
./components/ui/select.tsx:64:          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
./components/ui/select.tsx:72:        <SelectScrollUpButton />
./components/ui/select.tsx:73:        <SelectPrimitive.Viewport
./components/ui/select.tsx:81:        </SelectPrimitive.Viewport>
./components/ui/select.tsx:82:        <SelectScrollDownButton />
./components/ui/select.tsx:83:      </SelectPrimitive.Content>
./components/ui/select.tsx:84:    </SelectPrimitive.Portal>
./components/ui/select.tsx:88:function SelectLabel({
./components/ui/select.tsx:91:}: React.ComponentProps<typeof SelectPrimitive.Label>) {
./components/ui/select.tsx:93:    <SelectPrimitive.Label
./components/ui/select.tsx:101:function SelectItem({
./components/ui/select.tsx:105:}: React.ComponentProps<typeof SelectPrimitive.Item>) {
./components/ui/select.tsx:107:    <SelectPrimitive.Item
./components/ui/select.tsx:116:        <SelectPrimitive.ItemIndicator>
./components/ui/select.tsx:118:        </SelectPrimitive.ItemIndicator>
./components/ui/select.tsx:120:      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
./components/ui/select.tsx:121:    </SelectPrimitive.Item>
./components/ui/select.tsx:125:function SelectSeparator({
./components/ui/select.tsx:128:}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
./components/ui/select.tsx:130:    <SelectPrimitive.Separator
./components/ui/select.tsx:138:function SelectScrollUpButton({
./components/ui/select.tsx:141:}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
./components/ui/select.tsx:143:    <SelectPrimitive.ScrollUpButton
./components/ui/select.tsx:152:    </SelectPrimitive.ScrollUpButton>
./components/ui/select.tsx:156:function SelectScrollDownButton({
./components/ui/select.tsx:159:}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
./components/ui/select.tsx:161:    <SelectPrimitive.ScrollDownButton
./components/ui/select.tsx:170:    </SelectPrimitive.ScrollDownButton>
./components/ui/select.tsx:175:  Select,
./components/ui/select.tsx:176:  SelectContent,
./components/ui/select.tsx:177:  SelectGroup,
./components/ui/select.tsx:178:  SelectItem,
./components/ui/select.tsx:179:  SelectLabel,
./components/ui/select.tsx:180:  SelectScrollDownButton,
./components/ui/select.tsx:181:  SelectScrollUpButton,
./components/ui/select.tsx:182:  SelectSeparator,
./components/ui/select.tsx:183:  SelectTrigger,
./components/ui/select.tsx:184:  SelectValue,
./lib/coach-students-backend.ts:5:  getCoachClubSelectionFromDb,
./lib/coach-students-backend.ts:7:  type BallboxCoachClubSelection,
./lib/coach-students-backend.ts:121:function selectClubsForCoach(clubs: BallboxCoachClubOption[], selection: BallboxCoachClubSelection) {
./lib/coach-students-backend.ts:364:  const selection = await getCoachClubSelectionFromDb(
./components/admin-shell.tsx:62:                    aria-current={isActiveNavItem(item.href, currentPath) ? "page" : undefined}
./components/sports-admin-shell.tsx:22:const sportsAdminSelectClassName = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
./components/sports-admin-shell.tsx:96:        selectClassName={sportsAdminSelectClassName}
./components/sports-admin-shell.tsx:117:          selectClassName={sportsAdminSelectClassName}


**Implementing accessibility plan**

I need to put a plan into action based on the documentation and code. First, I'll inspect the tests, possibly checking the app itself. Identifying the highest-impact accessibility issues seems essential. I should search for TODO notes—maybe there are forms that use a radix select? Since using html-first likely means replacing custom selects with native ones, I’ll inspect the admin pages as well. Running axe could be useful, and I should also check if there are playwright tests in the project. Finally, examining package scripts might reveal more insights.


dotenv -e .env.local -- tsx scripts/backfill-atc-clubs.ts

dotenv -e .env.local -- tsx scripts/revalidate-atc-clubs.ts

dotenv -e .env.local -- tsx prisma/seed.ts

vitest run tests/integration --passWithNoTests


"use client";

import Image from "next/image";
import { startTransition, useMemo, useState } from "react";
import { parsePaymentExternalReference, type PaymentsAdminData, type SerializedPaymentSession } from "@/lib/payments";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

type PaymentsQrProductOption = {
  id: string;
  name: string;
  priceMinor: number | null;
};

type PaymentsQrVendingMachineOption = {
  id: string;
  label: string;
  venueId: string;
};

type PaymentsQrOptions = {
  products: PaymentsQrProductOption[];
  vendingMachines: PaymentsQrVendingMachineOption[];
};

type GeneratedQr = {
  orderId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  mode: string;
  description: string;
  externalPosId: string;
  vendingMachineId: string;
  productId: string;
  qrData: string | null;
};

type PaymentsAdminClientProps = {
  initialData: PaymentsAdminData;
  qrOptions: PaymentsQrOptions;
};

const emptyForm = {
  sessionId: "",
  vendingMachineId: "",
  productId: "",
  machineTxnId: "",
  externalReference: "",
  amountMinor: "0",
  currency: "ARS",
  approvedAt: "",
  lateApproval: false,
  providerPaymentId: "",
  source: "admin-manual",
};

const paymentsErrorMessages: Record<string, string> = {
  INVALID_PAYLOAD: "Hay datos inválidos en la sesión de pago.",
  PAYMENT_SESSION_NOT_FOUND: "La sesión de pago ya no existe.",
  PAYMENT_SESSION_CONFLICT: "La sesión de pago entra en conflicto con otra existente.",
  VENDING_MACHINE_NOT_FOUND: "La vending machine indicada no existe.",
  PRODUCT_NOT_FOUND: "El producto indicado no existe.",
};

function normalizeError(error: unknown) {
  if (error instanceof Error) return paymentsErrorMessages[error.message] ?? error.message;
  return "Request failed";
}

async function readError(response: Response) {
  const payload = await response.json().catch(() => null);
  return payload?.error ?? `HTTP_${response.status}`;
}

function toDateTimeLocalInput(value: string) {
  const date = new Date(value);
  const offset = date.getTimezoneOffset();
  const local = new Date(date.getTime() - offset * 60_000);
  return local.toISOString().slice(0, 16);
}

function toPayloadDate(value: string) {
  return new Date(value).toISOString();
}

const MIN_PAYMENT_AMOUNT_MINOR = 1500;

function formatMoney(amountMinor: number, currency: string) {
  return new Intl.NumberFormat("es-AR", {
    style: "currency",
    currency,
    maximumFractionDigits: 2,
  }).format(amountMinor / 100);
}

function formatMoneyInputValue(amountMinor: number | null | undefined) {
  if (amountMinor === null || amountMinor === undefined || Number.isNaN(amountMinor)) return "";
  return (amountMinor / 100).toFixed(2);
}

function parseMoneyInputToMinor(value: string) {
  const normalized = value.trim().replace(",", ".");
  if (!normalized) return null;
  if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return null;

  const parsed = Number(normalized);
  if (!Number.isFinite(parsed)) return null;

  return Math.round(parsed * 100);
}

function formatQrPreviewUrl(qrData: string) {
  return `https://api.qrserver.com/v1/create-qr-code/?size=360x360&ecc=M&margin=2&data=${encodeURIComponent(qrData)}`;
}

export function PaymentsAdminClient({ initialData, qrOptions }: PaymentsAdminClientProps) {
  const [data, setData] = useState(initialData);
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState<string | null>(null);
  const [generatedQr, setGeneratedQr] = useState<GeneratedQr | null>(null);
  const [form, setForm] = useState({
    ...emptyForm,
    approvedAt: toDateTimeLocalInput(new Date().toISOString()),
  });

  const firstVendingMachineId = qrOptions.vendingMachines[0]?.id ?? "";
  const firstProductId = qrOptions.products[0]?.id ?? "";

  const [anyAmountForm, setAnyAmountForm] = useState({
    vendingMachineId: firstVendingMachineId,
    productId: firstProductId,
    amountMinor: "15.00",
    currency: "ARS",
    mode: "dynamic" as const,
    description: "",
  });

  const [productAmountForm, setProductAmountForm] = useState({
    vendingMachineId: firstVendingMachineId,
    productId: firstProductId,
    amountMinor: "",
    currency: "ARS",
    mode: "dynamic" as const,
    description: "",
  });

  const selectedProduct = useMemo(
    () => qrOptions.products.find((item) => item.id === productAmountForm.productId) ?? qrOptions.products[0] ?? null,
    [productAmountForm.productId, qrOptions.products]
  );

  const anyAmountMinor = useMemo(() => parseMoneyInputToMinor(anyAmountForm.amountMinor), [anyAmountForm.amountMinor]);
  const anyAmountValidationError = useMemo(() => {
    if (!anyAmountForm.vendingMachineId) return "Select a vending machine.";
    if (!anyAmountForm.productId) return "Select a product.";
    if (anyAmountMinor === null) return "Use a value like 15.00.";
    if (anyAmountMinor < MIN_PAYMENT_AMOUNT_MINOR) return "Minimum amount is 15.00 ARS.";
    return null;
  }, [anyAmountForm.productId, anyAmountForm.vendingMachineId, anyAmountMinor]);

  const productAmountMinor = useMemo(() => {
    if (!productAmountForm.amountMinor.trim()) return selectedProduct?.priceMinor ?? null;
    return parseMoneyInputToMinor(productAmountForm.amountMinor);
  }, [productAmountForm.amountMinor, selectedProduct?.priceMinor]);

  const productAmountValidationError = useMemo(() => {
    if (!productAmountForm.vendingMachineId) return "Select a vending machine.";
    if (!productAmountForm.productId) return "Select a product.";
    if (!productAmountForm.amountMinor.trim() && selectedProduct?.priceMinor === null) return "This product has no default price.";
    if (productAmountForm.amountMinor.trim() && productAmountMinor === null) return "Use a value like 1000.00.";
    if (productAmountMinor !== null && productAmountMinor < MIN_PAYMENT_AMOUNT_MINOR) return "Minimum amount is 15.00 ARS.";
    return null;
  }, [productAmountForm.amountMinor, productAmountForm.productId, productAmountForm.vendingMachineId, productAmountMinor, selectedProduct?.priceMinor]);

  async function reloadData() {
    const response = await fetch("/api/admin/payments/operations", { cache: "no-store" });
    if (!response.ok) {
      throw new Error(await readError(response));
    }

    const payload = (await response.json()) as { item: PaymentsAdminData };
    startTransition(() => {
      setData(payload.item);
    });
  }

  async function runMutation(task: () => Promise<void>, successMessage: string) {
    setBusy(true);
    setMessage(null);

    try {
      await task();
      await reloadData();
      setMessage(successMessage);
    } catch (error) {
      setMessage(normalizeError(error));
    } finally {
      setBusy(false);
    }
  }

  function updateItem(sessionId: string, updater: (item: SerializedPaymentSession) => SerializedPaymentSession) {
    setData((current) => ({
      ...current,
      items: current.items.map((item) => {
        if (item.sessionId !== sessionId) return item;

        const next = updater(item);
        const parsedReference = parsePaymentExternalReference(next.externalReference);

        return {
          ...next,
          correlation: {
            parsedReference,
            externalReferenceIsCanonical: parsedReference !== null,
            vendingMachineIdMatchesReference: parsedReference ? parsedReference.vendingMachineId === next.vendingMachineId : false,
            machineTxnIdMatchesReference: parsedReference ? parsedReference.machineTxnId === next.machineTxnId : false,
          },
        };
      }),
    }));
  }

  async function createQrOrder(payload: Record<string, unknown>) {
    const response = await fetch("/api/integrations/payments/orders", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      throw new Error(await readError(response));
    }

    const body = (await response.json()) as {
      item: {
        orderId: string;
        externalReference: string;
        amountMinor: number;
        currency: string;
        mode: string;
        externalPosId: string;
        status: string;
        statusDetail: string;
        expiresAt: string | null;
        qrData: string | null;
      };
    };

    const description = typeof payload.description === "string" ? payload.description : "";
    setGeneratedQr({
      orderId: body.item.orderId,
      externalReference: body.item.externalReference,
      amountMinor: body.item.amountMinor,
      currency: body.item.currency,
      mode: body.item.mode,
      description,
      externalPosId: body.item.externalPosId,
      vendingMachineId: String(payload.vendingMachineId ?? ""),
      productId: String(payload.productId ?? ""),
      qrData: body.item.qrData,
    });
    setMessage(`QR creado: ${body.item.orderId}`);
  }

  return (

Live QR generator

Mercado Pago QR para Ballbox

              Genera QR de cobro para cualquier monto o desde un producto Ballbox con precio guardado. El resultado devuelve el order ID y el QR listo para escanear en la app de Mercado Pago.

            external POS: default

Any amount

Monto libre

              manual

              <Select
                value={anyAmountForm.vendingMachineId}
                onValueChange={(value) => setAnyAmountForm((current) => ({ ...current, vendingMachineId: value }))}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Vending machine" />
                </SelectTrigger>
                <SelectContent>
                  {qrOptions.vendingMachines.map((machine) => (
                    <SelectItem key={machine.id} value={machine.id}>
                      {machine.id} · {machine.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>

              <Select
                value={anyAmountForm.productId}
                onValueChange={(value) => setAnyAmountForm((current) => ({ ...current, productId: value }))}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Product" />
                </SelectTrigger>
                <SelectContent>
                  {qrOptions.products.map((product) => (
                    <SelectItem key={product.id} value={product.id}>
                      {product.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>

              <Input
                value={anyAmountForm.amountMinor}
                onChange={(event) => setAnyAmountForm((current) => ({ ...current, amountMinor: event.target.value }))}
                placeholder="15.00"
                inputMode="decimal"
                disabled={busy}
                aria-invalid={anyAmountValidationError !== null}
              />

              <Select
                value={anyAmountForm.currency}
                onValueChange={(value) => setAnyAmountForm((current) => ({ ...current, currency: value }))}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Currency" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="ARS">ARS</SelectItem>
                </SelectContent>
              </Select>

              <Input
                className="md:col-span-2"
                value={anyAmountForm.description}
                onChange={(event) => setAnyAmountForm((current) => ({ ...current, description: event.target.value }))}
                placeholder="Description optional"
                disabled={busy}
              />

              <Button
                loading={busy}
                disabled={busy || anyAmountValidationError !== null}
                className={busy || anyAmountValidationError !== null ? "" : "cursor-pointer"}
                onClick={() =>
                  runMutation(async () => {
                    const machine = qrOptions.vendingMachines.find((item) => item.id === anyAmountForm.vendingMachineId);
                    const product = qrOptions.products.find((item) => item.id === anyAmountForm.productId);
                    const amountMinor = anyAmountMinor;
                    if (amountMinor === null) {
                      throw new Error("INVALID_PAYLOAD");
                    }
                    if (amountMinor < MIN_PAYMENT_AMOUNT_MINOR) {
                      throw new Error("INVALID_PAYLOAD");
                    }
                    await createQrOrder({
                      vendingMachineId: anyAmountForm.vendingMachineId,
                      productId: anyAmountForm.productId,
                      amountMinor,
                      currency: anyAmountForm.currency,
                      mode: anyAmountForm.mode,
                      description:
                        anyAmountForm.description.trim() || [product?.name, machine?.label].filter(Boolean).join(" · ") || undefined,
                    });
                  }, "QR any-amount created")
                }
              >
                Generate QR
              </Button>
              {anyAmountValidationError ?
{anyAmountValidationError}
 : null}

Per product

Monto desde producto

              catalog

              <Select
                value={productAmountForm.vendingMachineId}
                onValueChange={(value) => setProductAmountForm((current) => ({ ...current, vendingMachineId: value }))}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Vending machine" />
                </SelectTrigger>
                <SelectContent>
                  {qrOptions.vendingMachines.map((machine) => (
                    <SelectItem key={machine.id} value={machine.id}>
                      {machine.id} · {machine.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>

              <Select
                value={productAmountForm.productId}
                onValueChange={(value) =>
                  setProductAmountForm((current) => ({
                    ...current,
                    productId: value,
                  }))
                }
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Product" />
                </SelectTrigger>
                <SelectContent>
                  {qrOptions.products.map((product) => (
                    <SelectItem key={product.id} value={product.id}>
                      {product.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>

              <Input
                value={productAmountForm.amountMinor}
                onChange={(event) => setProductAmountForm((current) => ({ ...current, amountMinor: event.target.value }))}
                placeholder={selectedProduct ? formatMoneyInputValue(selectedProduct.priceMinor) : "1000.00"}
                inputMode="decimal"
                disabled={busy}
                aria-invalid={productAmountValidationError !== null}
              />

              <Select
                value={productAmountForm.currency}
                onValueChange={(value) => setProductAmountForm((current) => ({ ...current, currency: value }))}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder="Currency" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="ARS">ARS</SelectItem>
                </SelectContent>
              </Select>

              <Input
                className="md:col-span-2"
                value={productAmountForm.description}
                onChange={(event) => setProductAmountForm((current) => ({ ...current, description: event.target.value }))}
                placeholder="Description optional"
                disabled={busy}
              />

              {selectedProduct ? (
                <>
                  Default price: {selectedProduct.priceMinor !== null ? formatMoney(selectedProduct.priceMinor, "ARS") : "no price set"}. Dejá el campo vacío para usar ese valor.
                </>
              ) : (
                "No products configured"
              )}

              <Button
                loading={busy}
                disabled={busy || productAmountValidationError !== null}
                className={busy || productAmountValidationError !== null ? "" : "cursor-pointer"}
                onClick={() =>
                  runMutation(async () => {
                    const machine = qrOptions.vendingMachines.find((item) => item.id === productAmountForm.vendingMachineId);
                    const payload: Record<string, unknown> = {
                      vendingMachineId: productAmountForm.vendingMachineId,
                      productId: productAmountForm.productId,
                      currency: productAmountForm.currency,
                      mode: productAmountForm.mode,
                      description:
                        productAmountForm.description.trim() || [selectedProduct?.name, machine?.label].filter(Boolean).join(" · ") || undefined,
                    };

                    if (productAmountForm.amountMinor.trim()) {
                      if (productAmountMinor === null) {
                        throw new Error("INVALID_PAYLOAD");
                      }
                      if (productAmountMinor < MIN_PAYMENT_AMOUNT_MINOR) {
                        throw new Error("INVALID_PAYLOAD");
                      }
                      payload.amountMinor = productAmountMinor;
                    }

                    await createQrOrder(payload);
                  }, "QR product created")
                }
              >
                Generate QR
              </Button>
              {productAmountValidationError ?
{productAmountValidationError}
 : null}

        {generatedQr ? (

QR result

{generatedQr.orderId}

                  {formatMoney(generatedQr.amountMinor, generatedQr.currency)} · {generatedQr.mode} · {generatedQr.externalPosId}

{generatedQr.description || generatedQr.externalReference}

{generatedQr.vendingMachineId}

                {generatedQr.qrData ? (
                  <Image
                    src={formatQrPreviewUrl(generatedQr.qrData)}
                    alt={`QR for ${generatedQr.orderId}`}
                    width={320}
                    height={320}
                    unoptimized
                    className="h-[320px] w-[320px] rounded-2xl border border-border/60 bg-white p-3"
                  />
                ) : (

                    No QR data returned

                )}

External reference

{generatedQr.externalReference}

QR payload

{generatedQr.qrData ?? "null"}

Flow

Create QR in Ballbox, scan with a real Mercado Pago buyer account, and verify the webhook writes a session to `/admin/payments`.

        ) : null}

Sessions

{data.summary.sessions}

Vending machines

{data.summary.vendingMachines}

Late approvals

{data.summary.lateApprovals}

Reference mismatches

{data.summary.correlationMismatches}

Approved totals

            {data.summary.amountTotals.map((item) => (

                {formatMoney(item.amountMinor, item.currency)}

            ))}

Payments ops

Approved payment sessions

              Surface admin para reconciliar callbacks aprobados persistidos localmente. Corrige referencia, vending machine y metadata sobre el flujo de pagos ya centralizado en Ballbox.

{data.source}

          <Input value={form.sessionId} onChange={(event) => setForm((current) => ({ ...current, sessionId: event.target.value }))} placeholder="session_id" disabled={busy} />
          <Input
            value={form.vendingMachineId}
            onChange={(event) => setForm((current) => ({ ...current, vendingMachineId: event.target.value }))}
            placeholder="vending_machine_id"
            disabled={busy}
          />
          <Input value={form.productId} onChange={(event) => setForm((current) => ({ ...current, productId: event.target.value }))} placeholder="product_id" disabled={busy} />
          <Input value={form.machineTxnId} onChange={(event) => setForm((current) => ({ ...current, machineTxnId: event.target.value }))} placeholder="machine_txn_id" disabled={busy} />
          <Input value={form.externalReference} onChange={(event) => setForm((current) => ({ ...current, externalReference: event.target.value }))} placeholder="payment:vendingMachine:txn" disabled={busy} />
          <Input value={form.amountMinor} onChange={(event) => setForm((current) => ({ ...current, amountMinor: event.target.value }))} placeholder="amount_minor" disabled={busy} inputMode="numeric" />
          <Input value={form.currency} onChange={(event) => setForm((current) => ({ ...current, currency: event.target.value }))} placeholder="ARS" disabled={busy} />
          <Input value={form.approvedAt} onChange={(event) => setForm((current) => ({ ...current, approvedAt: event.target.value }))} type="datetime-local" disabled={busy} />
          <Input
            value={form.providerPaymentId}
            onChange={(event) => setForm((current) => ({ ...current, providerPaymentId: event.target.value }))}
            placeholder="provider_payment_id"
            disabled={busy}
          />
          <Input value={form.source} onChange={(event) => setForm((current) => ({ ...current, source: event.target.value }))} placeholder="source" disabled={busy} />

          <input
            type="checkbox"
            checked={form.lateApproval}
            onChange={(event) => setForm((current) => ({ ...current, lateApproval: event.target.checked }))}
            disabled={busy}
          />
          late approval

          <Button
            loading={busy}
            disabled={
              busy ||
              !form.sessionId.trim() ||
              !form.vendingMachineId.trim() ||
              !form.productId.trim() ||
              !form.machineTxnId.trim() ||
              !form.externalReference.trim() ||
              !form.approvedAt.trim()
            }
            onClick={() =>
              runMutation(async () => {
                const response = await fetch("/api/admin/payments/sessions", {
                  method: "POST",
                  headers: { "content-type": "application/json" },
                  body: JSON.stringify({
                    ...form,
                    amountMinor: Number(form.amountMinor),
                    approvedAt: toPayloadDate(form.approvedAt),
                    providerPaymentId: form.providerPaymentId || undefined,
                  }),
                });

                if (!response.ok) {
                  throw new Error(await readError(response));
                }

                setForm({
                  ...emptyForm,
                  approvedAt: toDateTimeLocalInput(new Date().toISOString()),
                });
              }, "Payment session created")
            }
          >
            Add session
          </Button>
          {message ?
{message}
 : null}

        {data.items.map((item) => {
          const hasMismatch =
            !item.correlation.vendingMachineIdMatchesReference || !item.correlation.machineTxnIdMatchesReference;

          return (

Session

{item.sessionId}

                    {formatMoney(item.amountMinor, item.currency)} · {item.currency} · {item.status}

                  {item.source}
                  {item.lateApproval ? late : null}
                  {hasMismatch ? reference mismatch : null}

                <Input
                  value={item.vendingMachineId}
                  onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, vendingMachineId: event.target.value }))}
                  disabled={busy}
                />
                <Input value={item.productId} onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, productId: event.target.value }))} disabled={busy} />
                <Input value={item.machineTxnId} onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, machineTxnId: event.target.value }))} disabled={busy} />
                <Input
                  value={item.externalReference}
                  onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, externalReference: event.target.value }))}
                  disabled={busy}
                />
                <Input
                  value={String(item.amountMinor)}
                  onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, amountMinor: Number(event.target.value || "0") }))}
                  disabled={busy}
                  inputMode="numeric"
                />
                <Input value={item.currency} onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, currency: event.target.value }))} disabled={busy} />
                <Input
                  value={toDateTimeLocalInput(item.approvedAt)}
                  onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, approvedAt: toPayloadDate(event.target.value) }))}
                  type="datetime-local"
                  disabled={busy}
                />
                <Input
                  value={item.providerPaymentId ?? ""}
                  onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, providerPaymentId: event.target.value || null }))}
                  disabled={busy}
                />
                <Input value={item.source} onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, source: event.target.value }))} disabled={busy} />

                  <input
                    type="checkbox"
                    checked={item.lateApproval}
                    onChange={(event) => updateItem(item.sessionId, (current) => ({ ...current, lateApproval: event.target.checked }))}
                    disabled={busy}
                  />
                  late approval

                  Parsed reference: {" "}
                  {item.correlation.parsedReference
                    ? `${item.correlation.parsedReference.vendingMachineId} / ${item.correlation.parsedReference.machineTxnId}`
                    : "invalid"}

Provider payment id: {item.providerPaymentId ?? "none"}

                <Button
                  loading={busy}
                  variant="outline"
                  disabled={busy}
                  onClick={() =>
                    runMutation(async () => {
                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, {
                        method: "PATCH",
                        headers: { "content-type": "application/json" },
                        body: JSON.stringify({
                          vendingMachineId: item.vendingMachineId,
                          productId: item.productId,
                          machineTxnId: item.machineTxnId,
                          externalReference: item.externalReference,
                          amountMinor: item.amountMinor,
                          currency: item.currency,
                          approvedAt: item.approvedAt,
                          lateApproval: item.lateApproval,
                          providerPaymentId: item.providerPaymentId ?? "",
                          source: item.source,
                        }),
                      });

                      if (!response.ok) {
                        throw new Error(await readError(response));
                      }
                    }, "Payment session updated")
                  }
                >
                  Save
                </Button>
                <Button
                  loading={busy}
                  variant="destructive"
                  disabled={busy}
                  onClick={() =>
                    runMutation(async () => {
                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, { method: "DELETE" });
                      if (!response.ok) {
                        throw new Error(await readError(response));
                      }
                    }, "Payment session deleted")
                  }
                >
                  Delete
                </Button>

          );
        })}

  );
}


"use client";

import Link from "next/link";
import { useMemo, useState } from "react";
import { addDays, startOfDay } from "date-fns";
import { Button } from "@/components/ui/button";
import { formatBallboxDateTime, formatBallboxShortDateLabel, formatBallboxTime } from "@/lib/ballbox-datetime";
import type { CoachAvailabilityCalendar, CoachAvailabilitySlot, CoachAvailabilitySlotCoach } from "@/lib/coach-availability-matching";

type ClassesByDatePageClientProps = {
  calendar: CoachAvailabilityCalendar;
};

type SlotSelection = {
  slot: CoachAvailabilitySlot;
  coach: CoachAvailabilitySlotCoach | null;
};

function buildDateKeys(startIso: string, days: number) {
  const start = startOfDay(new Date(startIso));
  return Array.from({ length: days }, (_, index) => addDays(start, index).toISOString().slice(0, 10));
}

function buildSlotHref(slot: CoachAvailabilitySlot, coach: CoachAvailabilitySlotCoach, clubId: string, calendar: CoachAvailabilityCalendar) {
  if (!coach.offerId) return null;
  const details = calendar.slotDetailsByClub[clubId]?.[slot.start] ?? null;
  const params = new URLSearchParams({
    slotStart: slot.start,
    slotDurationMinutes: String(slot.durationMinutes),
  });

  if (details?.priceMinor !== null && details?.priceMinor !== undefined) {
    params.set("slotPriceMinor", String(details.priceMinor));
  }
  if (details?.currency) {
    params.set("slotCurrency", details.currency);
  }
  if (details?.courtId) {
    params.set("slotCourtId", details.courtId);
  }
  if (details?.courtName) {
    params.set("slotCourtName", details.courtName);
  }
  if (details?.sportclubId) {
    params.set("slotSportclubId", details.sportclubId);
  }

  return `/classes/${coach.offerId}?${params.toString()}#request-form`;
}

export function ClassesByDatePageClient({ calendar }: ClassesByDatePageClientProps) {
  const [selectedZone, setSelectedZone] = useState<string>("");
  const [selectedCoachId, setSelectedCoachId] = useState<string>("all");
  const [selectedClubId, setSelectedClubId] = useState<string>("all");
  const [selection, setSelection] = useState<SlotSelection | null>(null);

  const dateKeys = useMemo(() => buildDateKeys(calendar.range.start, 14), [calendar.range.start]);
  const zoneOptions = useMemo(
    () => Array.from(new Set(calendar.clubs.map((club) => club.zoneLabel))).sort((left, right) => left.localeCompare(right, "es")),
    [calendar.clubs]
  );
  const clubsInSelectedZone = useMemo(
    () => calendar.clubs.filter((club) => (selectedZone ? club.zoneLabel === selectedZone : true)),
    [calendar.clubs, selectedZone]
  );

  const filteredSlots = useMemo(() => {
    return calendar.slots.filter((slot) => {
      if (!selectedZone) {
        return false;
      }

      if (selectedCoachId !== "all" && !slot.coaches.some((coach) => coach.id === selectedCoachId)) {
        return false;
      }

      const clubsMatchingZone = slot.coaches.some((coach) =>
        coach.clubs.some((club) => club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
      );
      if (!clubsMatchingZone) {
        return false;
      }

      if (selectedClubId !== "all") {
        return slot.coaches.some((coach) => coach.clubs.some((club) => club.id === selectedClubId));
      }

      return true;
    });
  }, [calendar.slots, selectedClubId, selectedCoachId, selectedZone]);

  const slotsByDate = useMemo(() => {
    const grouped = new Map<string, CoachAvailabilitySlot[]>();
    for (const dateKey of dateKeys) {
      grouped.set(dateKey, []);
    }
    for (const slot of filteredSlots) {
      const group = grouped.get(slot.dateKey);
      if (group) {
        group.push(slot);
      }
    }
    for (const group of grouped.values()) {
      group.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime());
    }
    return grouped;
  }, [filteredSlots, dateKeys]);

  const weekGroups = useMemo(() => {
    const groups: string[][] = [];
    for (let i = 0; i < dateKeys.length; i += 7) {
      groups.push(dateKeys.slice(i, i + 7));
    }
    return groups;
  }, [dateKeys]);

  const selectedSlot = selection?.slot ?? null;
  const selectedSlotCoach = selection?.coach ?? null;
  const visibleSelectionCoaches = useMemo(() => {
    if (!selectedSlot || !selectedZone) return [];

    return selectedSlot.coaches.filter((coach) => {
      if (selectedCoachId !== "all" && coach.id !== selectedCoachId) {
        return false;
      }

      return coach.clubs.some((club) => club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId));
    });
  }, [selectedClubId, selectedCoachId, selectedSlot, selectedZone]);
  const activeSelectionCoach =
    (selectedSlotCoach ? visibleSelectionCoaches.find((coach) => coach.id === selectedSlotCoach.id) : null) ?? null;

  return (

Reservas

            Primero elige tu zona. Despues Ballbox te muestra solo los horarios y clubes que hoy tienen opcion real para reservar.

            Zona
            <select
              value={selectedZone}
              onChange={(event) => {
                setSelectedZone(event.target.value);
                setSelectedClubId("all");
                setSelection(null);
              }}
              className="mt-2 h-11 w-full rounded-xl border border-white/15 bg-black/30 px-3 text-sm text-white"
            >
              <option value="">Selecciona tu zona</option>
              {zoneOptions.map((zone) => (
                <option key={zone} value={zone}>
                  {zone}
                </option>
              ))}
            </select>

            Coach
            <select
              value={selectedCoachId}
              onChange={(event) => setSelectedCoachId(event.target.value)}
              className="mt-2 h-11 w-full rounded-xl border border-white/15 bg-black/30 px-3 text-sm text-white"
            >
              <option value="all">Todos los coaches</option>
              {calendar.coaches.map((coach) => (
                <option key={coach.id} value={coach.id}>
                  {coach.name}
                </option>
              ))}
            </select>

            Club
            <select
              value={selectedClubId}
              onChange={(event) => setSelectedClubId(event.target.value)}
              disabled={!selectedZone}
              className="mt-2 h-11 w-full rounded-xl border border-white/15 bg-black/30 px-3 text-sm text-white"
            >
              <option value="all">{selectedZone ? "Todos los clubes" : "Primero elige zona"}</option>
              {clubsInSelectedZone.map((club) => (
                <option key={club.id} value={club.id}>
                  {club.name ?? "ATC no disponible"}
                </option>
              ))}
            </select>

      {!selectedZone ? (

          Selecciona una zona para ver los clubes y horarios disponibles.

      ) : null}

      {selectedZone && filteredSlots.length === 0 ? (

          No encontramos horarios abiertos en esta zona con los filtros elegidos.

      ) : null}

      {selectedZone
        ? weekGroups.map((week, index) => (
        <div key={`week-${index}`} className="grid gap-4 lg:grid-cols-7">
          {week.map((dateKey) => {
            const slots = slotsByDate.get(dateKey) ?? [];

            return (

                  {formatBallboxShortDateLabel(dateKey)}
                  {slots.length}

                  {slots.length === 0 ? (

Sin rangos detectados.

                  ) : (
                    slots.map((slot) => (
                      <button
                        key={slot.key}
                        type="button"
                        onClick={() => {
                          const visibleCoaches = slot.coaches.filter((coach) => {
                            if (selectedCoachId !== "all" && coach.id !== selectedCoachId) {
                              return false;
                            }

                            return coach.clubs.some(
                              (club) => club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId)
                            );
                          });
                          const defaultCoach =
                            selectedCoachId !== "all"
                              ? visibleCoaches.find((coach) => coach.id === selectedCoachId) ?? null
                              : visibleCoaches[0] ?? null;
                          setSelection({ slot, coach: defaultCoach });
                        }}
                        className={`w-full rounded-2xl border px-3 py-3 text-left text-sm transition ${
                          slot.hasCourts ? "border-white/10 bg-black/30 text-white" : "border-[#f6c453]/40 bg-[#3a2c14]/40 text-[#f6c453]"
                        }`}
                      >

                          {formatBallboxTime(slot.start)}
                          {slot.durationMinutes}m

{slot.coaches.length} coaches disponibles

                        {!slot.hasCourts ? (

Coach disponible, pero sin canchas.

                        ) : null}
                      </button>
                    ))
                  )}

            );
          })}

          ))
        : null}

      {selection && selectedSlot ? (

Seleccion de horario

{formatBallboxDateTime(selectedSlot.start)}

Elige un coach y luego el club con canchas disponibles.

              <button
                type="button"
                onClick={() => setSelection(null)}
                className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-white/70"
              >
                Cerrar
              </button>

Paso 1: Coach

                {visibleSelectionCoaches.length === 0 ? (

No hay coaches visibles para esa zona con los filtros elegidos.

                ) : (

                    {visibleSelectionCoaches.map((coach) => {
                      const hasCourts = coach.clubs.some((club) => club.hasCourts);
                      const active = activeSelectionCoach?.id === coach.id;

                      return (
                        <button
                          key={coach.id}
                          type="button"
                          onClick={() => setSelection({ slot: selectedSlot, coach })}
                          className={`w-full rounded-xl border px-3 py-2 text-left text-sm ${
                            active ? "border-[#c4d600]/50 bg-[#c4d600]/10" : "border-white/10 bg-black/30"
                          }`}
                        >

                            {coach.name}
                            <span className={`text-xs ${hasCourts ? "text-[#c4d600]" : "text-[#f6c453]"}`}>
                              {hasCourts ? "Con canchas" : "Sin canchas"}

                        </button>
                      );
                    })}

                )}

Paso 2: Club

                {activeSelectionCoach ? (

                    {activeSelectionCoach.clubs.length === 0 ? (

Este coach no tiene clubes habilitados.

                    ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (

Coach disponible, pero sin canchas en este horario.

                    ) : (
                      activeSelectionCoach.clubs
                        .filter((club) => club.hasCourts && club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
                        .map((club) => {
                          const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);

                          return (

{club.name ?? "ATC no disponible"}

                                {href ? (
                                  <Button asChild className="h-9 rounded-lg text-xs">
                                    <Link href={href}>Solicitar con este club</Link>
                                  </Button>
                                ) : (
                                  Sin oferta activa para este coach
                                )}

                          );
                        })
                    )}

                ) : (

Elige un coach para continuar.

                )}

      ) : null}

  );
}


**Planning modal improvements**

I need a structured plan to address a high-impact issue: the custom modal I'm using is a fixed div without proper dialog semantics or a focus trap. Refactoring to a native details element or a dedicated section could enhance the HTML-first approach. Also, I think it would be better to replace the modal selection with an inline details panel or route parameters. Additionally, the payments page has custom selects without labels, so converting them to native selects and forms with proper fieldsets and labels is essential. I should check routes and tests too!

cd /home/sebas/work/projects/ballbox && rg -n "PaymentsAdminClient|ClassesByDatePageClient|/admin/payments|classes-by-date" tests app components --glob '!node_modules'


app/classes-by-date/page.tsx:2:import { ClassesByDatePageClient } from "@/components/classes-by-date-page-client";
app/classes-by-date/page.tsx:32:        <ClassesByDatePageClient calendar={calendar} />
app/coaches/page.tsx:13:          <Link href="/classes-by-date" className="text-sm text-white/60 transition hover:text-[#c4d600]">
tests/e2e/classes.spec.ts:39:test("classes-by-date handoff keeps slot and club selection", async ({ page }) => {
tests/e2e/classes.spec.ts:40:  await page.goto("/classes-by-date");
tests/e2e/payments-admin.spec.ts:14:  await gotoAdmin(page, "/admin/payments");
app/classes/(authenticated)/page.tsx:22:            href="/classes-by-date"
tests/contracts/payments/admin-routes.test.ts:43:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
tests/contracts/payments/admin-routes.test.ts:45:      new Request("http://localhost/api/admin/payments/sessions", {
tests/contracts/payments/admin-routes.test.ts:69:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
tests/contracts/payments/admin-routes.test.ts:71:      new Request("http://localhost/api/admin/payments/sessions", {
tests/contracts/payments/admin-routes.test.ts:90:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
tests/contracts/payments/admin-routes.test.ts:92:      new Request("http://localhost/api/admin/payments/sessions", {
tests/contracts/payments/admin-routes.test.ts:121:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
tests/contracts/payments/admin-routes.test.ts:123:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
tests/contracts/payments/admin-routes.test.ts:141:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
tests/contracts/payments/admin-routes.test.ts:143:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
tests/contracts/payments/admin-routes.test.ts:159:    const { DELETE } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
tests/contracts/payments/admin-routes.test.ts:160:    const response = await DELETE(new Request("http://localhost/api/admin/payments/sessions/ps_missing"), {
tests/contracts/classes/by-date.test.ts:2:import { flattenOffersToDateFirstSlots, listDateFirstDateKeys } from "@/lib/classes-by-date";
app/admin/payments/page.tsx:1:import { PaymentsAdminClient } from "@/components/payments-admin-client";
app/admin/payments/page.tsx:9:  return <PaymentsAdminClient initialData={data} qrOptions={qrOptions} />;
components/navbar.tsx:14:  { name: "Reservas de cancha", href: "/classes-by-date" },
components/classes-by-date-page-client.tsx:10:type ClassesByDatePageClientProps = {
components/classes-by-date-page-client.tsx:51:export function ClassesByDatePageClient({ calendar }: ClassesByDatePageClientProps) {
components/payments-admin-client.tsx:46:type PaymentsAdminClientProps = {
components/payments-admin-client.tsx:124:export function PaymentsAdminClient({ initialData, qrOptions }: PaymentsAdminClientProps) {
components/payments-admin-client.tsx:184:    const response = await fetch("/api/admin/payments/operations", { cache: "no-store" });
components/payments-admin-client.tsx:565:
Create QR in Ballbox, scan with a real Mercado Pago buyer account, and verify the webhook writes a session to `/admin/payments`.

components/payments-admin-client.tsx:661:                const response = await fetch("/api/admin/payments/sessions", {
components/payments-admin-client.tsx:771:                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, {
components/payments-admin-client.tsx:802:                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, { method: "DELETE" });
components/hero.tsx:51:                Reservas de cancha
components/footer.tsx:13:    { name: "Reservas de cancha", href: "/classes-by-date" },


import { PaymentsAdminClient } from "@/components/payments-admin-client";
import { getPaymentsAdminDataFromDb, listPaymentQrOptionsFromDb } from "@/lib/payments";

export const dynamic = "force-dynamic";

export default async function AdminPaymentsPage() {
  const [data, qrOptions] = await Promise.all([getPaymentsAdminDataFromDb(), listPaymentQrOptionsFromDb()]);

  return <PaymentsAdminClient initialData={data} qrOptions={qrOptions} />;
}


import Link from "next/link";
import { ClassesByDatePageClient } from "@/components/classes-by-date-page-client";
import { buildCoachAvailabilityCalendar } from "@/lib/coach-availability-matching";

export const dynamic = "force-dynamic";

export default async function ClassesByDatePage() {
  const calendar = await buildCoachAvailabilityCalendar();

  return (

          <Link href="/" className="text-sm text-white/60 transition hover:text-[#c4d600]">
            Volver a inicio
          </Link>
          <Link href="/coaches" className="text-sm text-white/60 transition hover:text-[#c4d600]">
            Sign in
          </Link>

Reservas de cancha

            Elige tu zona y busca donde jugar.

            Te mostramos primero clubes y horarios con disponibilidad visible. Desde ahi eliges coach, club y el horario que mejor te cierre.

        <ClassesByDatePageClient calendar={calendar} />

  );
}


import { expect, test } from "@playwright/test";

test("classes MVP supports list, detail, and request happy path", async ({ page }) => {
  await page.goto("/classes");

  await expect(page.getByRole("heading", { name: "Elegi un horario y solicita tu clase." })).toBeVisible();
  await expect(page.getByRole("link", { name: "Ver detalle y solicitar" }).first()).toBeVisible();

  await page.getByRole("link", { name: "Ver detalle y solicitar" }).first().click();

  await expect(page.getByRole("heading", { level: 2, name: "Elige horario y deja tus datos" })).toBeVisible();
  await expect(page.getByText("Contexto ATC")).toBeVisible();

  try {
    await page.waitForResponse((response) => response.url().includes("/api/classes/offers/") && response.status() === 200, {
      timeout: 2000,
    });
  } catch {
    // Detail may already be hydrated; continue.
  }

  const radios = page.getByRole("radio");
  const radioCount = await radios.count();
  if (radioCount > 1) {
    await radios.first().check();
  } else {
    await page.getByLabel("Cualquier horario / lugar").check();
  }

  await page.locator('input[name="studentName"]').fill("PW E2E Student");
  await page.locator('input[name="studentEmail"]').fill("pw-e2e-student@example.com");
  await page.locator('input[name="studentPhone"]').fill("+5491112345678");
  await page.locator('textarea[name="message"]').fill("Quiero coordinar una clase de prueba.");
  await page.getByRole("button", { name: "Solicitar clase" }).click();

  await expect(page.getByText(/Solicitud enviada/)).toBeVisible();
});

test("classes-by-date handoff keeps slot and club selection", async ({ page }) => {
  await page.goto("/classes-by-date");

  await page.getByRole("combobox").first().selectOption({ label: "Playwright" });

  const slotButton = page.locator("button", { hasText: "coaches disponibles" }).first();
  await expect(slotButton).toBeVisible();
  await slotButton.click();

  await expect(page.getByText("Seleccion de horario")).toBeVisible();

  const coachSection = page.getByText("Paso 1: Coach").locator("..");
  await coachSection.getByRole("button").first().click();

  const clubLink = page.getByRole("link", { name: "Solicitar con este club" });
  if (await clubLink.count()) {
    await clubLink.first().click();

    await expect(page).toHaveURL(/slotStart=/);
    await expect(page).toHaveURL(/slotDurationMinutes=/);
    await expect(page).toHaveURL(/slotSportclubId=/);
    await expect(page.getByRole("heading", { level: 2, name: "Elige horario y deja tus datos" })).toBeVisible();
  } else {
    await expect(page.getByText(/Coach disponible, pero sin canchas|Este coach no tiene clubes habilitados/).first()).toBeVisible();
  }
});


import { expect, test } from "@playwright/test";
import { gotoAdmin } from "./support/admin-auth";
import { E2E_PAYMENT_SESSION_PREFIX, createE2ESuffix } from "./support/test-fixtures";

test.describe.configure({ mode: "serial" });

test("payments admin supports approved session CRUD", async ({ page }) => {
  const suffix = createE2ESuffix();
  const sessionId = `${E2E_PAYMENT_SESSION_PREFIX}${suffix}`;
  const machineTxnId = `txn-${suffix}`;
  const machineTxnIdUpdated = `txn-${suffix}-updated`;
  const sourceUpdated = "admin-e2e-updated";

  await gotoAdmin(page, "/admin/payments");

  const approvedSessionsSection = page.locator("section").filter({ has: page.getByRole("button", { name: "Add session" }) }).first();

  await approvedSessionsSection.getByPlaceholder("session_id").fill(sessionId);
  await approvedSessionsSection.getByPlaceholder("vending_machine_id").fill("vm_003");
  await approvedSessionsSection.getByPlaceholder("product_id").fill("product_tubo_pelotas");
  await approvedSessionsSection.getByPlaceholder("machine_txn_id").fill(machineTxnId);
  await approvedSessionsSection.getByPlaceholder("payment:vendingMachine:txn").fill(`payment-vm_vm_003-txn_${machineTxnId}`);
  await approvedSessionsSection.getByPlaceholder("amount_minor").fill("2500");
  await approvedSessionsSection.getByPlaceholder("ARS").fill("ARS");
  await approvedSessionsSection.getByPlaceholder("provider_payment_id").fill(`provider-${suffix}`);
  await approvedSessionsSection.getByPlaceholder("source").fill("admin-e2e");
  await approvedSessionsSection.getByRole("button", { name: "Add session" }).click();

  await expect(page.getByText("Payment session created")).toBeVisible();

  const sessionArticle = page.locator("article").filter({ hasText: sessionId }).first();
  await expect(sessionArticle).toBeVisible();

  await sessionArticle.locator("input").nth(2).fill(machineTxnIdUpdated);
  await sessionArticle.locator("input").nth(3).fill(`payment-vm_vm_003-txn_${machineTxnIdUpdated}`);
  await sessionArticle.locator("input").nth(8).fill(sourceUpdated);
  await sessionArticle.getByLabel("late approval").check();
  await sessionArticle.getByRole("button", { name: "Save" }).click();

  await expect(page.getByText("Payment session updated")).toBeVisible();
  await expect(sessionArticle.locator("input").nth(2)).toHaveValue(machineTxnIdUpdated);
  await expect(sessionArticle.locator("input").nth(8)).toHaveValue(sourceUpdated);
  await expect(sessionArticle.getByText("reference mismatch")).toHaveCount(0);

  await sessionArticle.getByRole("button", { name: "Delete" }).click();

  await expect(page.getByText("Payment session deleted")).toBeVisible();
  await expect(page.locator("article").filter({ hasText: sessionId })).toHaveCount(0);
});


**Considering component changes**

I’m thinking about possibly adding a helper component called NativeSelect. Alternatively, I could style select tags directly. I need to inspect the UI input, maybe starting with a button. It might make sense to switch to using labels instead of placeholders for tests, though placeholders can still work.

I’m also considering how to edit the payments file. Since it's extensive, I could either rewrite the top imports and include the helper component or use a Python script to make major changes—though that feels a bit risky. Carefully patching and running type checks, lints, and tests will be necessary!

write

# Ballbox html-first accessibility plan

## Goal
Shift key Ballbox flows toward semantic HTML first, then JS enhancement.

## Principles
- Prefer native elements before custom widgets.
- Use labels, fieldsets, lists, headings, and forms.
- Keep critical actions reachable by keyboard with visible focus.
- Use ARIA to expose state, not to replace missing semantics.
- Keep selection and status visible in the document flow.

## Scope executed now
1. `classes-by-date`
   - Replace custom overlay flow with in-page detail panel.
   - Expose slot selection with `aria-expanded`, `aria-controls`, and live status.
   - Structure filters and results as labeled sections/lists.
2. `admin/payments`
   - Replace custom Radix selects in QR forms with native `<select>`.
   - Convert action areas into real forms with labels and submit buttons.
   - Add live status/error messaging and explicit help text.

## Next backlog
- Audit all custom dialogs, popovers, and selects.
- Add keyboard + screen reader acceptance checks for core booking and admin flows.
- Add automated accessibility checks in Playwright for public booking flows.
- Build a small set of html-first form primitives and ban `div`-as-control patterns in review.


Successfully wrote 1221 bytes to /home/sebas/work/projects/ballbox/docs/html-first-accessibility-plan.md


edit

const activeSelectionCoach =
    (selectedSlotCoach ? visibleSelectionCoaches.find((coach) => coach.id === selectedSlotCoach.id) : null) ?? null;

  return (

const activeSelectionCoach =
    (selectedSlotCoach ? visibleSelectionCoaches.find((coach) => coach.id === selectedSlotCoach.id) : null) ?? null;
  const detailPanelId = selectedSlot ? `slot-detail-${selectedSlot.key}` : "slot-detail";

  return (

-
Reservas

+
Reservas

+
Filtros para buscar horarios

             Primero elige tu zona. Despues Ballbox te muestra solo los horarios y clubes que hoy tienen opcion real para reservar.

-

+        <fieldset className="grid gap-3 sm:grid-cols-3">
+          <legend className="sr-only">Filtros de horarios</legend>

             Zona
             <select
@@
               ))}
             </select>

-

+        </fieldset>

      {selectedZone
        ? weekGroups.map((week, index) => (
        <div key={`week-${index}`} className="grid gap-4 lg:grid-cols-7">

      {selectedZone
        ? weekGroups.map((week, index) => (
        <section key={`week-${index}`} className="grid gap-4 lg:grid-cols-7" aria-label={`Semana ${index + 1} de horarios`}>

                      <button
                        key={slot.key}
                        type="button"
                        onClick={() => {
                          const visibleCoaches = slot.coaches.filter((coach) => {
                            if (selectedCoachId !== "all" && coach.id !== selectedCoachId) {
                              return false;
                            }

                            return coach.clubs.some(
                              (club) => club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId)
                            );
                          });
                          const defaultCoach =
                            selectedCoachId !== "all"
                              ? visibleCoaches.find((coach) => coach.id === selectedCoachId) ?? null
                              : visibleCoaches[0] ?? null;
                          setSelection({ slot, coach: defaultCoach });
                        }}
                        className={`w-full rounded-2xl border px-3 py-3 text-left text-sm transition ${
                          slot.hasCourts ? "border-white/10 bg-black/30 text-white" : "border-[#f6c453]/40 bg-[#3a2c14]/40 text-[#f6c453]"
                        }`}
                      >

                      <button
                        key={slot.key}
                        type="button"
                        onClick={() => {
                          const visibleCoaches = slot.coaches.filter((coach) => {
                            if (selectedCoachId !== "all" && coach.id !== selectedCoachId) {
                              return false;
                            }

                            return coach.clubs.some(
                              (club) => club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId)
                            );
                          });
                          const defaultCoach =
                            selectedCoachId !== "all"
                              ? visibleCoaches.find((coach) => coach.id === selectedCoachId) ?? null
                              : visibleCoaches[0] ?? null;
                          setSelection({ slot, coach: defaultCoach });
                        }}
                        aria-expanded={selectedSlot?.key === slot.key}
                        aria-controls={detailPanelId}
                        className={`w-full rounded-2xl border px-3 py-3 text-left text-sm transition ${
                          slot.hasCourts ? "border-white/10 bg-black/30 text-white" : "border-[#f6c453]/40 bg-[#3a2c14]/40 text-[#f6c453]"
                        }`}
                      >

          ))
        : null}

      {selection && selectedSlot ? (

Seleccion de horario

{formatBallboxDateTime(selectedSlot.start)}

Elige un coach y luego el club con canchas disponibles.

              <button
                type="button"
                onClick={() => setSelection(null)}
                className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-white/70"
              >
                Cerrar
              </button>

Paso 1: Coach

                {visibleSelectionCoaches.length === 0 ? (

No hay coaches visibles para esa zona con los filtros elegidos.

                ) : (

                    {visibleSelectionCoaches.map((coach) => {
                      const hasCourts = coach.clubs.some((club) => club.hasCourts);
                      const active = activeSelectionCoach?.id === coach.id;

                      return (
                        <button
                          key={coach.id}
                          type="button"
                          onClick={() => setSelection({ slot: selectedSlot, coach })}
                          className={`w-full rounded-xl border px-3 py-2 text-left text-sm ${
                            active ? "border-[#c4d600]/50 bg-[#c4d600]/10" : "border-white/10 bg-black/30"
                          }`}
                        >

                            {coach.name}
                            <span className={`text-xs ${hasCourts ? "text-[#c4d600]" : "text-[#f6c453]"}`}>
                              {hasCourts ? "Con canchas" : "Sin canchas"}

                        </button>
                      );
                    })}

                )}

Paso 2: Club

                {activeSelectionCoach ? (

                    {activeSelectionCoach.clubs.length === 0 ? (

Este coach no tiene clubes habilitados.

                    ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (

Coach disponible, pero sin canchas en este horario.

                    ) : (
                      activeSelectionCoach.clubs
                        .filter((club) => club.hasCourts && club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
                        .map((club) => {
                          const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);

                          return (

{club.name ?? "ATC no disponible"}

                                {href ? (
                                  <Button asChild className="h-9 rounded-lg text-xs">
                                    <Link href={href}>Solicitar con este club</Link>
                                  </Button>
                                ) : (
                                  Sin oferta activa para este coach
                                )}

                          );
                        })
                    )}

                ) : (

Elige un coach para continuar.

                )}

      ) : null}

  );
}

          ))
        : null}

        {selectedSlot ? `Horario seleccionado ${formatBallboxDateTime(selectedSlot.start)}` : "Ningun horario seleccionado"}

      {selection && selectedSlot ? (

Seleccion de horario

{formatBallboxDateTime(selectedSlot.start)}

Elige un coach y luego el club con canchas disponibles.

            <button
              type="button"
              onClick={() => setSelection(null)}
              className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-white/70"
            >
              Limpiar
            </button>

Paso 1: Coach

              {visibleSelectionCoaches.length === 0 ? (

No hay coaches visibles para esa zona con los filtros elegidos.

              ) : (

                  {visibleSelectionCoaches.map((coach) => {
                    const hasCourts = coach.clubs.some((club) => club.hasCourts);
                    const active = activeSelectionCoach?.id === coach.id;

                    return (
                      <button
                        key={coach.id}
                        type="button"
                        onClick={() => setSelection({ slot: selectedSlot, coach })}
                        aria-pressed={active}
                        className={`w-full rounded-xl border px-3 py-2 text-left text-sm ${
                          active ? "border-[#c4d600]/50 bg-[#c4d600]/10" : "border-white/10 bg-black/30"
                        }`}
                      >

                          {coach.name}
                          <span className={`text-xs ${hasCourts ? "text-[#c4d600]" : "text-[#f6c453]"}`}>
                            {hasCourts ? "Con canchas" : "Sin canchas"}

                      </button>
                    );
                  })}

              )}

Paso 2: Club

              {activeSelectionCoach ? (

                  {activeSelectionCoach.clubs.length === 0 ? (

Este coach no tiene clubes habilitados.

                  ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (

Coach disponible, pero sin canchas en este horario.

                  ) : (
                    activeSelectionCoach.clubs
                      .filter((club) => club.hasCourts && club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
                      .map((club) => {
                        const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);

                        return (

{club.name ?? "ATC no disponible"}

                              {href ? (
                                <Button asChild className="h-9 rounded-lg text-xs">
                                  <Link href={href}>Solicitar con este club</Link>
                                </Button>
                              ) : (
                                Sin oferta activa para este coach
                              )}

                        );
                      })
                  )}

              ) : (

Elige un coach para continuar.

              )}

      ) : null}

  );
}


Successfully replaced 4 block(s) in /home/sebas/work/projects/ballbox/components/classes-by-date-page-client.tsx.

...
 128     });
 129   }, [selectedClubId, selectedCoachId, selectedSlot, selectedZone]);
 130   const activeSelectionCoach =
 131     (selectedSlotCoach ? visibleSelectionCoaches.find((coach) => coach.id === selectedSlotCoach.id) : null) ?? null;
+132   const detailPanelId = selectedSlot ? `slot-detail-${selectedSlot.key}` : "slot-detail";
 132
 133   return (
-134

+135

 135

+137

+138 -
Reservas

+139 +
Reservas

+140 +
Filtros para buscar horarios

+141

+142              Primero elige tu zona. Despues Ballbox te muestra solo los horarios y clubes que hoy tienen opcion real para reservar.
+143

+144

+145 -

+146 +        <fieldset className="grid gap-3 sm:grid-cols-3">
+147 +          <legend className="sr-only">Filtros de horarios</legend>
+148
+149              Zona
+150              <select
+151 @@
+152                ))}
+153              </select>
+154
+155 -

+156 +        </fieldset>
+157

 136

 137
Reservas

 138

 139             Primero elige tu zona. Despues Ballbox te muestra solo los horarios y clubes que hoy tienen opcion real para reservar.
     ...
 206       ) : null}
 207
 208       {selectedZone
 209         ? weekGroups.map((week, index) => (
-210         <div key={`week-${index}`} className="grid gap-4 lg:grid-cols-7">
+232         <section key={`week-${index}`} className="grid gap-4 lg:grid-cols-7" aria-label={`Semana ${index + 1} de horarios`}>
 211           {week.map((dateKey) => {
 212             const slots = slotsByDate.get(dateKey) ?? [];
 213
 214             return (
     ...
 241                               ? visibleCoaches.find((coach) => coach.id === selectedCoachId) ?? null
 242                               : visibleCoaches[0] ?? null;
 243                           setSelection({ slot, coach: defaultCoach });
 244                         }}
+267                         aria-expanded={selectedSlot?.key === slot.key}
+268                         aria-controls={detailPanelId}
 245                         className={`w-full rounded-2xl border px-3 py-3 text-left text-sm transition ${
 246                           slot.hasCourts ? "border-white/10 bg-black/30 text-white" : "border-[#f6c453]/40 bg-[#3a2c14]/40 text-[#f6c453]"
 247                         }`}
 248                       >
     ...
 260

 261

 262             );
 263           })}
-264

+288

 265           ))
 266         : null}
 267
+292

+293         {selectedSlot ? `Horario seleccionado ${formatBallboxDateTime(selectedSlot.start)}` : "Ningun horario seleccionado"}
+294

+295
 268       {selection && selectedSlot ? (
-269

-270

-271

-272

-273
Seleccion de horario

-274
{formatBallboxDateTime(selectedSlot.start)}

-275
Elige un coach y luego el club con canchas disponibles.

-276

-277               <button
-278                 type="button"
-279                 onClick={() => setSelection(null)}
-280                 className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-white/70"
-281               >
-282                 Cerrar
-283               </button>
+297

+302

+303

+304
Seleccion de horario

+305
{formatBallboxDateTime(selectedSlot.start)}

+306
Elige un coach y luego el club con canchas disponibles.

 284

+308             <button
+309               type="button"
+310               onClick={() => setSelection(null)}
+311               className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-white/70"
+312             >
+313               Limpiar
+314             </button>
+315

 285
-286

-287

-288
Paso 1: Coach

-289                 {visibleSelectionCoaches.length === 0 ? (
-290
No hay coaches visibles para esa zona con los filtros elegidos.

-291                 ) : (
-292

-293                     {visibleSelectionCoaches.map((coach) => {
-294                       const hasCourts = coach.clubs.some((club) => club.hasCourts);
-295                       const active = activeSelectionCoach?.id === coach.id;
+317

+318

+319
Paso 1: Coach

+320               {visibleSelectionCoaches.length === 0 ? (
+321
No hay coaches visibles para esa zona con los filtros elegidos.

+322               ) : (
+323

+324                   {visibleSelectionCoaches.map((coach) => {
+325                     const hasCourts = coach.clubs.some((club) => club.hasCourts);
+326                     const active = activeSelectionCoach?.id === coach.id;
 296
-297                       return (
-298                         <button
-299                           key={coach.id}
-300                           type="button"
-301                           onClick={() => setSelection({ slot: selectedSlot, coach })}
-302                           className={`w-full rounded-xl border px-3 py-2 text-left text-sm ${
-303                             active ? "border-[#c4d600]/50 bg-[#c4d600]/10" : "border-white/10 bg-black/30"
-304                           }`}
-305                         >
-306

-307                             {coach.name}
-308                             <span className={`text-xs ${hasCourts ? "text-[#c4d600]" : "text-[#f6c453]"}`}>
-309                               {hasCourts ? "Con canchas" : "Sin canchas"}
-310
-311

-312                         </button>
-313                       );
-314                     })}
-315

-316                 )}
-317

+328                     return (
+329                       <button
+330                         key={coach.id}
+331                         type="button"
+332                         onClick={() => setSelection({ slot: selectedSlot, coach })}
+333                         aria-pressed={active}
+334                         className={`w-full rounded-xl border px-3 py-2 text-left text-sm ${
+335                           active ? "border-[#c4d600]/50 bg-[#c4d600]/10" : "border-white/10 bg-black/30"
+336                         }`}
+337                       >
+338

+339                           {coach.name}
+340                           <span className={`text-xs ${hasCourts ? "text-[#c4d600]" : "text-[#f6c453]"}`}>
+341                             {hasCourts ? "Con canchas" : "Sin canchas"}
+342
+343

+344                       </button>
+345                     );
+346                   })}
+347

+348               )}
+349

 318
-319

-320
Paso 2: Club

-321                 {activeSelectionCoach ? (
-322

-323                     {activeSelectionCoach.clubs.length === 0 ? (
-324
Este coach no tiene clubes habilitados.

-325                     ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (
-326
Coach disponible, pero sin canchas en este horario.

-327                     ) : (
-328                       activeSelectionCoach.clubs
-329                         .filter((club) => club.hasCourts && club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
-330                         .map((club) => {
-331                           const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);
+351

+352
Paso 2: Club

+353               {activeSelectionCoach ? (
+354

+355                   {activeSelectionCoach.clubs.length === 0 ? (
+356
Este coach no tiene clubes habilitados.

+357                   ) : activeSelectionCoach.clubs.filter((club) => club.hasCourts && club.zoneLabel === selectedZone).length === 0 ? (
+358
Coach disponible, pero sin canchas en este horario.

+359                   ) : (
+360                     activeSelectionCoach.clubs
+361                       .filter((club) => club.hasCourts && club.zoneLabel === selectedZone && (selectedClubId === "all" || club.id === selectedClubId))
+362                       .map((club) => {
+363                         const href = buildSlotHref(selectedSlot, activeSelectionCoach, club.id, calendar);
 332
-333                           return (
-334

-335
{club.name ?? "ATC no disponible"}

-336

-337                                 {href ? (
-338                                   <Button asChild className="h-9 rounded-lg text-xs">
-339                                     <Link href={href}>Solicitar con este club</Link>
-340                                   </Button>
-341                                 ) : (
-342                                   Sin oferta activa para este coach
-343                                 )}
-344

+365                         return (
+366

+367
{club.name ?? "ATC no disponible"}

+368

+369                               {href ? (
+370                                 <Button asChild className="h-9 rounded-lg text-xs">
+371                                   <Link href={href}>Solicitar con este club</Link>
+372                                 </Button>
+373                               ) : (
+374                                 Sin oferta activa para este coach
+375                               )}
 345

-346                           );
-347                         })
-348                     )}
-349

-350                 ) : (
-351
Elige un coach para continuar.

-352                 )}
-353

-354

+377

+378                         );
+379                       })
+380                   )}
+381

+382               ) : (
+383
Elige un coach para continuar.

+384               )}
+385

 355

-356

+387

 357       ) : null}
 358

 359   );
 360 }