@eventlook/sdk 1.7.0 → 1.7.2-beta.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.
Files changed (78) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.prettierignore +3 -0
  3. package/CLAUDE.md +33 -0
  4. package/dist/cjs/{index-0a8uUeDg.js → index-DmK9RPSa.js} +26553 -25757
  5. package/dist/cjs/index-DmK9RPSa.js.map +1 -0
  6. package/dist/cjs/index.js +3 -1
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/{index.umd-BvTBfvwB.js → index.umd-DUMMTVwU.js} +2 -2
  9. package/dist/cjs/{index.umd-BvTBfvwB.js.map → index.umd-DUMMTVwU.js.map} +1 -1
  10. package/dist/cjs/{mui-tel-input.es-Bjml407E.js → mui-tel-input.es-Dk9M_v4X.js} +6 -6
  11. package/dist/{esm/mui-tel-input.es-Bt2rE3An.js.map → cjs/mui-tel-input.es-Dk9M_v4X.js.map} +1 -1
  12. package/dist/esm/{index-ByLnhSXB.js → index-C0HcmMMr.js} +27111 -26333
  13. package/dist/esm/index-C0HcmMMr.js.map +1 -0
  14. package/dist/esm/index.js +3 -1
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/{index.umd-DepuOxm3.js → index.umd-BqJOlKvJ.js} +4 -4
  17. package/dist/esm/{index.umd-DepuOxm3.js.map → index.umd-BqJOlKvJ.js.map} +1 -1
  18. package/dist/esm/{mui-tel-input.es-Bt2rE3An.js → mui-tel-input.es-Cb4Lpqx7.js} +21 -21
  19. package/dist/{cjs/mui-tel-input.es-Bjml407E.js.map → esm/mui-tel-input.es-Cb4Lpqx7.js.map} +1 -1
  20. package/dist/types/form/paydroid/PaydroidCashlessSection.d.ts +7 -0
  21. package/dist/types/form/paydroid/PaydroidError.d.ts +3 -0
  22. package/dist/types/form/paydroid/PaydroidErrorAccount.d.ts +3 -0
  23. package/dist/types/form/paydroid/PaydroidErrorTicket.d.ts +3 -0
  24. package/dist/types/form/paydroid/PaydroidPage.d.ts +6 -0
  25. package/dist/types/form/paydroid/PaydroidStatusCard.d.ts +10 -0
  26. package/dist/types/form/paydroid/PaydroidSuccess.d.ts +3 -0
  27. package/dist/types/form/paydroid/PaydroidSuccessTopup.d.ts +3 -0
  28. package/dist/types/form/tickets/PrimaryTicketDialog.d.ts +10 -0
  29. package/dist/types/locales/cs.d.ts +38 -0
  30. package/dist/types/locales/en.d.ts +40 -1
  31. package/dist/types/locales/es.d.ts +40 -1
  32. package/dist/types/locales/pl.d.ts +40 -1
  33. package/dist/types/locales/sk.d.ts +40 -1
  34. package/dist/types/locales/uk.d.ts +40 -1
  35. package/dist/types/modules/paydroid.d.ts +4 -0
  36. package/dist/types/modules/ticket.d.ts +1 -0
  37. package/dist/types/utils/data/page.d.ts +7 -0
  38. package/dist/types/utils/page.d.ts +1 -0
  39. package/dist/types/utils/paydroid.d.ts +6 -0
  40. package/dist/types/utils/types/global.type.d.ts +1 -0
  41. package/dist/types/utils/types/order.type.d.ts +2 -0
  42. package/dist/types/utils/types/paydroid.d.ts +23 -0
  43. package/dist/types/utils/types/release-category.type.d.ts +1 -0
  44. package/dist/types/utils/types/release.type.d.ts +2 -0
  45. package/dist/types/utils/types/ticket.type.d.ts +1 -0
  46. package/package.json +3 -1
  47. package/src/form/PaymentSuccess.tsx +6 -2
  48. package/src/form/TicketForm.tsx +28 -1
  49. package/src/form/paydroid/PaydroidCashlessSection.tsx +311 -0
  50. package/src/form/paydroid/PaydroidError.tsx +26 -0
  51. package/src/form/paydroid/PaydroidErrorAccount.tsx +26 -0
  52. package/src/form/paydroid/PaydroidErrorTicket.tsx +26 -0
  53. package/src/form/paydroid/PaydroidPage.tsx +22 -0
  54. package/src/form/paydroid/PaydroidStatusCard.tsx +91 -0
  55. package/src/form/paydroid/PaydroidSuccess.tsx +26 -0
  56. package/src/form/paydroid/PaydroidSuccessTopup.tsx +26 -0
  57. package/src/form/tickets/PrimaryTicketDialog.tsx +144 -0
  58. package/src/form/tickets/ReleaseWithMerchandise.tsx +89 -8
  59. package/src/form/tickets/TicketSelectionMobile.tsx +80 -12
  60. package/src/locales/cs.tsx +45 -0
  61. package/src/locales/en.tsx +47 -1
  62. package/src/locales/es.tsx +48 -1
  63. package/src/locales/pl.tsx +47 -1
  64. package/src/locales/sk.tsx +49 -1
  65. package/src/locales/uk.tsx +47 -2
  66. package/src/modules/paydroid.ts +33 -0
  67. package/src/modules/ticket.ts +13 -0
  68. package/src/utils/data/page.ts +7 -0
  69. package/src/utils/page.ts +4 -0
  70. package/src/utils/paydroid.ts +35 -0
  71. package/src/utils/types/global.type.ts +1 -0
  72. package/src/utils/types/order.type.ts +2 -0
  73. package/src/utils/types/paydroid.ts +26 -0
  74. package/src/utils/types/release-category.type.ts +1 -0
  75. package/src/utils/types/release.type.ts +2 -0
  76. package/src/utils/types/ticket.type.ts +1 -0
  77. package/dist/cjs/index-0a8uUeDg.js.map +0 -1
  78. package/dist/esm/index-ByLnhSXB.js.map +0 -1
@@ -0,0 +1,144 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogActions,
6
+ DialogContent,
7
+ DialogTitle,
8
+ Stack,
9
+ TextField,
10
+ Typography,
11
+ } from '@mui/material';
12
+ import useGlobal from '@hooks/useGlobal';
13
+
14
+ interface Props {
15
+ open: boolean;
16
+ onClose: () => void;
17
+ onConfirm: (number: string) => void;
18
+ usedNumbers?: string[];
19
+ validateRemote?: (number: string) => Promise<void>;
20
+ }
21
+
22
+ const PrimaryTicketDialog: React.FC<Props> = ({
23
+ open,
24
+ onClose,
25
+ onConfirm,
26
+ usedNumbers = [],
27
+ validateRemote,
28
+ }) => {
29
+ const { t, options } = useGlobal();
30
+ const [value, setValue] = useState('');
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [isChecking, setIsChecking] = useState(false);
33
+
34
+ useEffect(() => {
35
+ if (open) {
36
+ setValue('');
37
+ setError(null);
38
+ setIsChecking(false);
39
+ const urlParams = new URLSearchParams(new URL(window.location.href).search);
40
+ const elementId = urlParams.get('elementId');
41
+ parent.postMessage({ type: 'eventlookFrameOpenDialog', name: elementId }, '*');
42
+ }
43
+ }, [open]);
44
+
45
+ const validateLocal = (next: string): string | null => {
46
+ if (!next) return t('form.validation.primary_ticket_required');
47
+ if (!/^\d+$/.test(next)) return t('form.validation.primary_ticket_numeric');
48
+ if (usedNumbers.includes(next)) return t('form.validation.primary_ticket_duplicate');
49
+ return null;
50
+ };
51
+
52
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
53
+ const next = e.target.value;
54
+ setValue(next);
55
+ if (error) setError(validateLocal(next));
56
+ };
57
+
58
+ const handleConfirm = async () => {
59
+ const message = validateLocal(value);
60
+ if (message) {
61
+ setError(message);
62
+ return;
63
+ }
64
+ if (!validateRemote) {
65
+ onConfirm(value);
66
+ return;
67
+ }
68
+ setIsChecking(true);
69
+ try {
70
+ await validateRemote(value);
71
+ onConfirm(value);
72
+ } catch (err: unknown) {
73
+ const remoteMessage =
74
+ (err as { message?: string } | null)?.message ?? t('event.tickets.error.order');
75
+ setError(remoteMessage);
76
+ } finally {
77
+ setIsChecking(false);
78
+ }
79
+ };
80
+
81
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
82
+ if (e.key === 'Enter') {
83
+ e.preventDefault();
84
+ handleConfirm();
85
+ }
86
+ };
87
+
88
+ return (
89
+ <Dialog
90
+ open={open}
91
+ onClose={onClose}
92
+ fullWidth
93
+ slotProps={{ paper: { sx: { borderRadius: 2 } } }}
94
+ sx={{
95
+ mx: 'auto',
96
+ width: { xs: '100%', sm: 'calc(100vh - 300px)' },
97
+ minWidth: 300,
98
+ maxWidth: 444,
99
+ '& .MuiDialog-container': {
100
+ height: options?.isIframe ? 'auto' : '100%',
101
+ },
102
+ }}
103
+ >
104
+ <DialogTitle sx={{ textAlign: 'center', fontWeight: 700, fontSize: '1.25rem', pt: 3, pb: 1 }}>
105
+ {t('form.labels.primary_ticket_dialog_title')}
106
+ </DialogTitle>
107
+ <DialogContent sx={{ pb: 1 }}>
108
+ <Stack spacing={2.5}>
109
+ <Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
110
+ {t('form.labels.primary_ticket_number_hint')}
111
+ </Typography>
112
+ <TextField
113
+ autoFocus
114
+ value={value}
115
+ onChange={handleChange}
116
+ onKeyDown={handleKeyDown}
117
+ label={t('form.labels.primary_ticket_dialog_input_label')}
118
+ fullWidth
119
+ inputMode="numeric"
120
+ disabled={isChecking}
121
+ error={!!error}
122
+ helperText={error || ' '}
123
+ />
124
+ </Stack>
125
+ </DialogContent>
126
+ <DialogActions sx={{ px: 3, pb: 3, gap: 1 }}>
127
+ <Button
128
+ onClick={onClose}
129
+ disabled={isChecking}
130
+ variant="outlined"
131
+ color="inherit"
132
+ fullWidth
133
+ >
134
+ {t('cancel')}
135
+ </Button>
136
+ <Button variant="contained" onClick={handleConfirm} disabled={isChecking} fullWidth>
137
+ {t('confirm')}
138
+ </Button>
139
+ </DialogActions>
140
+ </Dialog>
141
+ );
142
+ };
143
+
144
+ export default PrimaryTicketDialog;
@@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react';
2
2
  import { Box, Stack, Typography } from '@mui/material';
3
3
  import ProductVariantsDialog from '@form/product/ProductVariantsDialog';
4
4
  import TicketQuantityControl from '@form/tickets/TicketQuantityControl';
5
+ import PrimaryTicketDialog from '@form/tickets/PrimaryTicketDialog';
5
6
  import { IReleaseShort } from '@utils/types/release.type';
6
7
  import { ITicketForm, ITicketFormTicket } from '@utils/types/ticket.type';
7
8
  import { useFormContext, useWatch } from 'react-hook-form';
@@ -12,6 +13,7 @@ import { MAX_TICKETS_PER_ORDER } from '@utils/data/ticket';
12
13
  import { getSelectedQuantityByVariant } from '@utils/product';
13
14
  import ReleaseExtraFields from '@form/extra-field/ReleaseExtraFields';
14
15
  import ReleaseDescription from '@form/tickets/ReleaseDescription';
16
+ import { validatePrimaryTicketNumber } from '@modules/ticket';
15
17
  import useGlobal from '@hooks/useGlobal';
16
18
 
17
19
  interface Props {
@@ -32,6 +34,8 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
32
34
  const { t, lang } = useGlobal();
33
35
  const [openVariantDialog, setOpenVariantDialog] = useState<'add' | 'increase' | null>(null);
34
36
  const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
37
+ const [primaryDialogMode, setPrimaryDialogMode] = useState<'add' | 'increase' | null>(null);
38
+ const [pendingPrimaryNumber, setPendingPrimaryNumber] = useState<string | null>(null);
35
39
  const { setValue } = useFormContext<ITicketForm>();
36
40
  const tickets: ITicketFormTicket[] = useWatch({ name: `tickets.${eventId}`, defaultValue: [] });
37
41
  const products: IEventProductForm[] = useWatch({ name: `products.${eventId}`, defaultValue: [] });
@@ -65,7 +69,10 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
65
69
  return getSelectedQuantity(releaseId) >= getAvailableTicketsForRelease(release);
66
70
  };
67
71
 
68
- const addRelease = (productsToAdd?: IEventProductForm[] | IEventProductForm) => {
72
+ const addRelease = (
73
+ productsToAdd?: IEventProductForm[] | IEventProductForm,
74
+ primaryTicketNumber?: string
75
+ ) => {
69
76
  const normalizedProducts = Array.isArray(productsToAdd)
70
77
  ? productsToAdd
71
78
  : productsToAdd
@@ -100,12 +107,17 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
100
107
  price: 0,
101
108
  products: selectedProducts,
102
109
  extraFields,
110
+ primaryTicketNumbers: release.requiresPrimaryTicket
111
+ ? primaryTicketNumber !== undefined
112
+ ? [primaryTicketNumber]
113
+ : []
114
+ : undefined,
103
115
  },
104
116
  ]);
105
117
  setOpenVariantDialog(null);
106
118
  };
107
119
 
108
- const increaseQuantity = (productsToAdd?: IEventProductForm[]) => {
120
+ const increaseQuantity = (productsToAdd?: IEventProductForm[], primaryTicketNumber?: string) => {
109
121
  const normalizedProducts = productsToAdd ?? [];
110
122
  const addedRelease = tickets.find((ticket) => ticket.releaseId === release.id);
111
123
  if (addedRelease) {
@@ -140,6 +152,11 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
140
152
  extraFields: release.extraFields?.length
141
153
  ? [...ticket.extraFields, ...extraFieldsToAdd]
142
154
  : [],
155
+ primaryTicketNumbers: release.requiresPrimaryTicket
156
+ ? primaryTicketNumber !== undefined
157
+ ? [...(ticket.primaryTicketNumbers ?? []), primaryTicketNumber]
158
+ : ticket.primaryTicketNumbers
159
+ : ticket.primaryTicketNumbers,
143
160
  }
144
161
  : ticket
145
162
  )
@@ -168,6 +185,7 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
168
185
  quantity: newQuantity,
169
186
  products: ticket?.products?.slice(0, -1), // non-mutating "pop"
170
187
  extraFields: ticket?.extraFields?.slice(0, -1),
188
+ primaryTicketNumbers: ticket.primaryTicketNumbers?.slice(0, -1),
171
189
  };
172
190
  })
173
191
  );
@@ -175,6 +193,7 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
175
193
  }
176
194
  }, [tickets, release.id, setValue]);
177
195
 
196
+ const ticketIndex = tickets.findIndex((t) => t.releaseId === release.id);
178
197
  const remainingOrderCapacity = Math.max(0, MAX_TICKETS_PER_ORDER - countSelectedTickets());
179
198
  const nextRelease = activeReleases?.find(
180
199
  (item) =>
@@ -225,10 +244,28 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
225
244
  }
226
245
  addLabel={t('add')}
227
246
  onDecrement={() => decreaseQuantity()}
228
- onIncrement={() =>
229
- release.product ? setOpenVariantDialog('increase') : increaseQuantity()
230
- }
231
- onAddFirst={() => (release.product ? setOpenVariantDialog('add') : addRelease())}
247
+ onIncrement={() => {
248
+ if (release.requiresPrimaryTicket) {
249
+ setPrimaryDialogMode('increase');
250
+ return;
251
+ }
252
+ if (release.product) {
253
+ setOpenVariantDialog('increase');
254
+ } else {
255
+ increaseQuantity();
256
+ }
257
+ }}
258
+ onAddFirst={() => {
259
+ if (release.requiresPrimaryTicket) {
260
+ setPrimaryDialogMode('add');
261
+ return;
262
+ }
263
+ if (release.product) {
264
+ setOpenVariantDialog('add');
265
+ } else {
266
+ addRelease();
267
+ }
268
+ }}
232
269
  />
233
270
  </Stack>
234
271
 
@@ -247,12 +284,56 @@ const ReleaseWithMerchandise: React.FC<Props> = ({
247
284
  quantity={countTickets}
248
285
  />
249
286
  )}
287
+ {release.requiresPrimaryTicket && countTickets > 0 && ticketIndex >= 0 && (
288
+ <Stack spacing={0.5} mt={1.5}>
289
+ {(tickets[ticketIndex]?.primaryTicketNumbers ?? []).map((num, i) => (
290
+ <Typography key={i} variant="caption" color="text.secondary">
291
+ {t('form.labels.primary_ticket_number', { number: i + 1 })}: {num}
292
+ </Typography>
293
+ ))}
294
+ </Stack>
295
+ )}
296
+ {release.requiresPrimaryTicket && (
297
+ <PrimaryTicketDialog
298
+ open={!!primaryDialogMode}
299
+ onClose={() => setPrimaryDialogMode(null)}
300
+ usedNumbers={tickets.flatMap((ticket) => ticket.primaryTicketNumbers ?? [])}
301
+ validateRemote={(number) =>
302
+ validatePrimaryTicketNumber(release.releaseCategoryId, number)
303
+ }
304
+ onConfirm={(number) => {
305
+ const mode = primaryDialogMode;
306
+ setPrimaryDialogMode(null);
307
+ if (release.product) {
308
+ setPendingPrimaryNumber(number);
309
+ setOpenVariantDialog(mode);
310
+ return;
311
+ }
312
+ if (mode === 'increase') {
313
+ increaseQuantity(undefined, number);
314
+ } else {
315
+ addRelease(undefined, number);
316
+ }
317
+ }}
318
+ />
319
+ )}
250
320
  {release.product && (
251
321
  <ProductVariantsDialog
252
322
  eventProduct={release.product}
253
323
  openDialog={!!openVariantDialog}
254
- callback={openVariantDialog === 'increase' ? increaseQuantity : addRelease}
255
- onClose={() => setOpenVariantDialog(null)}
324
+ callback={(variants) => {
325
+ const number = pendingPrimaryNumber ?? undefined;
326
+ setPendingPrimaryNumber(null);
327
+ if (openVariantDialog === 'increase') {
328
+ increaseQuantity(variants, number);
329
+ } else {
330
+ addRelease(variants, number);
331
+ }
332
+ }}
333
+ onClose={() => {
334
+ setPendingPrimaryNumber(null);
335
+ setOpenVariantDialog(null);
336
+ }}
256
337
  selectedQuantityByVariant={getSelectedQuantityByVariant(products, tickets)}
257
338
  eventId={eventId}
258
339
  canAddOnlyOneAtATime
@@ -6,8 +6,10 @@ import { IReleaseShort } from '@utils/types/release.type';
6
6
  import { ITicketFormTicket } from '@utils/types/ticket.type';
7
7
  import { MAX_TICKETS_PER_ORDER } from '@utils/data/ticket';
8
8
  import useGlobal from '@hooks/useGlobal';
9
+ import { validatePrimaryTicketNumber } from '@modules/ticket';
9
10
  import ReleaseDescription from './ReleaseDescription';
10
11
  import TicketQuantityControl from './TicketQuantityControl';
12
+ import PrimaryTicketDialog from './PrimaryTicketDialog';
11
13
 
12
14
  interface Props {
13
15
  event: IEvent;
@@ -32,6 +34,7 @@ const TicketSelectionMobile: React.FC<Props> = ({
32
34
  }) => {
33
35
  const { t, lang } = useGlobal();
34
36
  const [expandedReleaseIds, setExpandedReleaseIds] = useState<Record<number, boolean>>({});
37
+ const [pendingPrimaryRelease, setPendingPrimaryRelease] = useState<IReleaseShort | null>(null);
35
38
  const theme = useTheme();
36
39
  const isLight = theme.palette.mode === 'light';
37
40
 
@@ -49,18 +52,22 @@ const TicketSelectionMobile: React.FC<Props> = ({
49
52
  const isReleaseVisible = (release: IReleaseShort) =>
50
53
  !release.locked || getReleaseQuantity(release.id) > 0;
51
54
 
52
- const updateReleaseQuantity = (release: IReleaseShort, nextQuantity: number) => {
55
+ const incrementRelease = (release: IReleaseShort, primaryTicketNumber?: string) => {
53
56
  const maxAvailable = Math.min(release.availableTickets || 0, MAX_TICKETS_PER_ORDER);
54
- const clampedQuantity = Math.max(0, Math.min(nextQuantity, maxAvailable));
55
57
  const ticketIndex = getTicketIndexByRelease(release.id);
56
-
57
- if (clampedQuantity <= 0) {
58
- if (ticketIndex >= 0) removeTicket(ticketIndex);
59
- return;
60
- }
58
+ const currentQuantity = ticketIndex >= 0 ? Number(tickets[ticketIndex]?.quantity || 0) : 0;
59
+ const nextQuantity = Math.min(currentQuantity + 1, maxAvailable);
60
+ if (nextQuantity === currentQuantity) return;
61
61
 
62
62
  if (ticketIndex >= 0) {
63
- setValue(`tickets.${event.id}.${ticketIndex}.quantity`, clampedQuantity);
63
+ setValue(`tickets.${event.id}.${ticketIndex}.quantity`, nextQuantity);
64
+ if (release.requiresPrimaryTicket && primaryTicketNumber !== undefined) {
65
+ const current = tickets[ticketIndex]?.primaryTicketNumbers ?? [];
66
+ setValue(`tickets.${event.id}.${ticketIndex}.primaryTicketNumbers`, [
67
+ ...current,
68
+ primaryTicketNumber,
69
+ ]);
70
+ }
64
71
  return;
65
72
  }
66
73
 
@@ -68,15 +75,56 @@ const TicketSelectionMobile: React.FC<Props> = ({
68
75
  ...tickets,
69
76
  {
70
77
  releaseId: release.id,
71
- quantity: clampedQuantity,
78
+ quantity: nextQuantity,
72
79
  itemName: getReleaseTitle(release),
73
80
  price: release.price || 0,
74
81
  products: [],
75
82
  extraFields: [],
83
+ primaryTicketNumbers: release.requiresPrimaryTicket
84
+ ? primaryTicketNumber !== undefined
85
+ ? [primaryTicketNumber]
86
+ : []
87
+ : undefined,
76
88
  },
77
89
  ]);
78
90
  };
79
91
 
92
+ const decrementRelease = (release: IReleaseShort) => {
93
+ const ticketIndex = getTicketIndexByRelease(release.id);
94
+ if (ticketIndex < 0) return;
95
+ const currentQuantity = Number(tickets[ticketIndex]?.quantity || 0);
96
+ const nextQuantity = currentQuantity - 1;
97
+
98
+ if (nextQuantity <= 0) {
99
+ removeTicket(ticketIndex);
100
+ return;
101
+ }
102
+
103
+ setValue(`tickets.${event.id}.${ticketIndex}.quantity`, nextQuantity);
104
+ if (release.requiresPrimaryTicket) {
105
+ const current = tickets[ticketIndex]?.primaryTicketNumbers ?? [];
106
+ setValue(
107
+ `tickets.${event.id}.${ticketIndex}.primaryTicketNumbers`,
108
+ current.slice(0, nextQuantity)
109
+ );
110
+ }
111
+ };
112
+
113
+ const handleIncrementClick = (release: IReleaseShort) => {
114
+ if (release.requiresPrimaryTicket) {
115
+ setPendingPrimaryRelease(release);
116
+ return;
117
+ }
118
+ incrementRelease(release);
119
+ };
120
+
121
+ const handlePrimaryTicketConfirm = (number: string) => {
122
+ if (pendingPrimaryRelease) {
123
+ incrementRelease(pendingPrimaryRelease, number);
124
+ }
125
+ setPendingPrimaryRelease(null);
126
+ };
127
+
80
128
  const toggleReleaseDescription = (releaseId: number) =>
81
129
  setExpandedReleaseIds((prev) => ({
82
130
  ...prev,
@@ -162,9 +210,9 @@ const TicketSelectionMobile: React.FC<Props> = ({
162
210
  canAddFirst={canAddFirst}
163
211
  canAddMore={canAddMore}
164
212
  addLabel={t('add')}
165
- onDecrement={() => updateReleaseQuantity(release, quantity - 1)}
166
- onIncrement={() => updateReleaseQuantity(release, quantity + 1)}
167
- onAddFirst={() => updateReleaseQuantity(release, 1)}
213
+ onDecrement={() => decrementRelease(release)}
214
+ onIncrement={() => handleIncrementClick(release)}
215
+ onAddFirst={() => handleIncrementClick(release)}
168
216
  />
169
217
  </Stack>
170
218
 
@@ -177,10 +225,30 @@ const TicketSelectionMobile: React.FC<Props> = ({
177
225
  />
178
226
 
179
227
  {ticketIndex >= 0 && getExtraFields(release.id, ticketIndex)}
228
+ {release.requiresPrimaryTicket && quantity > 0 && ticketIndex >= 0 && (
229
+ <Stack spacing={0.5} mt={1.5}>
230
+ {(tickets[ticketIndex]?.primaryTicketNumbers ?? []).map((num, i) => (
231
+ <Typography key={i} variant="caption" color="text.secondary">
232
+ {t('form.labels.primary_ticket_number', { number: i + 1 })}: {num}
233
+ </Typography>
234
+ ))}
235
+ </Stack>
236
+ )}
180
237
  </Stack>
181
238
  </Box>
182
239
  );
183
240
  })}
241
+ <PrimaryTicketDialog
242
+ open={!!pendingPrimaryRelease}
243
+ onClose={() => setPendingPrimaryRelease(null)}
244
+ onConfirm={handlePrimaryTicketConfirm}
245
+ usedNumbers={tickets.flatMap((ticket) => ticket.primaryTicketNumbers ?? [])}
246
+ validateRemote={(number) =>
247
+ pendingPrimaryRelease
248
+ ? validatePrimaryTicketNumber(pendingPrimaryRelease.releaseCategoryId, number)
249
+ : Promise.resolve()
250
+ }
251
+ />
184
252
  </Stack>
185
253
  );
186
254
  };
@@ -59,6 +59,12 @@ const cs = {
59
59
  payment_overview_close: 'Zavřít přehled platby',
60
60
  ticket_quantity_decrease: 'Snížit počet vstupenek',
61
61
  ticket_quantity_increase: 'Zvýšit počet vstupenek',
62
+ primary_ticket_number: 'Číslo primární vstupenky #{{number}}',
63
+ primary_ticket_number_hint:
64
+ 'Tato kategorie je doplňková — zadejte číslo již zakoupené vstupenky.',
65
+ primary_ticket_dialog_title: 'Zadejte číslo primární vstupenky',
66
+ primary_ticket_dialog_input_label: 'Číslo primární vstupenky',
67
+ amount: 'Částka',
62
68
  },
63
69
  validation: {
64
70
  required: 'Toto pole je povinné.',
@@ -73,6 +79,9 @@ const cs = {
73
79
  count_tickets_or_products: 'Musíte vybrat alespoň 1 vstupenku nebo produkt.',
74
80
  insurance_terms_and_conditions:
75
81
  'Musíte souhlasit s pojistnými podmínkami pojištění a informačním dokumentem o tomto pojistném produktu (IPID).',
82
+ primary_ticket_required: 'Zadejte číslo primární vstupenky.',
83
+ primary_ticket_numeric: 'Číslo vstupenky smí obsahovat pouze číslice.',
84
+ primary_ticket_duplicate: 'Toto číslo jste v této objednávce již použili.',
76
85
  },
77
86
  },
78
87
  event: {
@@ -177,6 +186,42 @@ const cs = {
177
186
  button: 'Stáhnout vstupenku',
178
187
  products: 'Vaše produkty',
179
188
  },
189
+ paydroid: {
190
+ label: 'Paydroid Cashless',
191
+ heading: 'Vyhněte se frontám!',
192
+ heading_cta: 'Přednabijte si',
193
+ subtitle:
194
+ 'Na akci lze platit pouze bezhotovostně pomocí systému Paydroid Cashless. Přednabijte si částku teď a vyhněte se frontám!',
195
+ amount_label: 'Jakou částku chcete přednabít?',
196
+ amount_placeholder: 'Zadejte částku',
197
+ promo: 'Přednabijte si na akci částku vyšší než 1000 Kč a získejte pivo zdarma!',
198
+ terms_prefix: 'Souhlasím s',
199
+ terms_conditions: 'Obchodními podmínkami',
200
+ terms_middle: 'Paydroid Cashless a se',
201
+ terms_privacy: 'Zásadami zpracování osobních údajů',
202
+ terms: 'Souhlas VOP a GDPR Paydroid Cashless',
203
+ skip: 'chci pouze vstupenku v aplikaci',
204
+ submit: 'přednabít',
205
+ success_title: 'Vstupenku jsme přidali k vašemu účtu',
206
+ success_subtitle:
207
+ 'Teď už se stačí jen přihlásit do aplikace Paydroid Cashless. A pokud se budete chtít vyhnout frontám, přednabijte si v aplikaci!',
208
+ topup_success_title: 'Děkujeme za přednabitî!',
209
+ topup_success_subtitle:
210
+ 'Právě jsme připsali kredit na Váš účet Paydroid Cashless, nyní už se stačí pouze přihlásit v aplikaci.',
211
+ error_title: 'Přednabití se nezdařilo',
212
+ error_subtitle:
213
+ 'Nebyli jste nijak zpoplatněni, v případě jakýchkoliv problémů nás prosím kontaktujte.',
214
+ error_account_title: 'Nepodařilo se vytvořit účet',
215
+ error_account_subtitle:
216
+ 'Pravděpodobně jste zadali údaje, které se liší se záznamem v naší databázi.',
217
+ error_account_button: 'přihlásit se do aplikace',
218
+ error_ticket_title: 'Přidání vstupenky se nezdařilo',
219
+ error_ticket_subtitle:
220
+ 'Omlouváme se, vyzkoušejte to prosím později. V případě jakýchkoliv problémů nás prosím kontaktujte.',
221
+ faq_button: 'nejčastější dotazy',
222
+ retry_button: 'vyzkoušet znovu',
223
+ missing_api_key: 'Paydroid API klíč chybí',
224
+ },
180
225
  unpaid: 'Platba neproběhla',
181
226
  unpaid_description: 'Zkuste to prosím znovu kliknutím na tlačítko Zaplatit.',
182
227
  },
@@ -59,6 +59,12 @@ const en = {
59
59
  payment_overview_close: 'Close payment overview',
60
60
  ticket_quantity_decrease: 'Decrease ticket quantity',
61
61
  ticket_quantity_increase: 'Increase ticket quantity',
62
+ primary_ticket_number: 'Primary ticket number #{{number}}',
63
+ primary_ticket_number_hint:
64
+ 'This is an addon category — enter the number of an already-purchased ticket.',
65
+ primary_ticket_dialog_title: 'Enter primary ticket number',
66
+ primary_ticket_dialog_input_label: 'Primary ticket number',
67
+ amount: 'Amount',
62
68
  },
63
69
  validation: {
64
70
  required: 'This field is required.',
@@ -71,6 +77,11 @@ const en = {
71
77
  promo_code_cant_combine: 'This promo code cannot be combined with other codes.',
72
78
  min_purchase_not_met: 'Minimum purchase amount not met: ',
73
79
  count_tickets_or_products: 'You must select at least 1 ticket or product.',
80
+ insurance_terms_and_conditions:
81
+ 'You must agree to the insurance terms and conditions and the insurance product information document (IPID).',
82
+ primary_ticket_required: 'Enter the primary ticket number.',
83
+ primary_ticket_numeric: 'Ticket number must contain digits only.',
84
+ primary_ticket_duplicate: 'You have already entered this number in this order.',
74
85
  },
75
86
  },
76
87
  event: {
@@ -120,9 +131,10 @@ const en = {
120
131
  'I agree with the <0>Terms and Conditions</0> {{termsAndConditionsCompanies}} and the <1>Privacy Policy</1>.',
121
132
  insurance: {
122
133
  label: 'Ticket insurance',
134
+ checkbox:
135
+ 'I confirm that I have read the <0>insurance terms and conditions</0> and the <1>insurance product information document (IPID)</1>',
123
136
  per_ticket: 'pc',
124
137
  modal: {
125
- title: 'Ticket Insurance',
126
138
  description: [
127
139
  'Cancellation fee insurance provides you with protection in case of unexpected events that prevent you from attending the event. With insurance, you can avoid financial losses. You will receive a refund for the ticket if you cannot attend the event, especially due to:',
128
140
  '- Your injury or illness',
@@ -174,6 +186,40 @@ const en = {
174
186
  button: 'Download ticket',
175
187
  products: 'Your products',
176
188
  },
189
+ paydroid: {
190
+ label: 'Paydroid Cashless',
191
+ heading: 'Skip the queues!',
192
+ heading_cta: 'Top up now',
193
+ subtitle:
194
+ 'The event accepts only cashless payments via Paydroid Cashless. Top up your balance now and skip the queues!',
195
+ amount_label: 'How much would you like to top up?',
196
+ amount_placeholder: 'Enter amount',
197
+ promo: 'Top up more than 1000 CZK and get a free beer!',
198
+ terms_prefix: 'I agree to the',
199
+ terms_conditions: 'Terms and Conditions',
200
+ terms_middle: 'of Paydroid Cashless and the',
201
+ terms_privacy: 'Privacy Policy',
202
+ terms: 'I agree to the T&C and GDPR of Paydroid Cashless',
203
+ skip: 'I just want the ticket in the app',
204
+ submit: 'top up',
205
+ success_title: 'We added the ticket to your account',
206
+ success_subtitle:
207
+ 'Just log in to the Paydroid Cashless app. And if you want to skip the queues, top up in the app!',
208
+ topup_success_title: 'Thank you for topping up!',
209
+ topup_success_subtitle:
210
+ 'We have credited your Paydroid Cashless account. Just log in to the app.',
211
+ error_title: 'Top-up failed',
212
+ error_subtitle: 'You have not been charged. Please contact us if you have any issues.',
213
+ error_account_title: 'Failed to create account',
214
+ error_account_subtitle: 'You may have entered details that differ from our records.',
215
+ error_account_button: 'log in to the app',
216
+ error_ticket_title: 'Failed to add ticket',
217
+ error_ticket_subtitle:
218
+ 'We are sorry, please try again later. Contact us if the issue persists.',
219
+ faq_button: 'frequently asked questions',
220
+ retry_button: 'try again',
221
+ missing_api_key: 'Paydroid API key is missing',
222
+ },
177
223
  unpaid: 'The payment was not successful.',
178
224
  unpaid_description: 'Please try again by clicking the Pay button.',
179
225
  },