@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.
- package/.gitlab-ci.yml +51 -0
- package/README.md +7 -0
- package/dist/cjs/index.js +25692 -16
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +25674 -12
- package/dist/esm/index.js.map +1 -1
- package/dist/types/components/RichText.d.ts +7 -0
- package/dist/types/context/SeatPickerContext.d.ts +13 -0
- package/dist/types/embed.d.ts +49 -0
- package/dist/types/locales/cs.d.ts +1 -0
- package/dist/types/locales/en.d.ts +1 -0
- package/dist/types/locales/es.d.ts +1 -0
- package/dist/types/locales/pl.d.ts +1 -0
- package/dist/types/locales/sk.d.ts +1 -0
- package/dist/types/locales/uk.d.ts +1 -0
- package/dist/types/utils/render-rich-text.d.ts +2 -0
- package/dist/types/utils/types/event.type.d.ts +1 -0
- package/package.json +4 -2
- package/rollup.embed.config.mjs +74 -0
- package/src/components/RichText.tsx +33 -0
- package/src/context/SeatPickerContext.tsx +35 -0
- package/src/embed.tsx +243 -0
- package/src/form/Payment.tsx +6 -1
- package/src/form/PaymentOverviewBox.tsx +35 -19
- package/src/form/PaymentPending.tsx +7 -24
- package/src/form/ReleaseWithMerchandise.tsx +8 -1
- package/src/form/TicketForm.tsx +204 -164
- package/src/form/services/index.tsx +1 -0
- package/src/form/tickets/TicketSelectionMap.tsx +240 -73
- package/src/index.ts +4 -0
- package/src/locales/cs.tsx +1 -0
- package/src/locales/en.tsx +1 -0
- package/src/locales/es.tsx +1 -0
- package/src/locales/pl.tsx +1 -0
- package/src/locales/sk.tsx +1 -0
- package/src/locales/uk.tsx +1 -0
- package/src/react-dom-client.d.ts +11 -0
- package/src/utils/axios.ts +3 -0
- package/src/utils/render-rich-text.ts +100 -0
- package/src/utils/types/event.type.ts +1 -0
- package/dist/cjs/index-Dh-sV_wk.js +0 -41946
- package/dist/cjs/index-Dh-sV_wk.js.map +0 -1
- package/dist/cjs/index.umd-CJ-ZDtw8.js +0 -13397
- package/dist/cjs/index.umd-CJ-ZDtw8.js.map +0 -1
- package/dist/esm/index-DPHu9Sfs.js +0 -41925
- package/dist/esm/index-DPHu9Sfs.js.map +0 -1
- package/dist/esm/index.umd-DPIwz_aJ.js +0 -13395
- 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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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';
|
package/src/locales/cs.tsx
CHANGED
package/src/locales/en.tsx
CHANGED
package/src/locales/es.tsx
CHANGED
package/src/locales/pl.tsx
CHANGED
package/src/locales/sk.tsx
CHANGED
package/src/locales/uk.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/axios.ts
CHANGED
|
@@ -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, '&')
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"')
|
|
81
|
+
.replace(/'/g, ''');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeAttr(s: string): string {
|
|
85
|
+
return s.replace(/&/g, '&').replace(/"/g, '"');
|
|
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
|
+
}
|