@seatsyoo/react 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tikyoo, S.L.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,350 @@
1
- # Temporary Holding Version
1
+ # @seatsyoo/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ El visor de mapas de asientos de **Seatsyoo** como componente de React: el comprador ve el plano del recinto,
4
+ elige sus butacas (quedan retenidas a su nombre mientras paga) y ve en tiempo real lo que otros compran. Usa el
5
+ visor de [`@seatsyoo/visor`](https://www.npmjs.com/package/@seatsyoo/visor) y trae sus tipos de TypeScript.
6
+
7
+ Funciona con **React 18 y 19**, con renderizado en el servidor (Next.js, Remix, Astro…) y con `StrictMode`. Las
8
+ props opcionales admiten `undefined` (como las de un `<div>`): con `exactOptionalPropertyTypes`, un componente que lo
9
+ envuelva puede pasarle las suyas tal cual.
10
+
11
+ *(English summary below.)*
12
+
13
+ ## Instalación
14
+
15
+ ```sh
16
+ npm install @seatsyoo/react
17
+ # o: pnpm add @seatsyoo/react · yarn add @seatsyoo/react
18
+ ```
19
+
20
+ `react` lo pones tú (es una dependencia «peer»); `@seatsyoo/visor` se instala solo.
21
+
22
+ ## Uso
23
+
24
+ ```tsx
25
+ import { SeatsyooChart, type SeatingChart, type SelectedObject } from "@seatsyoo/react";
26
+ import { useRef, useState } from "react";
27
+
28
+ export function Entradas() {
29
+ const plano = useRef<SeatingChart>(null);
30
+ const [seleccion, setSeleccion] = useState<SelectedObject[]>([]);
31
+
32
+ async function comprar() {
33
+ // A tu servidor, solo el evento y el token de retención, nunca la lista de lo elegido: pregunta a Seatsyoo qué
34
+ // retiene ese token, cobra eso y, con el pago confirmado, lo reserva (ver «Vender»).
35
+ await fetch("/caja", {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify({ evento: "hamlet-2026-11-20", holdToken: plano.current?.holdToken }),
39
+ });
40
+ }
41
+
42
+ return (
43
+ <>
44
+ <SeatsyooChart
45
+ ref={plano}
46
+ server="https://seatsyoo.example" // la dirección de Seatsyoo de tu cuenta
47
+ publicKey="pk_tu_clave_publica" // la clave pública de tu espacio
48
+ event="hamlet-2026-11-20" // el evento que se vende
49
+ pricing={{ platea: 30, anfiteatro: 22 }} // opcional: tus precios (Seatsyoo no guarda precios)
50
+ style={{ height: 600 }} // el visor ocupa todo el <div>: dale altura
51
+ onChange={setSeleccion}
52
+ onHoldExpired={() => alert("Se ha acabado el tiempo: vuelve a elegir.")}
53
+ />
54
+ <button disabled={seleccion.length === 0} onClick={comprar}>Comprar</button>
55
+ </>
56
+ );
57
+ }
58
+ ```
59
+
60
+ Para probarlo en un proyecto nuevo de Vite:
61
+
62
+ ```sh
63
+ npm create vite@latest entradas -- --template react-ts
64
+ cd entradas
65
+ npm install @seatsyoo/react
66
+ # pon el componente Entradas de arriba en src/App.tsx y arranca con:
67
+ npm run dev
68
+ ```
69
+
70
+ ### Props
71
+
72
+ Todas las opciones del visor (`server`, `publicKey`, `event`, `season`, `link`, `document`, `language`, `theme`,
73
+ `maxSelected`, `pricing`, `channels`, `holdToken`, `mode`, `statusStyles`, `marks`, `view3d`…) y sus avisos (`onReady`, `onChange`,
74
+ `onSelect`, `onDeselect`, `onHoldExpired`, `onHoldTokenChange`, `onFloorChange`, `onError`), con los mismos nombres
75
+ y tipos que en `@seatsyoo/visor`, salvo `container`, que es el propio componente. Además, `className`, `style` e `id`
76
+ del `<div>` que lo contiene.
77
+
78
+ Cómo reacciona a los cambios de props:
79
+
80
+ | Qué cambia | Qué pasa |
81
+ | --- | --- |
82
+ | `server`, `publicKey`, `event`, `link`, `document`, `season`, `seasonEvents`, `channels`, `pricing`, `currency`, `locale`, `language`, `maxSelected`, `session`, `holdToken`, `mode`, `showLegend`, `showSelectionBar`, `floor`, `sectors`, `view3d`, `entrance`, `texts` | Se desmonta el visor y se monta otro con lo nuevo. Los arrays y objetos se comparan **por su valor**: `channels={["web"]}` en cada render no lo vuelve a montar. El plano (`document`), no: ver abajo. |
83
+ | `theme`, `colorBy` | Se aplica al visor montado (`setTheme`, `setColorBy`), sin volver a montarlo. |
84
+ | `statusStyles`, `marks` | Solo con `mode="manager"` (un panel de gestión): cómo se ve cada estado y las marcas sobre plazas libres (ver «Estados propios y marcas»). Se aplican al visor montado (`setStatusStyles`, `setMarks`), sin volver a montarlo, solo si cambia su valor: se comparan como datos (da igual el prototipo, y una clave que vale `undefined` es como si no estuviera), así que un objeto nuevo con lo mismo en cada render no hace nada. Lo que cambies en el sitio en el objeto que ya pasaste se aplica en el siguiente render del componente, no antes (cambiar el estado y llamar a `setState` con el mismo objeto no vuelve a pintar): mejor pasa un objeto nuevo, como con el estado de React. Quitarlas es dejar el visor sin estilos ni marcas. |
85
+ | `onChange`, `onSelect`… | Nada: el visor llama siempre a la última función que hayas pasado. No hace falta `useCallback`. |
86
+ | `load3d` | Se usa la última función; ponerlo o quitarlo vuelve a montar. |
87
+
88
+ El plano (`document`) no se copia, porque puede ser muy grande: un plano nuevo con lo mismo no vuelve a montar el
89
+ visor, pero si cambias el que ya pasaste (en el sitio), no se nota, ni aunque después pases una copia. Pasa un plano
90
+ nuevo sin tocar el anterior, como harías con el estado de React.
91
+
92
+ **Lo que el comprador tiene retenido al volver a montar.** Si es en el mismo evento y con `session: "continue"` (por
93
+ defecto), el visor nuevo lo recupera. Si el nuevo no puede recuperarlo (has cambiado de evento o de temporada, de
94
+ `server` o de `publicKey`, usas `session: "start"`, le pasas un `holdToken` o el modo `"manager"`), el componente lo
95
+ suelta antes, como `clearSelection()`: te llegan `onDeselect` y `onChange([])`, y esas butacas quedan libres para
96
+ los demás en lugar de bloqueadas hasta que caduque la retención. Así, para cambiar de función basta con cambiar
97
+ `event`.
98
+
99
+ Lo retenido con un `holdToken` tuyo (creado por tu servidor) no se suelta nunca: es de tu token, y tu servidor lo
100
+ reserva o lo suelta. Es la forma de llevar un carrito con varias funciones. Al desmontar el componente (por ejemplo,
101
+ al pasar a la página de pago) tampoco se suelta nada: tu servidor lo reserva con el token, o el comprador lo
102
+ recupera al volver.
103
+
104
+ ### Vender
105
+
106
+ A tu servidor la página le manda el evento y el `holdToken`, nada más: nunca la lista de lo elegido. En la caja, tu
107
+ servidor pregunta a Seatsyoo qué retiene ese token (`GET /api/v1/events/{evento}/statuses?holdToken=…`: lo suyo lleva
108
+ `mine: true` y, en una zona de pie, `mq` con las plazas del token; `mq` solo viene si en la zona hay más de una plaza
109
+ ocupada, contando las de otros compradores, y si no viene es 1), calcula el importe con sus precios (precio × plazas:
110
+ una mesa entera cuenta todas sus sillas, como el total del visor) y guarda esa lista con el pedido, cada objeto con su
111
+ cantidad (`{ key, quantity: mq ?? 1 }`). Con el pago confirmado, reserva exactamente lo guardado
112
+ (`POST /api/v1/events/{evento}/actions/book` con `holdToken` y tu número de pedido, `orderId`), sin volver a preguntar.
113
+
114
+ No reserves una lista que mande la página: `book` reserva también objetos libres, así que cualquiera podría añadirle
115
+ butacas que no ha retenido, y saltarse los topes de retención. Es lo que hace la tienda de ejemplo de `@seatsyoo/node`
116
+ (`ejemplo/tienda.ts`: `POST /caja` con `{ evento, holdToken }` y `POST /pagar` con `{ holdToken }`) y lo que cuenta la
117
+ guía de integración de la documentación (**/docs/integracion**).
118
+
119
+ ### Precios
120
+
121
+ Seatsyoo no guarda precios: los pone tu web. Pásalos con `pricing`, por clave de categoría (`{ platea: 30 }`), con
122
+ `currency` y `locale` para escribirlos: el visor los enseña en la ficha de cada butaca, en la leyenda y en el total,
123
+ y salen en `price` de cada objeto de `onChange`, `onSelect`… (sin `pricing`, `price` es `null`). `price` es lo que
124
+ cuesta cada unidad de `quantity` (por plaza; una mesa entera, con todas sus sillas), así que `price × quantity` es lo
125
+ que suma el total del visor. Cambiar `pricing` vuelve a montar el visor (en el mismo evento, el comprador no pierde lo
126
+ elegido). Lo que se cobra lo calcula tu servidor con sus precios, no con lo que diga la página.
127
+
128
+ ### La ref
129
+
130
+ Con `ref` tienes el visor montado (un `SeatingChart`): `getSelection()`, `holdToken`, `holdExpiresAt`,
131
+ `clearSelection()`, `selectBestAvailable(n)`, `setFloor(id)`, `show3d()`, `zoomToFit()`, `refresh()`… Vale `null`
132
+ mientras no hay visor: en el servidor, antes de montarlo y después de desmontarlo. Se pone en el mismo efecto que
133
+ monta el visor: tus efectos (`useEffect`) y `onReady` ya lo tienen. Si cambia una opción que lo vuelve a montar,
134
+ la ref pasa a ser el visor nuevo antes de que se ejecuten tus efectos de ese cambio.
135
+
136
+ ### Errores
137
+
138
+ Lo que falla al cargar o al retener llega a `onError` como `{ code, message }` (sin `onError`, a la consola). Si
139
+ faltan opciones para montarlo (qué enseñar, la clave pública…), el componente no lanza: llama a `onError` con
140
+ `code: "invalid-options"` (sin `onError`, lo escribe en la consola) y lo monta en cuanto las opciones valen.
141
+
142
+ ### Topes de retención
143
+
144
+ Lo que el comprador retiene desde el visor tiene topes en el servidor. Al pasar uno, la butaca no se retiene y llega
145
+ el error a `onError`:
146
+
147
+ | Código | Qué tope |
148
+ | --- | --- |
149
+ | `hold-limit` (409) | Las «Plazas por comprador» de tu espacio (`maxHoldPerToken`, 10 si no se cambia; lo da `GET /workspace`), por token y evento. Una mesa o un palco que se venden enteros cuentan todas sus plazas. |
150
+ | `hold-limit-ip` (409) | Lo retenido a la vez desde una misma conexión (IP) en un evento, sumando todas sus compras en curso: el 10 % del aforo, hasta 200, y nunca menos que `maxHoldPerToken`. |
151
+ | `hold-rate-limited` (429) | Lo que retiene una misma IP en poco tiempo: como mucho 600 plazas cada 10 minutos (soltar no cuenta). |
152
+
153
+ - `maxSelected` cuenta una mesa o un palco enteros como 1, y el servidor, con todas sus plazas: con
154
+ `maxSelected` y `maxHoldPerToken` en 10, dos mesas de 8 dan `hold-limit`. No pongas `maxSelected` por encima de
155
+ `maxHoldPerToken` y, si vendes mesas o palcos enteros, cuenta con ese error o sube el ajuste.
156
+ - `selectBestAvailable(n)` pide de 1 a 100 plazas, y nunca más que `maxHoldPerToken` (si no, `hold-limit` antes de
157
+ buscar).
158
+ - Un `holdToken` que crea tu servidor con la clave secreta (`POST /api/v1/hold-tokens`) no lleva IP: no cuenta para
159
+ `hold-limit-ip`. Lo que el comprador retiene con él desde el visor sí cuenta para `hold-limit` y `hold-rate-limited`.
160
+ - Lo que hace tu servidor con la clave secreta (retener, reservar) no tiene ninguno de estos topes.
161
+
162
+ ### Estados propios y marcas (modo «manager»)
163
+
164
+ En un panel de gestión (modo `"manager"`), `statusStyles` dice cómo se ve cada estado y `marks`, qué plazas libres
165
+ llevan una marca. Se sacan de la API con la clave secreta, así que los pide tu servidor y se los da a la página:
166
+
167
+ - `marks` es tal cual el `marks` de `GET /api/v1/events/{evento}/marks` (`{ "marks": { "Platea-A-1":
168
+ "visibilidad-reducida" } }`): objeto → clave del estado.
169
+ - `statusStyles` sale del catálogo de `GET /api/v1/statuses` (`{ statuses, system }`; con `?event={evento}`, solo los
170
+ que valen para ese evento), pero no es el mismo formato: por la clave de cada estado, `{ name, color, icon }`, y el
171
+ icono cambia de forma. `{ type: "text", text }` pasa a `{ text }` y `{ type: "image", src }`, a `{ image: src }`;
172
+ `{ type: "preset", name }` es de la colección del panel, que no se publica: sin icono, o con un trazado tuyo de 24×24
173
+ (`{ path }`). De `system` (los estados de base que tu espacio ha cambiado), solo los que traen `color`, y nunca
174
+ `free`. Un estado de base con `color: null` no pasa, así que el visor lo pinta como siempre y también pierde el
175
+ nombre y el icono propios que le hayas puesto, aunque el panel sí los enseñe (en el modo «manager», «Reservado» en
176
+ vez de tu «Vendida»). Si los quieres, ponle color propio en el panel. Pasar el catálogo tal cual no compila en
177
+ TypeScript y, en JavaScript, rompe el pintado.
178
+
179
+ ```ts
180
+ import type { StatusStyle } from "@seatsyoo/react";
181
+
182
+ /** Un icono del catálogo de estados (GET /api/v1/statuses). */
183
+ type IconoDeEstado = { type: "preset"; name: string } | { type: "text"; text: string } | { type: "image"; src: string };
184
+ /** Lo que responde GET /api/v1/statuses (lo que hace falta aquí). */
185
+ interface Catalogo {
186
+ statuses: { key: string; name: string; color: string; icon: IconoDeEstado | null }[];
187
+ system: { key: string; name: string | null; color: string | null; icon: IconoDeEstado | null }[];
188
+ }
189
+
190
+ function estilo(name: string | null, color: string, icon: IconoDeEstado | null): StatusStyle {
191
+ // «preset»: sin icono (o tu trazado de 24×24, { path: "M4 12h16" }).
192
+ const forma = icon?.type === "text" ? { text: icon.text } : icon?.type === "image" ? { image: icon.src } : null;
193
+ return { ...(name ? { name } : {}), color, ...(forma ? { icon: forma } : {}) };
194
+ }
195
+
196
+ /** De GET /api/v1/statuses a la opción statusStyles. */
197
+ export function estilosDeEstados({ statuses, system }: Catalogo): Record<string, StatusStyle> {
198
+ const estilos: Record<string, StatusStyle> = {};
199
+ for (const s of statuses) estilos[s.key] = estilo(s.name, s.color, s.icon);
200
+ // De los de base, solo los que tienen color propio: sin él se quedan como los pinta el visor, también con su nombre
201
+ // y su icono de siempre (los tuyos se pierden). «free» no se pinta con estilo.
202
+ for (const s of system) if (s.key !== "free" && s.color) estilos[s.key] = estilo(s.name, s.color, s.icon);
203
+ return estilos;
204
+ }
205
+ ```
206
+
207
+ Después, `<SeatsyooChart mode="manager" … statusStyles={estilos} marks={marcas} />`, con `estilos =
208
+ estilosDeEstados(catalogo)` y las marcas tal cual; para cambiarlos, pasa objetos nuevos.
209
+
210
+ ### Renderizado en el servidor y StrictMode
211
+
212
+ El visor se monta en un efecto: en el servidor el componente pinta solo un `<div>` vacío (con tu `className`,
213
+ `style` e `id`) y en el navegador se hidrata y monta el visor. Con `StrictMode`, React monta, desmonta y vuelve a
214
+ montar: el primer visor se destruye al momento (sin avisos ni retenciones) y queda uno.
215
+
216
+ En Next.js (App Router), úsalo dentro de un componente con `"use client"`.
217
+
218
+ ### La vista 3D
219
+
220
+ No va en el paquete: el visor la descarga de `${server}/sdk/butacas-3d.js` cuando alguien la abre. Si tu web
221
+ tiene una política de seguridad de contenidos (CSP), añade esa dirección a `script-src`, o pasa `view3d={false}`.
222
+
223
+ ## El diseñador de planos
224
+
225
+ `<SeatsyooDesigner>` abre en tu web el diseñador de planos de Seatsyoo, para que tus clientes dibujen sus recintos.
226
+ Tu servidor abre una sesión para un plano con su clave secreta (`POST /api/v1/charts/{plano}/designer-sessions`, o
227
+ `charts.designerSession` de `@seatsyoo/node`) y le da el token a tu web:
228
+
229
+ ```tsx
230
+ import { SeatsyooDesigner } from "@seatsyoo/react";
231
+
232
+ // Tu servidor abre la sesión y te da el token (aquí, una ruta tuya).
233
+ const pedirToken = (): Promise<string> =>
234
+ fetch("/mi-api/disenador/teatro", { method: "POST" }).then((r) => r.json()).then((s: { token: string }) => s.token);
235
+
236
+ function Disenador({ token, cerrar }: { token: string; cerrar: () => void }) {
237
+ return (
238
+ <SeatsyooDesigner
239
+ server="https://seatsyoo.example"
240
+ token={token}
241
+ refreshToken={pedirToken}
242
+ style={{ height: "80vh" }}
243
+ onPublish={({ version }) => console.log("Publicada la versión", version)}
244
+ onClose={cerrar}
245
+ />
246
+ );
247
+ }
248
+ ```
249
+
250
+ Props: `server`, `token`, `language`, `refreshToken` (pide a tu servidor otro token del mismo plano antes de que
251
+ caduque: sin ella, al caducar deja de guardar), y los avisos `onReady`, `onSave`, `onPublish`, `onClose` (sin ella no
252
+ sale «←») y `onError`; más `className`, `style` e `id` del `<div>`, que necesita alto. Un `token` nuevo se le manda sin
253
+ volver a montarlo; cambiar `server` o `language`, o pasar o quitar `refreshToken` u `onClose`, lo vuelve a montar. Con
254
+ `ref` (`useRef<EmbeddedDesigner>(null)`) tienes el diseñador: `setToken()`, `iframe`. En el servidor (SSR) pinta solo el
255
+ `<div>`.
256
+
257
+ La referencia completa de opciones, avisos y métodos está en la documentación de Seatsyoo (**/docs/visor**,
258
+ **/docs/disenador** y **/docs/frameworks**).
259
+
260
+ ## Licencia
261
+
262
+ MIT (archivo `LICENSE`). El visor del que depende, `@seatsyoo/visor`, tiene su propia licencia: se usa gratis para
263
+ integrar Seatsyoo.
264
+
265
+ ---
266
+
267
+ ## English summary
268
+
269
+ `@seatsyoo/react` is the **Seatsyoo** seating chart viewer as a React component (React 18 and 19, SSR-safe,
270
+ StrictMode-safe). It wraps `@seatsyoo/visor` and ships TypeScript types.
271
+
272
+ ```sh
273
+ npm install @seatsyoo/react
274
+ ```
275
+
276
+ ```tsx
277
+ import { SeatsyooChart, type SeatingChart } from "@seatsyoo/react";
278
+
279
+ const chart = useRef<SeatingChart>(null);
280
+
281
+ <SeatsyooChart
282
+ ref={chart}
283
+ server="https://seatsyoo.example"
284
+ publicKey="pk_your_public_key"
285
+ event="hamlet-2026-11-20"
286
+ language="en"
287
+ style={{ height: 600 }}
288
+ onChange={(selection) => console.log(selection.map((o) => o.key))}
289
+ />;
290
+ // At checkout, send only the event and chart.current.holdToken to your server (never the selected keys).
291
+ ```
292
+
293
+ - **Embedded designer**: `<SeatsyooDesigner server token refreshToken onPublish onClose style={{ height: "80vh" }} />`
294
+ opens the Seatsyoo chart designer in your website, with a session token your server opens with its secret key
295
+ (`POST /api/v1/charts/{chart}/designer-sessions`).
296
+ - **Props**: every viewer option except `container`, plus `className`, `style` and `id` for the wrapping `<div>`.
297
+ - **Re-mounting**: changing `server`, `publicKey`, `event`, `link`, `document`, `season`, `seasonEvents`,
298
+ `channels`, `pricing`, `currency`, `locale`, `language`, `maxSelected`, `session`, `holdToken`, `mode`,
299
+ `showLegend`, `showSelectionBar`, `floor`, `sectors`, `view3d`, `entrance` or `texts` destroys the viewer and mounts a new one (arrays
300
+ and objects are compared by value). `theme` and `colorBy` are applied live (`setTheme`, `setColorBy`), and so
301
+ are `statusStyles` and `marks` (`"manager"` mode: `setStatusStyles`, `setMarks`), only when their value changes
302
+ (compared as data: the prototype doesn't matter and a key set to `undefined` counts as missing). A change made in
303
+ place is applied on the component's next render, not before: better pass a new object.
304
+ - **Prices**: Seatsyoo does not store prices. Pass yours with `pricing` (per category key, with `currency` and
305
+ `locale`): the viewer shows them and they come in `price` of each selected object (`null` without `pricing`).
306
+ `price` is what each unit of `quantity` costs (per place; a whole table, all its chairs), so `price × quantity` adds
307
+ up to the viewer's total.
308
+ - **Selling**: the page sends your server the event and the `holdToken`, nothing else. At checkout your server asks
309
+ Seatsyoo what that token holds (`GET /api/v1/events/{event}/statuses?holdToken=…`: `mine: true`, plus `mq` in a
310
+ standing area with the token's places, which only comes when more than one place is taken there, counting other
311
+ buyers'; when it is missing, it is 1), prices it (price × places: a whole table counts all its chairs, as in the
312
+ viewer's total) and stores that list with the order, each object with an explicit quantity
313
+ (`{ key, quantity: mq ?? 1 }`). Once paid, it books exactly that list (`POST …/actions/book` with
314
+ `holdToken` and your `orderId`). Never book a list sent by the page: `book` also books free objects, so anyone could
315
+ add seats they never held. The `@seatsyoo/node` example store (`ejemplo/tienda.ts`) does this; see
316
+ **/en/docs/integration**.
317
+ - **Hold limits** (errors in `onError`): `hold-limit` (409, the workspace's "places per buyer", `maxHoldPerToken`, 10
318
+ by default, per token and event; a whole table or box counts all its places), `hold-limit-ip` (409, per connection
319
+ and event: 10% of the capacity, up to 200, never below `maxHoldPerToken`) and `hold-rate-limited` (429, 600 places
320
+ held per IP every 10 minutes). `maxSelected` counts a whole table or box as 1, so keep it at or below
321
+ `maxHoldPerToken` and expect `hold-limit` with whole tables. `selectBestAvailable(n)` takes 1 to 100 and never more
322
+ than `maxHoldPerToken`. A `holdToken` your server creates with the secret key has no IP, so it does not count for
323
+ `hold-limit-ip`, but what the buyer holds with it from the viewer still counts for `hold-limit` and
324
+ `hold-rate-limited`. Your server's own calls with the secret key have none of these limits.
325
+ - **Custom statuses and marks** (`"manager"` mode): `marks` is the `marks` of `GET /api/v1/events/{event}/marks` as
326
+ is. `statusStyles` is not the shape of `GET /api/v1/statuses`: per status key, `{ name, color, icon }` with the icon
327
+ converted (`text` → `{ text }`, `image` → `{ image: src }`, `preset` → no icon or your own 24×24 `{ path }`); from
328
+ `system`, only entries with a `color`, never `free` (one with `color: null` stays exactly as the viewer draws it:
329
+ its own name and icon are lost too, even if the panel shows them; give it its own colour if you want them). Both
330
+ need the secret key: your server fetches them. The `estilosDeEstados` function above does the conversion.
331
+ - **Holds on re-mount**: same event with `session: "continue"` (default) → the new viewer recovers the buyer's
332
+ held seats. If it can't (another event or season, `server`, `publicKey`, `session: "start"`, a `holdToken`,
333
+ `"manager"` mode), the component releases them first, like `clearSelection()` (you get `onDeselect` and
334
+ `onChange([])`): switching shows is just changing `event`. Seats held with your own `holdToken` are never
335
+ released (your server books or releases them, e.g. a multi-show cart), and nothing is released on unmount.
336
+ - **`document`** is not copied: changing it in place goes unnoticed; pass a new object without mutating the old one.
337
+ - **Callbacks** (`onChange`, `onSelect`, `onReady`, `onError`…) always call the latest function you passed; no
338
+ `useCallback` needed.
339
+ - **ref** gives the mounted `SeatingChart` (or `null` on the server, before mounting and after unmounting); it is
340
+ set in the same effect that mounts the viewer, so your effects and `onReady` already see it.
341
+ - **Types**: optional props also accept `undefined`, so wrappers can forward their own optional props even with
342
+ `exactOptionalPropertyTypes`.
343
+ - **Invalid options** don't throw: `onError` gets `{ code: "invalid-options", message }`.
344
+ - **SSR**: renders an empty `<div>` on the server and mounts the viewer in an effect in the browser. In Next.js App
345
+ Router, use it from a `"use client"` component.
346
+ - **3D view**: downloaded from `${server}/sdk/butacas-3d.js` when opened (allow it in your CSP `script-src`, or
347
+ pass `view3d={false}`).
348
+ - Full reference: the Seatsyoo docs at **/en/docs/seat-map** and **/en/docs/frameworks**.
349
+
350
+ License: MIT. The viewer it depends on, `@seatsyoo/visor`, has its own license (free to use to integrate Seatsyoo).
@@ -0,0 +1,36 @@
1
+ import { type RenderOptions, type SeatingChart } from "@seatsyoo/visor";
2
+ import { type CSSProperties, type ForwardRefExoticComponent, type RefAttributes } from "react";
3
+ /**
4
+ * Las opciones opcionales admiten también `undefined` (como un <div>): con `exactOptionalPropertyTypes`, un
5
+ * componente que envuelve a este puede pasarle sus props opcionales tal cual (`event={props.event}`). `undefined`
6
+ * es lo mismo que no pasarla.
7
+ */
8
+ type AdmitenUndefined<T> = {
9
+ [K in keyof T]: {} extends Pick<T, K> ? T[K] | undefined : T[K];
10
+ };
11
+ /** Props de <SeatsyooChart>: las opciones del visor (sin `container`: es el propio componente) y las del <div>. */
12
+ export interface SeatsyooChartProps extends AdmitenUndefined<Omit<RenderOptions, "container">> {
13
+ /** Clase del <div> que contiene el visor. El visor ocupa todo su tamaño: dale altura. */
14
+ className?: string | undefined;
15
+ /** Estilo del <div> que contiene el visor (p. ej. `{ height: 600 }`). */
16
+ style?: CSSProperties | undefined;
17
+ id?: string | undefined;
18
+ }
19
+ /**
20
+ * El visor de mapas de asientos de Seatsyoo.
21
+ *
22
+ * ```tsx
23
+ * <SeatsyooChart
24
+ * server="https://seatsyoo.example"
25
+ * publicKey="pk_tu_clave_publica"
26
+ * event="hamlet-2026-11-20"
27
+ * style={{ height: 600 }}
28
+ * onChange={(seleccion) => setSeleccion(seleccion)}
29
+ * />
30
+ * ```
31
+ *
32
+ * Con `ref` (`useRef<SeatingChart>(null)`) tienes el visor montado: `getSelection()`, `holdToken`,
33
+ * `clearSelection()`… (`null` mientras no hay ninguno).
34
+ */
35
+ export declare const SeatsyooChart: ForwardRefExoticComponent<SeatsyooChartProps & RefAttributes<SeatingChart>>;
36
+ export {};
@@ -0,0 +1,161 @@
1
+ // <SeatsyooChart>: el visor de Seatsyoo como componente de React (18 y 19).
2
+ //
3
+ // - Se monta en un efecto: en el servidor (SSR) solo se pinta el <div> vacío, y con StrictMode (que monta,
4
+ // desmonta y vuelve a montar) el primer visor se destruye antes de hacer nada visible: queda uno.
5
+ // - Al desmontar, destroy().
6
+ // - Si cambia una opción de identidad (OPCIONES_DE_IDENTIDAD, por valor), se destruye y se monta otro. Si el nuevo
7
+ // no va a recuperar lo que el comprador tenía retenido (otro evento…), antes se suelta (sueltaAlCambiar).
8
+ // - theme y colorBy se aplican al visor montado (setTheme, setColorBy); statusStyles y marks (modo «manager»),
9
+ // también, comparadas por valor (setStatusStyles, setMarks).
10
+ // - Los avisos (onChange, onSelect…) llaman siempre a la última función recibida, sin volver a montar.
11
+ // - La ref se pone en el mismo efecto que monta el visor (y se quita al destruirlo): el efecto del padre y onReady
12
+ // ya la tienen.
13
+ import { render } from "@seatsyoo/visor";
14
+ import { createElement, forwardRef, useEffect, useLayoutEffect, useRef, version, } from "react";
15
+ import { aplicarEnVivo, avisarEnConsola, enVivoDe, errorDeOpciones, identidadDe, igual, opcionesEnVivo, sinIndefinidos, sueltaAlCambiar, } from "./opciones.js";
16
+ // useLayoutEffect no hace nada en el servidor (y React 18 avisa): allí, useEffect, que tampoco se ejecuta.
17
+ const useEfectoAntesDePintar = typeof document === "undefined" ? useEffect : useLayoutEffect;
18
+ /**
19
+ * La identidad de este render, o la del último render confirmado si es igual por valor: así un array nuevo
20
+ * con lo mismo no cambia la dependencia del efecto que monta. Solo se lee la referencia durante el render; se
21
+ * escribe después de confirmar (un render descartado no cuenta).
22
+ */
23
+ function useIdentidadEstable(actual) {
24
+ const confirmada = useRef(actual);
25
+ const estable = igual(confirmada.current, actual) ? confirmada.current : actual;
26
+ useEffect(() => {
27
+ confirmada.current = estable;
28
+ });
29
+ return estable;
30
+ }
31
+ /** React 19 llama a la limpieza que devuelve una ref de función en lugar de llamarla con null; React 18, no. */
32
+ const REFS_CON_LIMPIEZA = Number(version.split(".")[0]) >= 19;
33
+ /**
34
+ * Pone el visor en la ref de quien usa el componente (un objeto de useRef o una función) y devuelve cómo quitarlo,
35
+ * como lo haría React con la ref de un elemento.
36
+ */
37
+ function ponerEnRef(ref, plano) {
38
+ if (typeof ref === "function") {
39
+ const limpieza = ref(plano);
40
+ return REFS_CON_LIMPIEZA && typeof limpieza === "function" ? limpieza : () => ref(null);
41
+ }
42
+ if (ref)
43
+ ref.current = plano;
44
+ return () => {
45
+ if (ref && ref.current === plano)
46
+ ref.current = null;
47
+ };
48
+ }
49
+ function SeatsyooChartConRef(props, ref) {
50
+ const { className, style, id, ...opciones } = props;
51
+ const contenedor = useRef(null);
52
+ // Las últimas opciones recibidas: los avisos y el montaje leen de aquí.
53
+ const ultimas = useRef(opciones);
54
+ // El visor montado ahora mismo y las opciones en vivo con que se montó o se dejó (para setTheme, setColorBy…).
55
+ const visor = useRef(null);
56
+ // La ref de quien usa el componente en la que está puesto el visor, y cómo quitarlo de ella.
57
+ const enRef = useRef(null);
58
+ // false desde que React quita (o esconde) el componente: el visor se destruye entonces sin soltar nada, para
59
+ // que lo recupere el que se monte después (o tu servidor lo reserve con el token).
60
+ const presente = useRef(false);
61
+ useEfectoAntesDePintar(() => {
62
+ ultimas.current = opciones;
63
+ });
64
+ useEfectoAntesDePintar(() => {
65
+ presente.current = true;
66
+ return () => {
67
+ presente.current = false;
68
+ };
69
+ }, []);
70
+ function quitarDeRef() {
71
+ const actual = enRef.current;
72
+ enRef.current = null;
73
+ actual?.quitar();
74
+ }
75
+ function ponerVisorEnRef(r, plano) {
76
+ if (enRef.current?.ref === r && enRef.current.plano === plano)
77
+ return;
78
+ quitarDeRef();
79
+ enRef.current = { ref: r, plano, quitar: ponerEnRef(r, plano) };
80
+ }
81
+ const identidad = useIdentidadEstable(identidadDe(opciones));
82
+ useEffect(() => {
83
+ const div = contenedor.current;
84
+ if (!div)
85
+ return;
86
+ const o = ultimas.current;
87
+ const load3d = o.load3d;
88
+ // Con copias de statusStyles y marks: lo que se compara después y lo que tiene el visor no es el objeto de la página.
89
+ const enVivo = enVivoDe(o);
90
+ let nuevo;
91
+ try {
92
+ nuevo = render({
93
+ ...sinIndefinidos(o),
94
+ ...opcionesEnVivo(o, enVivo),
95
+ container: div,
96
+ onReady: (c) => ultimas.current.onReady?.(c),
97
+ onChange: (s) => ultimas.current.onChange?.(s),
98
+ onSelect: (x) => ultimas.current.onSelect?.(x),
99
+ onDeselect: (x) => ultimas.current.onDeselect?.(x),
100
+ onHoldExpired: () => ultimas.current.onHoldExpired?.(),
101
+ onHoldTokenChange: (t) => ultimas.current.onHoldTokenChange?.(t),
102
+ onFloorChange: (f) => ultimas.current.onFloorChange?.(f),
103
+ onError: (e) => (ultimas.current.onError ?? avisarEnConsola)(e),
104
+ // Solo si hay load3d (si no, el visor pide la 3D al servidor); quitarlo o ponerlo cambia la identidad.
105
+ ...(load3d ? { load3d: () => (ultimas.current.load3d ?? load3d)() } : {}),
106
+ });
107
+ }
108
+ catch (err) {
109
+ const error = errorDeOpciones(err);
110
+ if (o.onError)
111
+ o.onError(error);
112
+ else
113
+ console.error("[seatsyoo]", error.message);
114
+ return;
115
+ }
116
+ visor.current = { plano: nuevo, enVivo };
117
+ ponerVisorEnRef(ref, nuevo);
118
+ return () => {
119
+ // Si es porque cambia una opción de identidad (el componente sigue), las opciones nuevas ya están en
120
+ // `ultimas` (se guardan antes de pintar, y este efecto va después).
121
+ if (presente.current && sueltaAlCambiar(identidad, identidadDe(ultimas.current)))
122
+ void nuevo.clearSelection();
123
+ if (enRef.current?.plano === nuevo)
124
+ quitarDeRef();
125
+ nuevo.destroy();
126
+ if (visor.current?.plano === nuevo)
127
+ visor.current = null;
128
+ };
129
+ }, [identidad]);
130
+ // Si cambia la ref (otra función u otro objeto), el visor pasa a la nueva y se quita de la anterior.
131
+ useEffect(() => {
132
+ if (visor.current)
133
+ ponerVisorEnRef(ref, visor.current.plano);
134
+ }, [ref]);
135
+ // Después del efecto que monta: si en el mismo cambio se montó otro, ya lleva lo nuevo y no se toca. En cada render
136
+ // (sin dependencias): statusStyles y marks se comparan por valor con lo aplicado, también si se cambian en el sitio.
137
+ useEffect(() => {
138
+ const v = visor.current;
139
+ if (v)
140
+ aplicarEnVivo(v.plano, v.enVivo, opciones);
141
+ });
142
+ return createElement("div", { ref: contenedor, className, style, id });
143
+ }
144
+ /**
145
+ * El visor de mapas de asientos de Seatsyoo.
146
+ *
147
+ * ```tsx
148
+ * <SeatsyooChart
149
+ * server="https://seatsyoo.example"
150
+ * publicKey="pk_tu_clave_publica"
151
+ * event="hamlet-2026-11-20"
152
+ * style={{ height: 600 }}
153
+ * onChange={(seleccion) => setSeleccion(seleccion)}
154
+ * />
155
+ * ```
156
+ *
157
+ * Con `ref` (`useRef<SeatingChart>(null)`) tienes el visor montado: `getSelection()`, `holdToken`,
158
+ * `clearSelection()`… (`null` mientras no hay ninguno).
159
+ */
160
+ export const SeatsyooChart = forwardRef(SeatsyooChartConRef);
161
+ SeatsyooChart.displayName = "SeatsyooChart";
@@ -0,0 +1,33 @@
1
+ import { type EmbeddedDesigner, type EmbeddedDesignerOptions } from "@seatsyoo/visor";
2
+ import { type CSSProperties, type ForwardRefExoticComponent, type RefAttributes } from "react";
3
+ /** Como en <SeatsyooChart>: las opciones opcionales admiten también `undefined`, que es lo mismo que no pasarlas. */
4
+ type AdmitenUndefined<T> = {
5
+ [K in keyof T]: {} extends Pick<T, K> ? T[K] | undefined : T[K];
6
+ };
7
+ /** Props de <SeatsyooDesigner>: las opciones del diseñador (sin `container`: es el propio componente) y las del <div>. */
8
+ export interface SeatsyooDesignerProps extends AdmitenUndefined<Omit<EmbeddedDesignerOptions, "container">> {
9
+ /** Clase del <div> que contiene el diseñador. Ocupa todo su tamaño: dale altura. */
10
+ className?: string | undefined;
11
+ /** Estilo del <div> que contiene el diseñador (p. ej. `{ height: "80vh" }`). */
12
+ style?: CSSProperties | undefined;
13
+ id?: string | undefined;
14
+ }
15
+ /**
16
+ * El diseñador de planos de Seatsyoo en tu web, con el token de una sesión que abre tu servidor con su clave secreta
17
+ * (`POST /api/v1/charts/{plano}/designer-sessions`, o `charts.designerSession` de `@seatsyoo/node`).
18
+ *
19
+ * ```tsx
20
+ * <SeatsyooDesigner
21
+ * server="https://seatsyoo.example"
22
+ * token={token}
23
+ * refreshToken={pedirToken}
24
+ * style={{ height: "80vh" }}
25
+ * onPublish={({ version }) => console.log("Publicada la versión", version)}
26
+ * onClose={() => setAbierto(false)}
27
+ * />
28
+ * ```
29
+ *
30
+ * Con `ref` (`useRef<EmbeddedDesigner>(null)`) tienes el diseñador abierto: `setToken()`, `iframe`…
31
+ */
32
+ export declare const SeatsyooDesigner: ForwardRefExoticComponent<SeatsyooDesignerProps & RefAttributes<EmbeddedDesigner>>;
33
+ export {};
@@ -0,0 +1,132 @@
1
+ // <SeatsyooDesigner>: el diseñador de planos de Seatsyoo, incrustado en tu web, como componente de React (18 y 19).
2
+ //
3
+ // - Se monta en un efecto: en el servidor (SSR) solo se pinta el <div> vacío, y con StrictMode (que monta, desmonta y
4
+ // vuelve a montar) el primero se destruye antes de abrir nada: queda uno. Al desmontar, destroy().
5
+ // - Vuelve a montarse si cambia `server` o `language`, o si se pasa o se quita `refreshToken` u `onClose`: la página del
6
+ // diseñador sabe al abrirse si puede pedir tokens nuevos y si sale «←».
7
+ // - Un `token` nuevo con el diseñador abierto no lo vuelve a montar: se le manda (setToken), como al renovarlo.
8
+ // - Los avisos (onReady, onSave…) y refreshToken llaman siempre a la última función recibida, sin volver a montar.
9
+ import { designer } from "@seatsyoo/visor";
10
+ import { createElement, forwardRef, useEffect, useLayoutEffect, useRef, version, } from "react";
11
+ import { avisarEnConsola, errorDeOpciones } from "./opciones.js";
12
+ // useLayoutEffect no hace nada en el servidor (y React 18 avisa): allí, useEffect, que tampoco se ejecuta.
13
+ const useEfectoAntesDePintar = typeof document === "undefined" ? useEffect : useLayoutEffect;
14
+ /** React 19 llama a la limpieza que devuelve una ref de función en lugar de llamarla con null; React 18, no. */
15
+ const REFS_CON_LIMPIEZA = Number(version.split(".")[0]) >= 19;
16
+ /** Pone el diseñador en la ref de quien usa el componente y devuelve cómo quitarlo, como React con la de un elemento. */
17
+ function ponerEnRef(ref, d) {
18
+ if (typeof ref === "function") {
19
+ const limpieza = ref(d);
20
+ return REFS_CON_LIMPIEZA && typeof limpieza === "function" ? limpieza : () => ref(null);
21
+ }
22
+ if (ref)
23
+ ref.current = d;
24
+ return () => {
25
+ if (ref && ref.current === d)
26
+ ref.current = null;
27
+ };
28
+ }
29
+ function SeatsyooDesignerConRef(props, ref) {
30
+ const { className, style, id, ...opciones } = props;
31
+ const contenedor = useRef(null);
32
+ // Las últimas opciones recibidas: los avisos y el montaje leen de aquí.
33
+ const ultimas = useRef(opciones);
34
+ // El diseñador abierto ahora mismo y el último token que se le dio.
35
+ const abierto = useRef(null);
36
+ // La ref en la que está puesto y cómo quitarlo de ella.
37
+ const enRef = useRef(null);
38
+ useEfectoAntesDePintar(() => {
39
+ ultimas.current = opciones;
40
+ });
41
+ function quitarDeRef() {
42
+ const actual = enRef.current;
43
+ enRef.current = null;
44
+ actual?.quitar();
45
+ }
46
+ function ponerDisenadorEnRef(r, d) {
47
+ if (enRef.current?.ref === r && enRef.current.d === d)
48
+ return;
49
+ quitarDeRef();
50
+ enRef.current = { ref: r, d, quitar: ponerEnRef(r, d) };
51
+ }
52
+ const renueva = !!opciones.refreshToken;
53
+ const cierra = !!opciones.onClose;
54
+ useEffect(() => {
55
+ const div = contenedor.current;
56
+ if (!div)
57
+ return;
58
+ const o = ultimas.current;
59
+ let d;
60
+ try {
61
+ d = designer({
62
+ container: div,
63
+ server: o.server,
64
+ token: o.token,
65
+ ...(o.language ? { language: o.language } : {}),
66
+ // Solo si los hay: la página del diseñador sabe al abrirse si puede pedir tokens y si sale «←».
67
+ ...(o.refreshToken ? { refreshToken: () => (ultimas.current.refreshToken ?? o.refreshToken)() } : {}),
68
+ ...(o.onClose ? { onClose: () => ultimas.current.onClose?.() } : {}),
69
+ onReady: (i) => ultimas.current.onReady?.(i),
70
+ onSave: (i) => ultimas.current.onSave?.(i),
71
+ onPublish: (i) => ultimas.current.onPublish?.(i),
72
+ onError: (e) => (ultimas.current.onError ?? avisarEnConsola)(e),
73
+ });
74
+ }
75
+ catch (err) {
76
+ // Un token que no es de una sesión del diseñador, un contenedor que falta…
77
+ const error = errorDeOpciones(err);
78
+ if (o.onError)
79
+ o.onError(error);
80
+ else
81
+ console.error("[seatsyoo]", error.message);
82
+ return;
83
+ }
84
+ abierto.current = { d, token: o.token };
85
+ ponerDisenadorEnRef(ref, d);
86
+ return () => {
87
+ if (enRef.current?.d === d)
88
+ quitarDeRef();
89
+ d.destroy();
90
+ if (abierto.current?.d === d)
91
+ abierto.current = null;
92
+ };
93
+ }, [opciones.server, opciones.language, renueva, cierra]);
94
+ // Si cambia la ref (otra función u otro objeto), el diseñador pasa a la nueva y se quita de la anterior.
95
+ useEffect(() => {
96
+ if (abierto.current)
97
+ ponerDisenadorEnRef(ref, abierto.current.d);
98
+ }, [ref]);
99
+ // Un token nuevo, al diseñador abierto (si se acaba de montar con él, ya lo tiene).
100
+ useEffect(() => {
101
+ const a = abierto.current;
102
+ if (!a || a.token === opciones.token)
103
+ return;
104
+ try {
105
+ a.d.setToken(opciones.token);
106
+ a.token = opciones.token;
107
+ }
108
+ catch (err) {
109
+ (ultimas.current.onError ?? avisarEnConsola)(errorDeOpciones(err));
110
+ }
111
+ }, [opciones.token]);
112
+ return createElement("div", { ref: contenedor, className, style, id });
113
+ }
114
+ /**
115
+ * El diseñador de planos de Seatsyoo en tu web, con el token de una sesión que abre tu servidor con su clave secreta
116
+ * (`POST /api/v1/charts/{plano}/designer-sessions`, o `charts.designerSession` de `@seatsyoo/node`).
117
+ *
118
+ * ```tsx
119
+ * <SeatsyooDesigner
120
+ * server="https://seatsyoo.example"
121
+ * token={token}
122
+ * refreshToken={pedirToken}
123
+ * style={{ height: "80vh" }}
124
+ * onPublish={({ version }) => console.log("Publicada la versión", version)}
125
+ * onClose={() => setAbierto(false)}
126
+ * />
127
+ * ```
128
+ *
129
+ * Con `ref` (`useRef<EmbeddedDesigner>(null)`) tienes el diseñador abierto: `setToken()`, `iframe`…
130
+ */
131
+ export const SeatsyooDesigner = forwardRef(SeatsyooDesignerConRef);
132
+ SeatsyooDesigner.displayName = "SeatsyooDesigner";
@@ -0,0 +1,3 @@
1
+ export { SeatsyooChart, type SeatsyooChartProps } from "./SeatsyooChart.js";
2
+ export { SeatsyooDesigner, type SeatsyooDesignerProps } from "./SeatsyooDesigner.js";
3
+ export type { ChartDocument, EmbeddedDesigner, EmbeddedDesignerOptions, Language, RenderOptions, SeatingChart, SelectedObject, StatusStyle, } from "@seatsyoo/visor";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // @seatsyoo/react: el visor de mapas de asientos de Seatsyoo y su diseñador de planos como componentes de React.
2
+ export { SeatsyooChart } from "./SeatsyooChart.js";
3
+ export { SeatsyooDesigner } from "./SeatsyooDesigner.js";
@@ -0,0 +1,99 @@
1
+ import type { RenderOptions, SeatingChart } from "@seatsyoo/visor";
2
+ /**
3
+ * Opciones que, si cambian, vuelven a montar el visor: deciden qué se carga y cómo se vende. Los arrays y los
4
+ * objetos se comparan por su valor, no por la referencia: `channels={["web"]}` en cada render no remonta.
5
+ */
6
+ export declare const OPCIONES_DE_IDENTIDAD: readonly ["server", "publicKey", "event", "link", "document", "season", "seasonEvents", "channels", "pricing", "currency", "locale", "language", "maxSelected", "session", "holdToken", "mode", "showLegend", "showSelectionBar", "floor", "sectors", "view3d", "entrance", "texts"];
7
+ /**
8
+ * Opciones que se aplican al visor ya montado, sin volver a montarlo: theme y colorBy (setTheme y setColorBy) y, para
9
+ * el modo «manager», los estilos de los estados y las marcas sobre plazas libres (setStatusStyles y setMarks). Se
10
+ * comparan por valor, como las de identidad: un objeto nuevo con lo mismo no llama a nada.
11
+ */
12
+ export declare const OPCIONES_EN_VIVO: readonly ["theme", "colorBy", "statusStyles", "marks"];
13
+ /** Avisos del visor: siempre llaman a la última función recibida, sin volver a montar. */
14
+ export declare const AVISOS: readonly ["onReady", "onChange", "onSelect", "onDeselect", "onError", "onHoldExpired", "onHoldTokenChange", "onFloorChange"];
15
+ type ClaveDeIdentidad = (typeof OPCIONES_DE_IDENTIDAD)[number];
16
+ /**
17
+ * Cada opción del visor tiene aquí su regla (vuelve a montar, se aplica en vivo o es un aviso). Si el visor gana una
18
+ * opción nueva, esto deja de compilar hasta decidir qué hace el componente con ella: pasó con `entrance` y `texts`,
19
+ * que llegaron al visor sin que el componente las pasara, y con `statusStyles` y `marks` (en vivo: el visor tiene
20
+ * setStatusStyles y setMarks).
21
+ */
22
+ type SinRegla = Exclude<keyof RenderOptions, ClaveDeIdentidad | (typeof OPCIONES_EN_VIVO)[number] | (typeof AVISOS)[number] | "container" | "load3d">;
23
+ type DebeSerNever<T extends never> = T;
24
+ export type TodasLasOpcionesTienenRegla = DebeSerNever<SinRegla>;
25
+ /**
26
+ * Lo que identifica a un visor montado: las opciones de identidad (copiando arrays y objetos pequeños, para que
27
+ * cambiarlos en el sitio también cuente) y si hay `load3d` (la función en sí puede cambiar sin remontar: se
28
+ * llama siempre a la última).
29
+ */
30
+ export type Identidad = {
31
+ [K in ClaveDeIdentidad]: RenderOptions[K];
32
+ } & {
33
+ load3d: boolean;
34
+ };
35
+ export declare function identidadDe(opciones: Partial<RenderOptions>): Identidad;
36
+ /** Igualdad por valor de datos tipo JSON (cadenas, números, booleanos, null, undefined, arrays y objetos). */
37
+ export declare function igual(a: unknown, b: unknown): boolean;
38
+ /**
39
+ * Copia normalizada de datos tipo JSON (listas y objetos, a cualquier profundidad): los objetos salen con
40
+ * Object.prototype (también un diccionario hecho con `Object.create(null)`) y sin las claves que valen `undefined`
41
+ * (`{ color, icon: undefined }` es lo mismo que `{ color }`: el visor no pinta icono en los dos casos).
42
+ *
43
+ * Es la regla de statusStyles y marks, la misma en React, Vue y Angular: lo aplicado en vivo se guarda así copiado, lo
44
+ * que llega se copia igual antes de compararlo, y el visor recibe esa copia. Así cambiarlo en el sitio también cuenta y
45
+ * el visor no comparte el objeto de la página (setMarks repinta lo que difiere de lo que tenía, y si fuera el mismo
46
+ * objeto ya cambiado no vería nada).
47
+ */
48
+ export declare function copiaDeDatos<T>(valor: T): T;
49
+ /** Con qué valor de cada opción en vivo se montó el visor o se le cambió después (sin indicar, el de por defecto). */
50
+ export interface EnVivo {
51
+ theme: NonNullable<RenderOptions["theme"]>;
52
+ colorBy: NonNullable<RenderOptions["colorBy"]>;
53
+ statusStyles: NonNullable<RenderOptions["statusStyles"]>;
54
+ marks: NonNullable<RenderOptions["marks"]>;
55
+ }
56
+ /**
57
+ * Las opciones en vivo con que se monta un visor. Sin theme ni colorBy, los del visor por defecto; sin statusStyles ni
58
+ * marks, ninguno (`{}`, que para el visor es lo mismo que no pasarlas).
59
+ */
60
+ export declare function enVivoDe(opciones: Partial<RenderOptions>): EnVivo;
61
+ /** Lo que hay que añadir a las opciones de `render` para montar con estas opciones en vivo (las copias guardadas). */
62
+ export declare function opcionesEnVivo(opciones: Partial<RenderOptions>, enVivo: EnVivo): Partial<RenderOptions>;
63
+ /** Los métodos del visor con que se aplican las opciones en vivo. */
64
+ export type VisorEnVivo = Pick<SeatingChart, "setTheme" | "setColorBy" | "setStatusStyles" | "setMarks">;
65
+ /**
66
+ * Lleva al visor montado las opciones en vivo que han cambiado (por valor) desde `aplicadas`, y las apunta en
67
+ * `aplicadas`. Quitar una es volver a la de por defecto: el tema claro, el color por categoría, sin estilos ni marcas.
68
+ */
69
+ export declare function aplicarEnVivo(visor: VisorEnVivo, aplicadas: EnVivo, opciones: Partial<RenderOptions>): void;
70
+ /** Las opciones sin las que valen `undefined` (el visor usa entonces su valor por defecto). */
71
+ export declare function sinIndefinidos<T extends object>(opciones: T): T;
72
+ /** Lo que hace el visor con un error si nadie escucha `onError`: escribirlo en la consola. */
73
+ export declare function avisarEnConsola(error: {
74
+ code: string;
75
+ message: string;
76
+ }): void;
77
+ /**
78
+ * `render` lanza si faltan opciones (qué enseñar, la clave pública…). En un componente no hay quien lo
79
+ * recoja, así que se convierte en un error con el código `invalid-options`.
80
+ */
81
+ export declare function errorDeOpciones(err: unknown): {
82
+ code: string;
83
+ message: string;
84
+ };
85
+ /**
86
+ * Al volver a montar (cambia una opción de identidad), ¿hay que soltar lo que el comprador tiene retenido en el
87
+ * visor que se va?
88
+ *
89
+ * destroy() no suelta lo retenido: así el visor que se monte después en el mismo evento lo recupera (con
90
+ * `session: "continue"`, guarda su token en sessionStorage). Pero si el nuevo no lo va a recuperar (otro evento u
91
+ * otra temporada, otro servidor o clave pública, `session: "start"`, un `holdToken` nuevo, modo «manager»), esas
92
+ * plazas se quedarían bloqueadas para los demás hasta que caduque la retención, sin que la página pueda venderlas
93
+ * ni soltarlas: el comprador ya no las ve y el token del visor nuevo es otro. Entonces se sueltan.
94
+ *
95
+ * Lo retenido con un `holdToken` de la página no se suelta nunca: es de su token y lo lleva su servidor (por
96
+ * ejemplo, un carrito con varias funciones). Tampoco hay nada que soltar en el modo «manager» ni al solo ver.
97
+ */
98
+ export declare function sueltaAlCambiar(antes: Identidad, despues: Identidad): boolean;
99
+ export {};
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Opciones que, si cambian, vuelven a montar el visor: deciden qué se carga y cómo se vende. Los arrays y los
3
+ * objetos se comparan por su valor, no por la referencia: `channels={["web"]}` en cada render no remonta.
4
+ */
5
+ export const OPCIONES_DE_IDENTIDAD = [
6
+ "server",
7
+ "publicKey",
8
+ "event",
9
+ "link",
10
+ "document",
11
+ "season",
12
+ "seasonEvents",
13
+ "channels",
14
+ "pricing",
15
+ "currency",
16
+ "locale",
17
+ "language",
18
+ "maxSelected",
19
+ "session",
20
+ "holdToken",
21
+ "mode",
22
+ "showLegend",
23
+ "showSelectionBar",
24
+ "floor",
25
+ "sectors",
26
+ "view3d",
27
+ "entrance",
28
+ "texts",
29
+ ];
30
+ /**
31
+ * Opciones que se aplican al visor ya montado, sin volver a montarlo: theme y colorBy (setTheme y setColorBy) y, para
32
+ * el modo «manager», los estilos de los estados y las marcas sobre plazas libres (setStatusStyles y setMarks). Se
33
+ * comparan por valor, como las de identidad: un objeto nuevo con lo mismo no llama a nada.
34
+ */
35
+ export const OPCIONES_EN_VIVO = ["theme", "colorBy", "statusStyles", "marks"];
36
+ /** Avisos del visor: siempre llaman a la última función recibida, sin volver a montar. */
37
+ export const AVISOS = [
38
+ "onReady",
39
+ "onChange",
40
+ "onSelect",
41
+ "onDeselect",
42
+ "onError",
43
+ "onHoldExpired",
44
+ "onHoldTokenChange",
45
+ "onFloorChange",
46
+ ];
47
+ export function identidadDe(opciones) {
48
+ const identidad = { load3d: typeof opciones.load3d === "function" };
49
+ for (const clave of OPCIONES_DE_IDENTIDAD) {
50
+ const valor = opciones[clave];
51
+ // El plano puede ser muy grande: no se copia (se compara por valor solo si llega otro objeto).
52
+ identidad[clave] = clave === "document" ? valor : copiaSuperficial(valor);
53
+ }
54
+ return identidad;
55
+ }
56
+ function copiaSuperficial(valor) {
57
+ if (Array.isArray(valor))
58
+ return [...valor];
59
+ if (valor !== null && typeof valor === "object")
60
+ return { ...valor };
61
+ return valor;
62
+ }
63
+ /** Igualdad por valor de datos tipo JSON (cadenas, números, booleanos, null, undefined, arrays y objetos). */
64
+ export function igual(a, b) {
65
+ if (Object.is(a, b))
66
+ return true;
67
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
68
+ return false;
69
+ if (Array.isArray(a)) {
70
+ if (!Array.isArray(b) || a.length !== b.length)
71
+ return false;
72
+ for (let i = 0; i < a.length; i++)
73
+ if (!igual(a[i], b[i]))
74
+ return false;
75
+ return true;
76
+ }
77
+ if (Array.isArray(b))
78
+ return false;
79
+ const x = a;
80
+ const y = b;
81
+ const claves = Object.keys(x);
82
+ if (claves.length !== Object.keys(y).length)
83
+ return false;
84
+ for (const k of claves) {
85
+ if (!Object.prototype.hasOwnProperty.call(y, k) || !igual(x[k], y[k]))
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+ /**
91
+ * Copia normalizada de datos tipo JSON (listas y objetos, a cualquier profundidad): los objetos salen con
92
+ * Object.prototype (también un diccionario hecho con `Object.create(null)`) y sin las claves que valen `undefined`
93
+ * (`{ color, icon: undefined }` es lo mismo que `{ color }`: el visor no pinta icono en los dos casos).
94
+ *
95
+ * Es la regla de statusStyles y marks, la misma en React, Vue y Angular: lo aplicado en vivo se guarda así copiado, lo
96
+ * que llega se copia igual antes de compararlo, y el visor recibe esa copia. Así cambiarlo en el sitio también cuenta y
97
+ * el visor no comparte el objeto de la página (setMarks repinta lo que difiere de lo que tenía, y si fuera el mismo
98
+ * objeto ya cambiado no vería nada).
99
+ */
100
+ export function copiaDeDatos(valor) {
101
+ if (Array.isArray(valor))
102
+ return valor.map((v) => copiaDeDatos(v));
103
+ if (valor !== null && typeof valor === "object") {
104
+ const entradas = Object.entries(valor).filter(([, v]) => v !== undefined);
105
+ return Object.fromEntries(entradas.map(([k, v]) => [k, copiaDeDatos(v)]));
106
+ }
107
+ return valor;
108
+ }
109
+ /**
110
+ * Las opciones en vivo con que se monta un visor. Sin theme ni colorBy, los del visor por defecto; sin statusStyles ni
111
+ * marks, ninguno (`{}`, que para el visor es lo mismo que no pasarlas).
112
+ */
113
+ export function enVivoDe(opciones) {
114
+ return {
115
+ theme: opciones.theme ?? "light",
116
+ colorBy: opciones.colorBy ?? "category",
117
+ statusStyles: copiaDeDatos(opciones.statusStyles ?? {}),
118
+ marks: copiaDeDatos(opciones.marks ?? {}),
119
+ };
120
+ }
121
+ /** Lo que hay que añadir a las opciones de `render` para montar con estas opciones en vivo (las copias guardadas). */
122
+ export function opcionesEnVivo(opciones, enVivo) {
123
+ return sinIndefinidos({
124
+ theme: opciones.theme,
125
+ colorBy: opciones.colorBy,
126
+ statusStyles: opciones.statusStyles && enVivo.statusStyles,
127
+ marks: opciones.marks && enVivo.marks,
128
+ });
129
+ }
130
+ /**
131
+ * Lleva al visor montado las opciones en vivo que han cambiado (por valor) desde `aplicadas`, y las apunta en
132
+ * `aplicadas`. Quitar una es volver a la de por defecto: el tema claro, el color por categoría, sin estilos ni marcas.
133
+ */
134
+ export function aplicarEnVivo(visor, aplicadas, opciones) {
135
+ const tema = opciones.theme ?? "light";
136
+ if (tema !== aplicadas.theme) {
137
+ visor.setTheme(tema);
138
+ aplicadas.theme = tema;
139
+ }
140
+ const color = opciones.colorBy ?? "category";
141
+ if (color !== aplicadas.colorBy) {
142
+ visor.setColorBy(color);
143
+ aplicadas.colorBy = color;
144
+ }
145
+ // statusStyles y marks: la copia normalizada de lo que llega (copiaDeDatos) contra la de lo aplicado. Un
146
+ // diccionario sin prototipo o con claves que valen undefined, con lo mismo, no llama a nada (como en Angular).
147
+ const estilos = copiaDeDatos(opciones.statusStyles ?? {});
148
+ if (!igual(estilos, aplicadas.statusStyles)) {
149
+ aplicadas.statusStyles = estilos;
150
+ visor.setStatusStyles(estilos);
151
+ }
152
+ const marcas = copiaDeDatos(opciones.marks ?? {});
153
+ if (!igual(marcas, aplicadas.marks)) {
154
+ aplicadas.marks = marcas;
155
+ visor.setMarks(marcas);
156
+ }
157
+ }
158
+ /** Las opciones sin las que valen `undefined` (el visor usa entonces su valor por defecto). */
159
+ export function sinIndefinidos(opciones) {
160
+ return Object.fromEntries(Object.entries(opciones).filter(([, v]) => v !== undefined));
161
+ }
162
+ /** Lo que hace el visor con un error si nadie escucha `onError`: escribirlo en la consola. */
163
+ export function avisarEnConsola(error) {
164
+ console.warn("[seatsyoo]", error.code, error.message);
165
+ }
166
+ /**
167
+ * `render` lanza si faltan opciones (qué enseñar, la clave pública…). En un componente no hay quien lo
168
+ * recoja, así que se convierte en un error con el código `invalid-options`.
169
+ */
170
+ export function errorDeOpciones(err) {
171
+ return { code: "invalid-options", message: err instanceof Error ? err.message : String(err) };
172
+ }
173
+ /**
174
+ * Qué compra el visor montado con estas opciones: el evento, o la temporada (con los eventos del abono parcial, en
175
+ * cualquier orden, como los guarda el visor); `null` si solo enseña el plano (`link`, `document`: no retiene nada).
176
+ */
177
+ function destino(i) {
178
+ // Con el mismo orden que el visor: document, link, season y event.
179
+ if (i.document || i.link)
180
+ return null;
181
+ if (!i.season)
182
+ return `evento:${i.event ?? ""}`;
183
+ const abono = i.seasonEvents?.length ? `@${[...i.seasonEvents].sort().join(",")}` : "";
184
+ return `temporada:${i.season}${abono}`;
185
+ }
186
+ /**
187
+ * Al volver a montar (cambia una opción de identidad), ¿hay que soltar lo que el comprador tiene retenido en el
188
+ * visor que se va?
189
+ *
190
+ * destroy() no suelta lo retenido: así el visor que se monte después en el mismo evento lo recupera (con
191
+ * `session: "continue"`, guarda su token en sessionStorage). Pero si el nuevo no lo va a recuperar (otro evento u
192
+ * otra temporada, otro servidor o clave pública, `session: "start"`, un `holdToken` nuevo, modo «manager»), esas
193
+ * plazas se quedarían bloqueadas para los demás hasta que caduque la retención, sin que la página pueda venderlas
194
+ * ni soltarlas: el comprador ya no las ve y el token del visor nuevo es otro. Entonces se sueltan.
195
+ *
196
+ * Lo retenido con un `holdToken` de la página no se suelta nunca: es de su token y lo lleva su servidor (por
197
+ * ejemplo, un carrito con varias funciones). Tampoco hay nada que soltar en el modo «manager» ni al solo ver.
198
+ */
199
+ export function sueltaAlCambiar(antes, despues) {
200
+ if (antes.holdToken !== undefined || antes.mode === "manager" || destino(antes) === null)
201
+ return false;
202
+ const recupera = despues.holdToken === undefined &&
203
+ despues.mode !== "manager" &&
204
+ (antes.session ?? "continue") === "continue" &&
205
+ (despues.session ?? "continue") === "continue" &&
206
+ antes.server === despues.server &&
207
+ antes.publicKey === despues.publicKey &&
208
+ destino(antes) === destino(despues);
209
+ return !recupera;
210
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@seatsyoo/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.1.0",
4
+ "description": "Componente de React para el visor de mapas de asientos de Seatsyoo: elegir butacas, retenerlas y venderlas en tiempo real.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "seatsyoo",
8
+ "react",
9
+ "mapa de asientos",
10
+ "butacas",
11
+ "entradas",
12
+ "seating chart",
13
+ "seat map",
14
+ "tickets"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist/*.js",
27
+ "dist/*.d.ts"
28
+ ],
29
+ "dependencies": {
30
+ "@seatsyoo/visor": "^1.1.0"
31
+ },
32
+ "peerDependencies": {
33
+ "react": "^18.0.0 || ^19.0.0"
34
+ },
35
+ "main": "./dist/index.js",
36
+ "module": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
6
41
  }