@infinitech.maps/st-map 1.0.18 → 1.0.20
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/README.md +436 -0
- package/dist/index.js +17898 -17457
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# @infinitech.maps/st-map
|
|
2
|
+
|
|
3
|
+
SDK de React para renderizar mapas interactivos de SmartTicket con selección de asientos, mesas y secciones.
|
|
4
|
+
|
|
5
|
+
## Instalación
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @infinitech.maps/st-map
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Peer dependencies:** `react >=18` y `react-dom >=18`.
|
|
12
|
+
|
|
13
|
+
## Inicio rápido
|
|
14
|
+
|
|
15
|
+
### Modo self-contained (recomendado)
|
|
16
|
+
|
|
17
|
+
El componente carga el mapa automáticamente desde la API usando `apiKey` y `cacheKey`:
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
import { STMap } from "@infinitech.maps/st-map";
|
|
21
|
+
|
|
22
|
+
function App() {
|
|
23
|
+
return (
|
|
24
|
+
<STMap
|
|
25
|
+
apiKey="tu-api-key"
|
|
26
|
+
cacheKey="uuid-del-mapa"
|
|
27
|
+
onSeatClick={(seat, shape, event, meta) => {
|
|
28
|
+
console.log(seat.id, seat.price);
|
|
29
|
+
}}
|
|
30
|
+
loadingFallback={<p>Cargando mapa...</p>}
|
|
31
|
+
errorFallback={(error) => <p>Error: {error.message}</p>}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Modo legacy (datos directos)
|
|
38
|
+
|
|
39
|
+
Para pasar el JSON del mapa directamente:
|
|
40
|
+
|
|
41
|
+
```jsx
|
|
42
|
+
import { STMap } from "@infinitech.maps/st-map";
|
|
43
|
+
|
|
44
|
+
function App() {
|
|
45
|
+
return (
|
|
46
|
+
<STMap
|
|
47
|
+
map={mapData}
|
|
48
|
+
onSeatClick={(seat, shape, event, meta) => {
|
|
49
|
+
console.log(seat.id, seat.price);
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Props
|
|
59
|
+
|
|
60
|
+
### Modo self-contained
|
|
61
|
+
|
|
62
|
+
| Prop | Tipo | Default | Descripción |
|
|
63
|
+
|------|------|---------|-------------|
|
|
64
|
+
| `apiKey` | `string` | — | **Requerido.** API key de autenticación. |
|
|
65
|
+
| `cacheKey` | `string` | — | **Requerido.** UUID del mapa en caché. |
|
|
66
|
+
| `baseUrl` | `string` | `"http://localhost:3001"` | URL base del Gateway. |
|
|
67
|
+
| `loadingFallback` | `ReactNode` | `null` | Componente a mostrar mientras carga el mapa. |
|
|
68
|
+
| `errorFallback` | `ReactNode \| (error) => ReactNode` | `null` | Componente o función a mostrar en caso de error. |
|
|
69
|
+
|
|
70
|
+
### Modo legacy
|
|
71
|
+
|
|
72
|
+
| Prop | Tipo | Descripción |
|
|
73
|
+
|------|------|-------------|
|
|
74
|
+
| `map` | `object` | Objeto JSON del mapa (formato v2 o legacy). |
|
|
75
|
+
|
|
76
|
+
### Callbacks
|
|
77
|
+
|
|
78
|
+
| Prop | Firma | Descripción |
|
|
79
|
+
|------|-------|-------------|
|
|
80
|
+
| `onSeatClick` | `(seat, shape, event, meta) => void` | Clic en un asiento, silla o mesa. |
|
|
81
|
+
| `onSectionClick` | `(shape, event) => void` | Clic en una sección/categoría completa. |
|
|
82
|
+
| `onEmptyAreaClick` | `() => void` | Clic en el área vacía del canvas. |
|
|
83
|
+
| `onReady` | `() => void` | El mapa terminó de renderizar. |
|
|
84
|
+
|
|
85
|
+
### Personalización visual
|
|
86
|
+
|
|
87
|
+
| Prop | Tipo | Default | Descripción |
|
|
88
|
+
|------|------|---------|-------------|
|
|
89
|
+
| `prices` | `Record<string, number>` | — | Mapa de precios por UUID (ver [Resolución de precios](#resolución-jerárquica-de-precios)). |
|
|
90
|
+
| `selectedSeats` | `string[]` | — | Lista de UUIDs seleccionados (modo controlado). |
|
|
91
|
+
| `reservedSeats` | `string[]` | — | Lista de UUIDs de asientos reservados. |
|
|
92
|
+
| `selectedSeatColor` | `string` | `"#01FFFF"` | Color de asientos seleccionados. |
|
|
93
|
+
| `reservedSeatColor` | `string` | `"#FF0000"` | Color de asientos reservados. |
|
|
94
|
+
| `purchasedSeatColor` | `string` | `"#000000"` | Color de asientos comprados. |
|
|
95
|
+
| `hoverColor` | `string` | `"#444"` | Color al pasar el cursor. |
|
|
96
|
+
| `hoverTransition` | `number` | `0` | Duración de la transición hover en ms. |
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Callbacks
|
|
101
|
+
|
|
102
|
+
### `onSeatClick(seat, shape, event, meta)`
|
|
103
|
+
|
|
104
|
+
Se invoca al hacer clic en cualquier elemento reservable (asiento, silla o mesa).
|
|
105
|
+
|
|
106
|
+
| Argumento | Descripción |
|
|
107
|
+
|-----------|-------------|
|
|
108
|
+
| `seat` | Objeto del asiento/mesa seleccionado. Contiene `id` (UUID), `price`, `status`, etc. |
|
|
109
|
+
| `shape` | Objeto de la forma padre (fila, mesa, sector). Contiene `category`, `section`, `sectorName`, etc. |
|
|
110
|
+
| `event` | Evento de Konva (canvas). |
|
|
111
|
+
| `meta` | Metadatos adicionales. Contiene `screenPosition` para posicionar popups. |
|
|
112
|
+
|
|
113
|
+
#### Propiedades de `seat`
|
|
114
|
+
|
|
115
|
+
| Propiedad | Tipo | Descripción |
|
|
116
|
+
|-----------|------|-------------|
|
|
117
|
+
| `seat.id` | `string` | **UUID** único del elemento seleccionado. |
|
|
118
|
+
| `seat.price` | `number \| undefined` | **Precio** resuelto. Presente solo si se pasó `prices` y existe un precio. |
|
|
119
|
+
| `seat.name` | `string` | Nombre visible del asiento (ej. `"A01"`). |
|
|
120
|
+
| `seat.row` | `string \| number` | Fila del asiento. |
|
|
121
|
+
| `seat.column` | `number` | Columna del asiento. |
|
|
122
|
+
| `seat.status` | `string` | Estado: `"vacant"`, `"reserved"`, `"purchased"`. |
|
|
123
|
+
| `seat.type` | `string` | Tipo de elemento: `"seat"` o `"table"`. |
|
|
124
|
+
|
|
125
|
+
##### Propiedades exclusivas de mesas (`seat.type === "table"`)
|
|
126
|
+
|
|
127
|
+
| Propiedad | Tipo | Descripción |
|
|
128
|
+
|-----------|------|-------------|
|
|
129
|
+
| `seat.isTable` | `boolean` | `true` cuando es una mesa. |
|
|
130
|
+
| `seat.tableName` | `string` | Nombre de la mesa. |
|
|
131
|
+
| `seat.chairs` | `array` | Lista de sillas de la mesa. |
|
|
132
|
+
| `seat.totalChairs` | `number` | Total de sillas en la mesa. |
|
|
133
|
+
| `seat.vacantCount` | `number` | Cantidad de sillas vacantes. |
|
|
134
|
+
| `seat.reservedCount` | `number` | Cantidad de sillas reservadas. |
|
|
135
|
+
| `seat.purchasedCount` | `number` | Cantidad de sillas compradas. |
|
|
136
|
+
| `seat.available` | `number` | Alias de `vacantCount`. |
|
|
137
|
+
|
|
138
|
+
#### Propiedad `meta.screenPosition`
|
|
139
|
+
|
|
140
|
+
Contiene las coordenadas CSS del centro del elemento clicado, útil para posicionar tooltips o popups:
|
|
141
|
+
|
|
142
|
+
```jsx
|
|
143
|
+
const handleSeatClick = (seat, shape, event, meta) => {
|
|
144
|
+
// Posicionar un popup sobre el asiento
|
|
145
|
+
setPopupPosition({
|
|
146
|
+
x: meta.screenPosition.x,
|
|
147
|
+
y: meta.screenPosition.y,
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Ejemplo completo: obtener UUID y precio
|
|
153
|
+
|
|
154
|
+
```jsx
|
|
155
|
+
import { STMap } from "@infinitech.maps/st-map";
|
|
156
|
+
|
|
157
|
+
function MapView() {
|
|
158
|
+
const prices = {
|
|
159
|
+
"d5195a0a-2e6f-4167-a930-b26440d1e072": 150, // Categoría "VIP"
|
|
160
|
+
"702fdfc0-3a10-48a5-861c-0ddddac1bf7f": 50, // Categoría "General"
|
|
161
|
+
"58a5497e-eb75-445a-a89c-dc0fbe652d38": 500, // Asiento específico
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleSeatClick = (seat, shape, event, meta) => {
|
|
165
|
+
console.log("UUID:", seat.id);
|
|
166
|
+
console.log("Precio:", seat.price ?? "Sin precio asignado");
|
|
167
|
+
|
|
168
|
+
if (seat.type === "table") {
|
|
169
|
+
console.log(`Mesa "${seat.tableName}" — ${seat.vacantCount}/${seat.totalChairs} disponibles`);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(`Asiento ${seat.name}, fila ${seat.row}`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<STMap
|
|
177
|
+
apiKey="tu-api-key"
|
|
178
|
+
cacheKey="uuid-del-mapa"
|
|
179
|
+
prices={prices}
|
|
180
|
+
onSeatClick={handleSeatClick}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `onSectionClick(shape, event)`
|
|
187
|
+
|
|
188
|
+
Se invoca al hacer clic en una sección/categoría completa (formas geométricas sin asientos individuales).
|
|
189
|
+
|
|
190
|
+
| Propiedad | Tipo | Descripción |
|
|
191
|
+
|-----------|------|-------------|
|
|
192
|
+
| `shape.id` | `string` | **UUID** de la sección. |
|
|
193
|
+
| `shape.price` | `number \| undefined` | **Precio** resuelto (si existe). |
|
|
194
|
+
| `shape.category` | `string` | ID de la categoría. |
|
|
195
|
+
| `shape.categoryName` | `string` | Nombre de la categoría. |
|
|
196
|
+
| `shape.section` | `string` | ID del sector. |
|
|
197
|
+
| `shape.sectorName` | `string` | Nombre del sector. |
|
|
198
|
+
|
|
199
|
+
```jsx
|
|
200
|
+
<STMap
|
|
201
|
+
apiKey="tu-api-key"
|
|
202
|
+
cacheKey="uuid-del-mapa"
|
|
203
|
+
prices={prices}
|
|
204
|
+
onSeatClick={handleSeatClick}
|
|
205
|
+
onSectionClick={(shape, event) => {
|
|
206
|
+
console.log("UUID sección:", shape.id);
|
|
207
|
+
console.log("Precio:", shape.price ?? "Sin precio");
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Resolución jerárquica de precios
|
|
215
|
+
|
|
216
|
+
Cuando se pasa la prop `prices`, el SDK resuelve el precio de forma jerárquica buscando en este orden:
|
|
217
|
+
|
|
218
|
+
1. **Asiento individual** → `prices[seat.id]`
|
|
219
|
+
2. **Sector** → `prices[shape.section]`
|
|
220
|
+
3. **Categoría** → `prices[shape.category]`
|
|
221
|
+
|
|
222
|
+
El primer match encontrado se asigna como `seat.price` (o `shape.price` en secciones).
|
|
223
|
+
|
|
224
|
+
Si se proporcionan `prices` pero no se encuentra precio en ningún nivel, el SDK muestra una alerta y **no permite la selección** del elemento.
|
|
225
|
+
|
|
226
|
+
Si **no** se pasa la prop `prices`, no hay validación de precio y todos los elementos son seleccionables.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Métodos imperativos (ref)
|
|
231
|
+
|
|
232
|
+
Usa `ref` para acceder a métodos imperativos del componente:
|
|
233
|
+
|
|
234
|
+
```jsx
|
|
235
|
+
import { useRef } from "react";
|
|
236
|
+
import { STMap } from "@infinitech.maps/st-map";
|
|
237
|
+
|
|
238
|
+
function App() {
|
|
239
|
+
const mapRef = useRef();
|
|
240
|
+
|
|
241
|
+
return <STMap ref={mapRef} apiKey="tu-api-key" cacheKey="uuid-del-mapa" />;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `resolveReservationIds(selections)`
|
|
246
|
+
|
|
247
|
+
Convierte los elementos clicados en IDs de asientos/sillas individuales listos para la API. Para mesas, extrae los IDs de las sillas vacantes; para asientos, devuelve `[id]`.
|
|
248
|
+
|
|
249
|
+
```jsx
|
|
250
|
+
const seatIds = mapRef.current.resolveReservationIds([
|
|
251
|
+
{ id: "uuid-mesa", quantity: 3 }, // extrae 3 sillas vacantes
|
|
252
|
+
{ id: "uuid-asiento" }, // devuelve [uuid-asiento]
|
|
253
|
+
]);
|
|
254
|
+
// → ["silla-1", "silla-2", "silla-3", "uuid-asiento"]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
| Parámetro | Tipo | Descripción |
|
|
258
|
+
|-----------|------|-------------|
|
|
259
|
+
| `selections` | `Array<{ id: string, quantity?: number }>` | Elementos a resolver. `quantity` solo aplica a mesas. |
|
|
260
|
+
|
|
261
|
+
### `reserveSelected({ selections, ttlSeconds })`
|
|
262
|
+
|
|
263
|
+
Resuelve IDs y reserva en una sola llamada. Requiere `apiKey` y `cacheKey`.
|
|
264
|
+
|
|
265
|
+
```jsx
|
|
266
|
+
const { seatIds } = await mapRef.current.reserveSelected({
|
|
267
|
+
selections: [{ id: "uuid-mesa", quantity: 2 }],
|
|
268
|
+
ttlSeconds: 300, // 5 minutos (default)
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### `releaseAll({ seatIds })`
|
|
273
|
+
|
|
274
|
+
Libera asientos (vuelven a estado `"vacant"`). Requiere `apiKey` y `cacheKey`.
|
|
275
|
+
|
|
276
|
+
```jsx
|
|
277
|
+
await mapRef.current.releaseAll({
|
|
278
|
+
seatIds: ["silla-1", "silla-2"],
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Selección controlada
|
|
285
|
+
|
|
286
|
+
Por defecto el SDK gestiona la selección internamente. Para controlarla externamente, pasa `selectedSeats`:
|
|
287
|
+
|
|
288
|
+
```jsx
|
|
289
|
+
const [selected, setSelected] = useState([]);
|
|
290
|
+
|
|
291
|
+
const handleSeatClick = (seat) => {
|
|
292
|
+
setSelected((prev) =>
|
|
293
|
+
prev.includes(seat.id)
|
|
294
|
+
? prev.filter((id) => id !== seat.id)
|
|
295
|
+
: [...prev, seat.id]
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
<STMap
|
|
300
|
+
apiKey="tu-api-key"
|
|
301
|
+
cacheKey="uuid-del-mapa"
|
|
302
|
+
selectedSeats={selected}
|
|
303
|
+
onSeatClick={handleSeatClick}
|
|
304
|
+
/>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Console log integrado
|
|
310
|
+
|
|
311
|
+
El SDK incluye un log automático en la consola del navegador cada vez que se hace clic en un elemento reservable:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
// Asientos/mesas
|
|
315
|
+
[ST-Map] Seat click {
|
|
316
|
+
uuid: "58a5497e-eb75-445a-a89c-dc0fbe652d38",
|
|
317
|
+
price: 500,
|
|
318
|
+
row: "A",
|
|
319
|
+
column: 1,
|
|
320
|
+
category: "VIP",
|
|
321
|
+
section: "Sector 1"
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Secciones
|
|
325
|
+
[ST-Map] Section click {
|
|
326
|
+
uuid: "3b1633c9-57d0-4438-b952-95c213f98e29",
|
|
327
|
+
price: 200,
|
|
328
|
+
category: "General",
|
|
329
|
+
section: "Sector 2"
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Si un campo no existe (ej. fila/columna en mesas), se muestra `"N/A"`. Sin precio se muestra `"sin precio"`.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## STMapClient
|
|
338
|
+
|
|
339
|
+
Cliente HTTP para interactuar con la API de gestión de asientos. Funciona de forma independiente, sin necesidad del componente React.
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
import { STMapClient } from "@infinitech.maps/st-map";
|
|
343
|
+
|
|
344
|
+
const client = new STMapClient({ apiKey: "tu-api-key" });
|
|
345
|
+
|
|
346
|
+
// O con URL personalizada:
|
|
347
|
+
const client = new STMapClient({
|
|
348
|
+
apiKey: "tu-api-key",
|
|
349
|
+
baseUrl: "https://gateway.example.com",
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### `getMap(cacheKey, options?)`
|
|
354
|
+
|
|
355
|
+
Carga el mapa desde la API.
|
|
356
|
+
|
|
357
|
+
```js
|
|
358
|
+
const map = await client.getMap("uuid-del-mapa");
|
|
359
|
+
// Con versión específica:
|
|
360
|
+
const map = await client.getMap("uuid-del-mapa", { version: "v1" });
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### `reserve({ cacheKey, seatIds, ttlSeconds?, version? })`
|
|
364
|
+
|
|
365
|
+
Reserva asientos. Estado → `"reserved"` con expiración.
|
|
366
|
+
|
|
367
|
+
```js
|
|
368
|
+
const reserved = await client.reserve({
|
|
369
|
+
cacheKey: "uuid-del-mapa",
|
|
370
|
+
seatIds: ["seat-1", "seat-2"],
|
|
371
|
+
ttlSeconds: 300, // 5 minutos (default)
|
|
372
|
+
});
|
|
373
|
+
// → [{ id: "seat-1", status: "reserved", expiredAt: "..." }, ...]
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### `purchase({ cacheKey, seatIds, version? })`
|
|
377
|
+
|
|
378
|
+
Compra asientos. Estado → `"purchased"` (permanente). Funciona sobre asientos vacantes o previamente reservados.
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
const purchased = await client.purchase({
|
|
382
|
+
cacheKey: "uuid-del-mapa",
|
|
383
|
+
seatIds: ["seat-1"],
|
|
384
|
+
});
|
|
385
|
+
// → [{ id: "seat-1", status: "purchased" }]
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### `release({ cacheKey, seatIds, version? })`
|
|
389
|
+
|
|
390
|
+
Libera asientos. Estado → `"vacant"`. Idempotente: liberar un asiento ya vacante no genera error.
|
|
391
|
+
|
|
392
|
+
```js
|
|
393
|
+
await client.release({
|
|
394
|
+
cacheKey: "uuid-del-mapa",
|
|
395
|
+
seatIds: ["seat-2"],
|
|
396
|
+
});
|
|
397
|
+
// → [{ id: "seat-2", status: "vacant" }]
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### `getSeatStatus({ cacheKey, seatIds?, version? })`
|
|
401
|
+
|
|
402
|
+
Consulta el estado de asientos. Omitir `seatIds` devuelve **todos** los asientos del mapa.
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
// Estado de asientos específicos
|
|
406
|
+
const status = await client.getSeatStatus({
|
|
407
|
+
cacheKey: "uuid-del-mapa",
|
|
408
|
+
seatIds: ["seat-1", "seat-2"],
|
|
409
|
+
});
|
|
410
|
+
// → [{ id: "seat-1", status: "reserved", expiredAt: "..." }, ...]
|
|
411
|
+
|
|
412
|
+
// Estado de todos los asientos
|
|
413
|
+
const allStatus = await client.getSeatStatus({
|
|
414
|
+
cacheKey: "uuid-del-mapa",
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Métodos estáticos
|
|
419
|
+
|
|
420
|
+
#### `STMapClient.resolveReservationIds(element, quantity?)`
|
|
421
|
+
|
|
422
|
+
Resuelve los IDs individuales de un elemento devuelto por `onSeatClick`. Para mesas, extrae los IDs de las sillas vacantes; para asientos, devuelve `[id]`.
|
|
423
|
+
|
|
424
|
+
```js
|
|
425
|
+
const ids = STMapClient.resolveReservationIds(clickedElement, 3);
|
|
426
|
+
await client.reserve({ cacheKey: "uuid-del-mapa", seatIds: ids });
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
#### `STMapClient.vacantCount(element)`
|
|
430
|
+
|
|
431
|
+
Devuelve la cantidad de asientos vacantes para un elemento. Asientos individuales devuelven `0` o `1`; mesas devuelven la cantidad de sillas vacantes.
|
|
432
|
+
|
|
433
|
+
```js
|
|
434
|
+
const available = STMapClient.vacantCount(clickedElement);
|
|
435
|
+
console.log(`${available} lugares disponibles`);
|
|
436
|
+
```
|