@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 +21 -0
- package/README.md +349 -2
- package/dist/SeatsyooChart.d.ts +36 -0
- package/dist/SeatsyooChart.js +161 -0
- package/dist/SeatsyooDesigner.d.ts +33 -0
- package/dist/SeatsyooDesigner.js +132 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/opciones.d.ts +99 -0
- package/dist/opciones.js +210 -0
- package/package.json +38 -3
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
|
-
#
|
|
1
|
+
# @seatsyoo/react
|
|
2
2
|
|
|
3
|
-
|
|
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";
|
package/dist/index.d.ts
ADDED
|
@@ -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,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 {};
|
package/dist/opciones.js
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|