@eventlook/sdk 1.7.6 → 1.7.8

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.
Files changed (48) hide show
  1. package/.gitlab-ci.yml +51 -0
  2. package/README.md +7 -0
  3. package/dist/cjs/index.js +25692 -16
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/esm/index.js +25674 -12
  6. package/dist/esm/index.js.map +1 -1
  7. package/dist/types/components/RichText.d.ts +7 -0
  8. package/dist/types/context/SeatPickerContext.d.ts +13 -0
  9. package/dist/types/embed.d.ts +49 -0
  10. package/dist/types/locales/cs.d.ts +1 -0
  11. package/dist/types/locales/en.d.ts +1 -0
  12. package/dist/types/locales/es.d.ts +1 -0
  13. package/dist/types/locales/pl.d.ts +1 -0
  14. package/dist/types/locales/sk.d.ts +1 -0
  15. package/dist/types/locales/uk.d.ts +1 -0
  16. package/dist/types/utils/render-rich-text.d.ts +2 -0
  17. package/dist/types/utils/types/event.type.d.ts +1 -0
  18. package/package.json +4 -2
  19. package/rollup.embed.config.mjs +74 -0
  20. package/src/components/RichText.tsx +33 -0
  21. package/src/context/SeatPickerContext.tsx +35 -0
  22. package/src/embed.tsx +243 -0
  23. package/src/form/Payment.tsx +6 -1
  24. package/src/form/PaymentOverviewBox.tsx +35 -19
  25. package/src/form/PaymentPending.tsx +7 -24
  26. package/src/form/ReleaseWithMerchandise.tsx +8 -1
  27. package/src/form/TicketForm.tsx +204 -164
  28. package/src/form/services/index.tsx +1 -0
  29. package/src/form/tickets/TicketSelectionMap.tsx +240 -73
  30. package/src/index.ts +4 -0
  31. package/src/locales/cs.tsx +1 -0
  32. package/src/locales/en.tsx +1 -0
  33. package/src/locales/es.tsx +1 -0
  34. package/src/locales/pl.tsx +1 -0
  35. package/src/locales/sk.tsx +1 -0
  36. package/src/locales/uk.tsx +1 -0
  37. package/src/react-dom-client.d.ts +11 -0
  38. package/src/utils/axios.ts +3 -0
  39. package/src/utils/render-rich-text.ts +100 -0
  40. package/src/utils/types/event.type.ts +1 -0
  41. package/dist/cjs/index-Dh-sV_wk.js +0 -41946
  42. package/dist/cjs/index-Dh-sV_wk.js.map +0 -1
  43. package/dist/cjs/index.umd-CJ-ZDtw8.js +0 -13397
  44. package/dist/cjs/index.umd-CJ-ZDtw8.js.map +0 -1
  45. package/dist/esm/index-DPHu9Sfs.js +0 -41925
  46. package/dist/esm/index-DPHu9Sfs.js.map +0 -1
  47. package/dist/esm/index.umd-DPIwz_aJ.js +0 -13395
  48. package/dist/esm/index.umd-DPIwz_aJ.js.map +0 -1
@@ -1,98 +1,265 @@
1
- import React from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import useGlobal from '@hooks/useGlobal';
3
3
  import { IEvent } from '@utils/types/event.type';
4
- import { Button } from '@mui/material';
5
- import { iframe, TicketSelection } from '@seat-picker/seat-picker-sdk';
4
+ import { Box, Stack, Typography, useTheme } from '@mui/material';
5
+ import { iframe, SeatPickerCategory, TicketSelection } from '@seat-picker/seat-picker-sdk';
6
6
  import { useFormContext } from 'react-hook-form';
7
7
  import { ITicketForm, ITicketFormTicket, ITicketLocation } from '@utils/types/ticket.type';
8
8
  import { getMaxTicketsPerOrder } from '@utils/data/ticket';
9
- import Iconify from '@components/iconify/Iconify';
9
+ import { useSeatPicker } from '@context/SeatPickerContext';
10
+ import { fCurrency } from '@utils/formatNumber';
10
11
 
11
12
  interface Props {
12
13
  event: IEvent;
13
14
  }
14
15
 
15
16
  const TicketSelectionMap: React.FC<Props> = ({ event }) => {
16
- const { t, seatingIframeUrl } = useGlobal();
17
+ const { seatingIframeUrl, lang, t } = useGlobal();
17
18
  const { setValue, watch } = useFormContext<ITicketForm>();
19
+ const { registerDeselect } = useSeatPicker();
18
20
  const uuid = watch('uuid');
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const [aspectRatio, setAspectRatio] = useState<number | null>(null);
23
+ const [categories, setCategories] = useState<SeatPickerCategory[]>([]);
24
+ // The picker follows the host page's MUI theme: passed at mount for the
25
+ // first paint, then synced live via handle.setTheme (no remount — that
26
+ // would flash and re-open the reservation WS).
27
+ const themeMode = useTheme().palette.mode;
28
+ const themeModeRef = useRef(themeMode);
29
+ themeModeRef.current = themeMode;
30
+ const handleRef = useRef<ReturnType<typeof iframe.openPicker> | null>(null);
19
31
 
20
- if (!uuid) return;
21
-
22
- const onSelect = (seats: TicketSelection) => {
23
- const tickets: ITicketFormTicket[] = [];
24
- const groupedSeatsByZone = seats.reduce(
25
- (tickets, seat) => {
26
- const zoneIndex = tickets.findIndex((ticket) => ticket.seat.id === seat.locationId);
27
- if (zoneIndex !== -1) {
28
- tickets[zoneIndex].quantity += 1;
29
- } else {
30
- tickets.push({
31
- seat: {
32
- id: seat.locationId,
33
- type: seat.locationType,
34
- locationDescription: seat.locationTypeDescription,
35
- },
36
- quantity: 1,
37
- ticket: seat.ticket,
32
+ useEffect(() => {
33
+ const container = containerRef.current;
34
+ if (!uuid || !seatingIframeUrl || !container) return;
35
+
36
+ // Assigned right below; onSelect only fires via postMessage, i.e. after
37
+ // openPicker has returned.
38
+ let handle: ReturnType<typeof iframe.openPicker> | null = null;
39
+
40
+ const onSelect = (seats: TicketSelection) => {
41
+ const tickets: ITicketFormTicket[] = [];
42
+ const groupedSeatsByZone = seats.reduce(
43
+ (tickets, seat) => {
44
+ const zoneIndex = tickets.findIndex((ticket) => ticket.seat.id === seat.locationId);
45
+ if (zoneIndex !== -1) {
46
+ tickets[zoneIndex].quantity += 1;
47
+ } else {
48
+ tickets.push({
49
+ seat: {
50
+ id: seat.locationId,
51
+ type: seat.locationType,
52
+ locationDescription: seat.locationTypeDescription,
53
+ },
54
+ quantity: 1,
55
+ ticket: seat.ticket,
56
+ });
57
+ }
58
+
59
+ return tickets;
60
+ },
61
+ // TODO: fix types
62
+ [] as { quantity: number; seat: ITicketLocation; ticket: any }[]
63
+ );
64
+
65
+ let remainingTicketCapacity = getMaxTicketsPerOrder(event.id);
66
+ // Selections beyond the per-order cap. The picker enforces the cap at
67
+ // click time, so this only triggers when the two disagree (e.g. a
68
+ // restored previous session after the cap was lowered) — but when it
69
+ // does, the excess must be RELEASED back through the picker, not
70
+ // silently dropped: a dropped ticket would keep its WS reservation and
71
+ // block the seat for everyone while being in nobody's cart.
72
+ const overflow: { locationId: string; count: number }[] = [];
73
+
74
+ for (const groupedSeat of groupedSeatsByZone) {
75
+ const quantity = Math.min(groupedSeat.quantity, remainingTicketCapacity);
76
+ remainingTicketCapacity -= quantity;
77
+
78
+ if (quantity < groupedSeat.quantity) {
79
+ overflow.push({
80
+ locationId: groupedSeat.seat.id,
81
+ count: groupedSeat.quantity - quantity,
38
82
  });
39
83
  }
84
+ if (quantity <= 0) continue;
40
85
 
41
- return tickets;
42
- },
43
- // TODO: fix types
44
- [] as { quantity: number; seat: ITicketLocation; ticket: any }[]
45
- );
46
-
47
- let remainingTicketCapacity = getMaxTicketsPerOrder(event.id);
48
-
49
- for (const groupedSeat of groupedSeatsByZone) {
50
- if (remainingTicketCapacity <= 0) break;
51
-
52
- const quantity = Math.min(groupedSeat.quantity, remainingTicketCapacity);
53
- remainingTicketCapacity -= quantity;
54
-
55
- tickets.push({
56
- releaseId: groupedSeat.ticket.id,
57
- price: groupedSeat.ticket.price,
58
- quantity,
59
- itemName: `${groupedSeat.ticket.releaseCategoryName} - ${groupedSeat.ticket.name}`,
60
- products: [],
61
- extraFields: [],
62
- location: {
63
- id: groupedSeat.seat.id,
64
- type: groupedSeat.seat.type,
65
- locationDescription: groupedSeat.seat.locationDescription,
66
- },
67
- });
68
- }
86
+ tickets.push({
87
+ releaseId: groupedSeat.ticket.id,
88
+ price: groupedSeat.ticket.price,
89
+ quantity,
90
+ itemName: `${groupedSeat.ticket.releaseCategoryName} - ${groupedSeat.ticket.name}`,
91
+ products: [],
92
+ extraFields: [],
93
+ location: {
94
+ id: groupedSeat.seat.id,
95
+ type: groupedSeat.seat.type,
96
+ locationDescription: groupedSeat.seat.locationDescription,
97
+ },
98
+ });
99
+ }
100
+
101
+ setValue(`tickets.${event.id}`, tickets);
102
+
103
+ if (overflow.length) {
104
+ console.warn(
105
+ '[eventlook] seat selection exceeds the per-order ticket cap; releasing the excess:',
106
+ overflow
107
+ );
108
+ // Release ONE unit per pass: the picker re-emits the shrunken
109
+ // selection after each removal, which re-enters this handler — so
110
+ // releasing everything at once here would stack with those re-entrant
111
+ // passes and over-release. One-at-a-time converges on exactly the cap.
112
+ handle?.deselect(overflow[0].locationId);
113
+ }
114
+ };
69
115
 
70
- setValue(`tickets.${event.id}`, tickets);
71
- };
116
+ handle = iframe.openPicker({
117
+ eventId: String(event.id),
118
+ onSelect,
119
+ onResize: setAspectRatio,
120
+ onCategories: setCategories,
121
+ baseUrl: seatingIframeUrl,
122
+ clientId: uuid,
123
+ container,
124
+ maxTickets: getMaxTicketsPerOrder(event.id),
125
+ // Read via ref so a theme toggle doesn't remount the picker.
126
+ theme: themeModeRef.current,
127
+ // Site language — release names/descriptions follow the site, not the
128
+ // browser's Accept-Language.
129
+ lang,
130
+ });
131
+ handleRef.current = handle;
132
+
133
+ // Let the host cart's remove button drive deselection in the picker.
134
+ registerDeselect((locationId: string) => handle?.deselect(locationId));
135
+
136
+ return () => {
137
+ registerDeselect(null);
138
+ handleRef.current = null;
139
+ handle?.destroy();
140
+ };
141
+ }, [uuid, seatingIframeUrl, event.id, registerDeselect, lang]);
142
+
143
+ // Follow live theme toggles on the host page.
144
+ useEffect(() => {
145
+ handleRef.current?.setTheme(themeMode);
146
+ }, [themeMode]);
72
147
 
73
148
  if (!seatingIframeUrl) return null;
74
149
 
75
150
  return (
76
- <Button
77
- variant="outlined"
78
- onClick={() =>
79
- iframe.openPicker({
80
- eventId: String(event.id),
81
- onSelect,
82
- baseUrl: seatingIframeUrl,
83
- clientId: uuid,
84
- })
85
- }
86
- sx={{
87
- width: { xs: '100%' },
88
- color: 'text.primary',
89
- borderColor: (theme) => theme.palette.grey['300'],
90
- '& .MuiButton-endIcon': { ml: 0, fontSize: '1.5em' },
91
- }}
92
- endIcon={<Iconify icon="eva:chevron-right-outline" />}
93
- >
94
- {t('form.labels.open_map')}
95
- </Button>
151
+ <Box>
152
+ {categories.length > 0 && (
153
+ <Box
154
+ sx={{
155
+ display: 'grid',
156
+ gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
157
+ gap: 1,
158
+ mb: 1.5,
159
+ }}
160
+ role="list"
161
+ aria-label={t('event.tickets.stepper.1.title')}
162
+ >
163
+ {categories.map((category) => (
164
+ <Stack
165
+ key={category.releaseCategoryId}
166
+ role="listitem"
167
+ direction="row"
168
+ // Tapping a chip pulses that category's seats/zones on the map
169
+ // (the rest dim briefly) — the touch answer to "which seats are
170
+ // these?", where hover doesn't exist.
171
+ onClick={() => handleRef.current?.highlightCategory(category.releaseCategoryId)}
172
+ sx={{
173
+ gap: 1,
174
+ pl: 0.75,
175
+ pr: 1.25,
176
+ py: 0.5,
177
+ borderRadius: 1.5,
178
+ border: (theme) => `1px solid ${theme.palette.divider}`,
179
+ bgcolor: 'background.paper',
180
+ cursor: 'pointer',
181
+ userSelect: 'none',
182
+ '&:hover': {
183
+ borderColor: category.color,
184
+ },
185
+ }}
186
+ >
187
+ {/* Rounded color bar — a distinct swatch rather than a dot. */}
188
+ <Box
189
+ sx={{
190
+ width: 6,
191
+ borderRadius: 3,
192
+ bgcolor: category.color,
193
+ flexShrink: 0,
194
+ alignSelf: 'stretch',
195
+ minHeight: 18,
196
+ }}
197
+ />
198
+ <Box sx={{ minWidth: 0, flex: 1 }}>
199
+ <Stack
200
+ direction="row"
201
+ alignItems="baseline"
202
+ justifyContent="space-between"
203
+ sx={{ gap: 1 }}
204
+ >
205
+ <Typography variant="body2" fontWeight={600} noWrap>
206
+ {category.name}
207
+ </Typography>
208
+ <Typography variant="body2" color="text.secondary" noWrap sx={{ flexShrink: 0 }}>
209
+ {category.hasMultiplePrices
210
+ ? t('price_from', { price: fCurrency(category.price, lang, event.currency) })
211
+ : fCurrency(category.price, lang, event.currency)}
212
+ </Typography>
213
+ </Stack>
214
+ {category.description && (
215
+ <Typography
216
+ variant="caption"
217
+ color="text.secondary"
218
+ // Wraps fully — truncating hides purchase-relevant info,
219
+ // and the title-attribute tooltip is unreachable on touch.
220
+ sx={{ display: 'block', lineHeight: 1.35 }}
221
+ >
222
+ {category.description}
223
+ </Typography>
224
+ )}
225
+ </Box>
226
+ </Stack>
227
+ ))}
228
+ </Box>
229
+ )}
230
+ <Box
231
+ sx={{
232
+ width: '100%',
233
+ // Size to the map's aspect ratio once known (so the frame matches the
234
+ // map); fall back to a fixed height until the map reports it.
235
+ aspectRatio: aspectRatio ?? undefined,
236
+ height: aspectRatio ? undefined : { xs: 440, sm: 560 },
237
+ minHeight: 300,
238
+ maxHeight: '78vh',
239
+ // When the 78vh cap kicks in (very tall maps), also cap the width to
240
+ // the matching aspect-ratio value and center — otherwise the box goes
241
+ // wider than the map and the equal padding breaks.
242
+ maxWidth: aspectRatio ? `calc(78vh * ${aspectRatio})` : undefined,
243
+ mx: 'auto',
244
+ boxSizing: 'border-box',
245
+ // Equal space on all four sides between the card edge and the map.
246
+ p: { xs: 1.5, sm: 2 },
247
+ borderRadius: 2,
248
+ overflow: 'hidden',
249
+ // Follows the host theme; the picker iframe renders the matching
250
+ // surface so card and map read as one piece. Light mode uses a soft
251
+ // gray (admin-map style) — pure white is blinding next to the page.
252
+ bgcolor: (theme) =>
253
+ theme.palette.mode === 'dark'
254
+ ? theme.palette.background.paper
255
+ : theme.palette.grey[200],
256
+ border: (theme) => `1px solid ${theme.palette.divider}`,
257
+ boxShadow: (theme) => theme.shadows[2],
258
+ }}
259
+ >
260
+ <Box ref={containerRef} sx={{ width: '100%', height: '100%' }} />
261
+ </Box>
262
+ </Box>
96
263
  );
97
264
  };
98
265
 
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // The npm-published entry (OrderFormSdk). Consumed by Eventlook's own frontend and
2
+ // existing third parties. DEPRECATED for new third-party distribution — new embeds
3
+ // use the hosted in-page loader (src/embed.tsx → eventlook.js). Kept during the
4
+ // migration window; see frontend/docs/embed-sdk.md.
1
5
  import React from 'react';
2
6
  import OrderForm from '@form/index';
3
7
  import type { OrderFormProps } from '@form/index';
@@ -9,6 +9,7 @@ const cs = {
9
9
  cancel: 'Zrušit',
10
10
  close: 'Zavřít',
11
11
  remove: 'Odstranit',
12
+ price_from: 'od {{price}}',
12
13
  pay: 'Zaplatit',
13
14
  change: 'Změnit',
14
15
  free: 'Zdarma',
@@ -9,6 +9,7 @@ const en = {
9
9
  cancel: 'Cancel',
10
10
  close: 'Close',
11
11
  remove: 'Remove',
12
+ price_from: 'from {{price}}',
12
13
  pay: 'Pay',
13
14
  change: 'Change',
14
15
  free: 'Free',
@@ -9,6 +9,7 @@ const es = {
9
9
  cancel: 'Cancelar',
10
10
  close: 'Cerrar',
11
11
  remove: 'Eliminar',
12
+ price_from: 'desde {{price}}',
12
13
  pay: 'Pagar',
13
14
  change: 'Cambiar',
14
15
  free: 'Gratis',
@@ -9,6 +9,7 @@ const pl = {
9
9
  cancel: 'Anuluj',
10
10
  close: 'Zamknij',
11
11
  remove: 'Usuń',
12
+ price_from: 'od {{price}}',
12
13
  pay: 'Zapłać',
13
14
  change: 'Zmień',
14
15
  free: 'Darmowe',
@@ -9,6 +9,7 @@ const sk = {
9
9
  cancel: 'Zrušiť',
10
10
  close: 'Zavrieť',
11
11
  remove: 'Odstrániť',
12
+ price_from: 'od {{price}}',
12
13
  pay: 'Zaplaťte',
13
14
  change: 'Zmeniť',
14
15
  free: 'Zadarmo',
@@ -9,6 +9,7 @@ const uk = {
9
9
  cancel: 'Скасувати',
10
10
  close: 'Закрити',
11
11
  remove: 'Видалити',
12
+ price_from: 'від {{price}}',
12
13
  pay: 'Оплатити',
13
14
  change: 'Змінити',
14
15
  free: 'Безкоштовно',
@@ -0,0 +1,11 @@
1
+ // Minimal ambient declaration for react-dom/client (used by the standalone embed
2
+ // entry). Avoids pulling @types/react-dom just for createRoot in the IIFE build.
3
+ declare module 'react-dom/client' {
4
+ import { ReactNode } from 'react';
5
+ export interface Root {
6
+ render(children: ReactNode): void;
7
+ unmount(): void;
8
+ }
9
+ export function createRoot(container: Element | DocumentFragment, options?: unknown): Root;
10
+ export function hydrateRoot(container: Element, children: ReactNode, options?: unknown): Root;
11
+ }
@@ -6,6 +6,9 @@ const api = axios.create({
6
6
  'Content-Type': 'application/json',
7
7
  },
8
8
  withCredentials: false,
9
+ // Bound stalled requests so a hung fetch (flaky mobile network) fails instead
10
+ // of leaving the form stuck on its loading skeleton — SWR then auto-retries.
11
+ timeout: 20000,
9
12
  });
10
13
 
11
14
  api.interceptors.response.use(
@@ -0,0 +1,100 @@
1
+ // Render rich-text columns into safe HTML. Accepts both legacy HTML (pre-EVE-94 rows) and ProseMirror JSON (post-EVE-94 rows). Ported from frontend/src/utils/render-rich-text.ts — schema mirrors admin/src/components/editor/rich-text-extensions.ts and backend/src/utils/rich-text-schema.ts — keep all copies in sync.
2
+
3
+ type JSONNode = {
4
+ type?: string;
5
+ attrs?: Record<string, unknown>;
6
+ content?: JSONNode[];
7
+ text?: string;
8
+ marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
9
+ };
10
+
11
+ export function renderRichTextToHtml(content: string | null | undefined): string {
12
+ if (!content || typeof content !== 'string') return '';
13
+ const trimmed = content.trim();
14
+ if (!trimmed) return '';
15
+ if (trimmed.startsWith('{')) {
16
+ try {
17
+ return renderNode(JSON.parse(trimmed) as JSONNode);
18
+ } catch {
19
+ return '';
20
+ }
21
+ }
22
+ return trimmed;
23
+ }
24
+
25
+ function renderNode(node: JSONNode): string {
26
+ if (!node) return '';
27
+ if (node.type === 'text') return renderTextNode(node);
28
+ const children = (node.content ?? []).map(renderNode).join('');
29
+ switch (node.type) {
30
+ case 'doc':
31
+ return children;
32
+ case 'paragraph':
33
+ return `<p>${children}</p>`;
34
+ case 'heading': {
35
+ const level = node.attrs?.level === 3 ? 3 : 2;
36
+ return `<h${level}>${children}</h${level}>`;
37
+ }
38
+ case 'bulletList':
39
+ return `<ul>${children}</ul>`;
40
+ case 'orderedList':
41
+ return `<ol>${children}</ol>`;
42
+ case 'listItem':
43
+ return `<li>${children}</li>`;
44
+ default:
45
+ return children;
46
+ }
47
+ }
48
+
49
+ function renderTextNode(node: JSONNode): string {
50
+ let html = escapeHtml(node.text ?? '');
51
+ for (const mark of node.marks ?? []) {
52
+ html = applyMark(mark, html);
53
+ }
54
+ return html;
55
+ }
56
+
57
+ function applyMark(mark: { type: string; attrs?: Record<string, unknown> }, inner: string): string {
58
+ switch (mark.type) {
59
+ case 'bold':
60
+ return `<strong>${inner}</strong>`;
61
+ case 'italic':
62
+ return `<em>${inner}</em>`;
63
+ case 'underline':
64
+ return `<u>${inner}</u>`;
65
+ case 'link': {
66
+ const href = String(mark.attrs?.href ?? '');
67
+ if (!href || !isSafeHref(href)) return inner;
68
+ return `<a href="${escapeAttr(href)}" rel="noopener noreferrer nofollow" target="_blank">${inner}</a>`;
69
+ }
70
+ default:
71
+ return inner;
72
+ }
73
+ }
74
+
75
+ function escapeHtml(s: string): string {
76
+ return s
77
+ .replace(/&/g, '&amp;')
78
+ .replace(/</g, '&lt;')
79
+ .replace(/>/g, '&gt;')
80
+ .replace(/"/g, '&quot;')
81
+ .replace(/'/g, '&#39;');
82
+ }
83
+
84
+ function escapeAttr(s: string): string {
85
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
86
+ }
87
+
88
+ // Block javascript:/data: URIs; allow http(s)/mailto/tel/relative.
89
+ function isSafeHref(href: string): boolean {
90
+ return /^(https?:|mailto:|tel:|\/|#)/i.test(href);
91
+ }
92
+
93
+ // True when the content has no renderable text (empty editor → '' or an empty paragraph doc).
94
+ export function isEmptyRichText(content: string | null | undefined): boolean {
95
+ return (
96
+ renderRichTextToHtml(content)
97
+ .replace(/<[^>]*>/g, '')
98
+ .trim() === ''
99
+ );
100
+ }
@@ -22,6 +22,7 @@ export interface IEvent extends IBase {
22
22
  releaseEndDate: string;
23
23
  place: IPlace;
24
24
  description: string;
25
+ purchaseInstructions: string | null;
25
26
  image: IFile;
26
27
  verified: boolean;
27
28
  ageLimit: number | null;