@akinon/pz-virtual-try-on 2.0.0-beta.16 → 2.0.0-beta.18

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.
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { twMerge } from 'tailwind-merge';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+ import type { BarcodeScannerButtonProps } from '../types/barcode';
7
+
8
+ export function BarcodeScannerButton({
9
+ onClick,
10
+ className,
11
+ disabled = false,
12
+ settings,
13
+ style
14
+ }: BarcodeScannerButtonProps) {
15
+ const { t } = useLocalization();
16
+
17
+ // Custom button renderer
18
+ if (settings?.customRenderers?.renderFloatingButton) {
19
+ return settings.customRenderers.renderFloatingButton({
20
+ onClick,
21
+ className
22
+ });
23
+ }
24
+
25
+ return (
26
+ <button
27
+ onClick={onClick}
28
+ disabled={disabled}
29
+ aria-label={t('product.barcode_scanner.button_label')}
30
+ style={style}
31
+ className={twMerge(
32
+ // Base styles - fixed position bottom right, mobile only
33
+ 'fixed bottom-20 right-4 z-50 md:hidden',
34
+ // Button appearance
35
+ 'px-4 py-3 rounded-full',
36
+ 'bg-black text-white',
37
+ 'flex items-center justify-center',
38
+ 'shadow-lg hover:shadow-xl',
39
+ 'transition-all duration-200',
40
+ 'hover:scale-105 active:scale-95',
41
+ 'text-sm font-medium whitespace-nowrap',
42
+ // Disabled state
43
+ disabled && 'opacity-50 cursor-not-allowed hover:scale-100',
44
+ // Custom styles
45
+ settings?.customStyles?.floatingButton,
46
+ className
47
+ )}
48
+ >
49
+ {settings?.customRenderers?.renderFloatingButtonIcon ? (
50
+ settings.customRenderers.renderFloatingButtonIcon()
51
+ ) : (
52
+ <span
53
+ className={twMerge(
54
+ 'text-sm font-medium',
55
+ settings?.customStyles?.floatingButtonIcon
56
+ )}
57
+ >
58
+ {t('product.barcode_scanner.button_text')}
59
+ </span>
60
+ )}
61
+ </button>
62
+ );
63
+ }
@@ -0,0 +1,632 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useCallback, useMemo, useEffect } from 'react';
4
+ import { twMerge } from 'tailwind-merge';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+ import { Product } from '@akinon/next/types';
7
+ import { Image } from '@akinon/next/components/image';
8
+ import { BarcodeScanner } from '../components/barcode-scanner';
9
+ import { useBarcodeSearch } from '../hooks/use-barcode-search';
10
+ import { ProcessingSpinner } from '../components/processing-spinner';
11
+ import type {
12
+ BarcodeScannerModalProps,
13
+ BarcodeScanResult
14
+ } from '../types/barcode';
15
+
16
+ export function BarcodeScannerModal({
17
+ isOpen,
18
+ onClose,
19
+ onProductFound,
20
+ onContinueToVTO,
21
+ className,
22
+ settings
23
+ }: BarcodeScannerModalProps) {
24
+ const { t } = useLocalization();
25
+ const [isScannerActive, setIsScannerActive] = useState(true);
26
+ const [permissionDenied, setPermissionDenied] = useState(false);
27
+
28
+ const {
29
+ scannedProducts,
30
+ isLoading,
31
+ error,
32
+ handleScanResult,
33
+ removeProduct,
34
+ clearError,
35
+ reset
36
+ } = useBarcodeSearch(settings?.maxProducts || 10);
37
+
38
+ const cssVariables = useMemo(() => {
39
+ if (!settings?.cssVariables && !settings?.theme) return {};
40
+
41
+ const variables: Record<string, string> = {};
42
+
43
+ if (settings?.cssVariables) {
44
+ Object.entries(settings.cssVariables).forEach(([key, value]) => {
45
+ if (typeof value === 'string') {
46
+ variables[`--${key}`] = value;
47
+ }
48
+ });
49
+ }
50
+
51
+ if (settings?.theme) {
52
+ const { colors, spacing, borderRadius, fonts } = settings.theme;
53
+
54
+ if (colors) {
55
+ Object.entries(colors).forEach(([key, value]) => {
56
+ if (value && typeof value === 'string')
57
+ variables[`--barcode-color-${key}`] = value;
58
+ });
59
+ }
60
+
61
+ if (spacing) {
62
+ Object.entries(spacing).forEach(([key, value]) => {
63
+ if (value && typeof value === 'string')
64
+ variables[`--barcode-spacing-${key}`] = value;
65
+ });
66
+ }
67
+
68
+ if (borderRadius) {
69
+ Object.entries(borderRadius).forEach(([key, value]) => {
70
+ if (value && typeof value === 'string')
71
+ variables[`--barcode-border-radius-${key}`] = value;
72
+ });
73
+ }
74
+
75
+ if (fonts) {
76
+ Object.entries(fonts).forEach(([key, value]) => {
77
+ if (value && typeof value === 'string')
78
+ variables[`--barcode-font-${key}`] = value;
79
+ });
80
+ }
81
+ }
82
+
83
+ return variables;
84
+ }, [settings?.cssVariables, settings?.theme]);
85
+
86
+ useEffect(() => {
87
+ if (error) {
88
+ const timer = setTimeout(() => {
89
+ clearError();
90
+ }, 2000);
91
+ return () => clearTimeout(timer);
92
+ }
93
+ }, [error, clearError]);
94
+
95
+ const handleScan = useCallback(
96
+ async (result: BarcodeScanResult) => {
97
+ clearError();
98
+ await handleScanResult(result);
99
+ },
100
+ [handleScanResult, clearError]
101
+ );
102
+
103
+ const handlePermissionDenied = useCallback(() => {
104
+ setPermissionDenied(true);
105
+ setIsScannerActive(false);
106
+ }, []);
107
+
108
+ const handleScannerError = useCallback((error: Error) => {
109
+ console.error('Scanner error:', error);
110
+ }, []);
111
+
112
+ const handleClose = useCallback(() => {
113
+ setIsScannerActive(false);
114
+ reset();
115
+ setPermissionDenied(false);
116
+ onClose();
117
+ }, [onClose, reset]);
118
+
119
+ const handleContinueToVTO = useCallback(() => {
120
+ if (scannedProducts.length > 0) {
121
+ setIsScannerActive(false);
122
+
123
+ const products = [...scannedProducts];
124
+
125
+ reset();
126
+ setPermissionDenied(false);
127
+ onContinueToVTO?.(products);
128
+ }
129
+ }, [scannedProducts, onContinueToVTO, reset]);
130
+
131
+ useEffect(() => {
132
+ if (scannedProducts.length > 0) {
133
+ onProductFound?.(scannedProducts);
134
+ }
135
+ }, [scannedProducts, onProductFound]);
136
+
137
+ useEffect(() => {
138
+ if (isOpen) {
139
+ setIsScannerActive(true);
140
+ setPermissionDenied(false);
141
+ } else {
142
+ setIsScannerActive(false);
143
+ }
144
+ }, [isOpen]);
145
+
146
+ if (!isOpen) return null;
147
+
148
+ if (settings?.customRenderers?.renderModal) {
149
+ return settings.customRenderers.renderModal({
150
+ isOpen,
151
+ onClose: handleClose,
152
+ onProductFound,
153
+ onContinueToVTO,
154
+ className,
155
+ settings
156
+ });
157
+ }
158
+
159
+ const renderHeader = () => {
160
+ if (settings?.customRenderers?.renderModalHeader) {
161
+ return settings.customRenderers.renderModalHeader({
162
+ title: t('product.barcode_scanner.title'),
163
+ onClose: handleClose
164
+ });
165
+ }
166
+
167
+ return (
168
+ <div
169
+ className={twMerge(
170
+ 'flex items-center justify-center px-4 py-4 border-b border-gray-200 relative',
171
+ settings?.customStyles?.modalHeader
172
+ )}
173
+ >
174
+ <h2
175
+ className={twMerge(
176
+ 'text-base font-medium text-black text-center',
177
+ settings?.customStyles?.modalTitle
178
+ )}
179
+ >
180
+ {t('product.barcode_scanner.title')}
181
+ </h2>
182
+
183
+ {settings?.customRenderers?.renderModalCloseButton ? (
184
+ settings.customRenderers.renderModalCloseButton({
185
+ onClick: handleClose
186
+ })
187
+ ) : (
188
+ <button
189
+ onClick={handleClose}
190
+ className={twMerge(
191
+ 'absolute right-4 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors',
192
+ settings?.customStyles?.modalCloseButton
193
+ )}
194
+ aria-label={t('common.close')}
195
+ >
196
+ {settings?.customRenderers?.renderModalCloseIcon ? (
197
+ settings.customRenderers.renderModalCloseIcon()
198
+ ) : (
199
+ <svg
200
+ className={twMerge(
201
+ 'w-8 h-8',
202
+ settings?.customStyles?.modalCloseIcon
203
+ )}
204
+ fill="none"
205
+ viewBox="0 0 24 24"
206
+ stroke="currentColor"
207
+ >
208
+ <path
209
+ strokeLinecap="round"
210
+ strokeLinejoin="round"
211
+ strokeWidth={1}
212
+ d="M6 18L18 6M6 6l12 12"
213
+ />
214
+ </svg>
215
+ )}
216
+ </button>
217
+ )}
218
+ </div>
219
+ );
220
+ };
221
+
222
+ const renderPermissionDenied = () => {
223
+ if (settings?.customRenderers?.renderPermissionDenied) {
224
+ return settings.customRenderers.renderPermissionDenied({
225
+ onRequestPermission: () => {
226
+ setPermissionDenied(false);
227
+ setIsScannerActive(true);
228
+ }
229
+ });
230
+ }
231
+
232
+ return (
233
+ <div
234
+ className={twMerge(
235
+ 'flex flex-col items-center justify-center p-8 text-center h-full bg-gray-900',
236
+ settings?.customStyles?.permissionDeniedContainer
237
+ )}
238
+ >
239
+ {settings?.customRenderers?.renderPermissionDeniedIcon ? (
240
+ settings.customRenderers.renderPermissionDeniedIcon()
241
+ ) : (
242
+ <svg
243
+ className={twMerge(
244
+ 'w-16 h-16 text-white mb-4',
245
+ settings?.customStyles?.permissionDeniedIcon
246
+ )}
247
+ fill="none"
248
+ viewBox="0 0 24 24"
249
+ stroke="currentColor"
250
+ >
251
+ <path
252
+ strokeLinecap="round"
253
+ strokeLinejoin="round"
254
+ strokeWidth={1.5}
255
+ d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
256
+ />
257
+ <path
258
+ strokeLinecap="round"
259
+ strokeLinejoin="round"
260
+ strokeWidth={1.5}
261
+ d="M3 3l18 18"
262
+ />
263
+ </svg>
264
+ )}
265
+
266
+ {settings?.customRenderers?.renderPermissionDeniedTitle ? (
267
+ settings.customRenderers.renderPermissionDeniedTitle({
268
+ title: t('product.barcode_scanner.permission_denied_title')
269
+ })
270
+ ) : (
271
+ <h3
272
+ className={twMerge(
273
+ 'text-lg font-medium text-white mb-2',
274
+ settings?.customStyles?.permissionDeniedTitle
275
+ )}
276
+ >
277
+ {t('product.barcode_scanner.permission_denied_title')}
278
+ </h3>
279
+ )}
280
+
281
+ {settings?.customRenderers?.renderPermissionDeniedMessage ? (
282
+ settings.customRenderers.renderPermissionDeniedMessage({
283
+ message: t('product.barcode_scanner.permission_denied_message')
284
+ })
285
+ ) : (
286
+ <p
287
+ className={twMerge(
288
+ 'text-sm text-gray-300 mb-6',
289
+ settings?.customStyles?.permissionDeniedMessage
290
+ )}
291
+ >
292
+ {t('product.barcode_scanner.permission_denied_message')}
293
+ </p>
294
+ )}
295
+
296
+ {settings?.customRenderers?.renderPermissionDeniedButton ? (
297
+ settings.customRenderers.renderPermissionDeniedButton({
298
+ onClick: () => {
299
+ setPermissionDenied(false);
300
+ setIsScannerActive(true);
301
+ },
302
+ text: t('product.barcode_scanner.try_again')
303
+ })
304
+ ) : (
305
+ <button
306
+ onClick={() => {
307
+ setPermissionDenied(false);
308
+ setIsScannerActive(true);
309
+ }}
310
+ className={twMerge(
311
+ 'px-6 py-3 bg-white text-black rounded-full font-medium hover:bg-gray-100 transition-colors',
312
+ settings?.customStyles?.permissionDeniedButton
313
+ )}
314
+ >
315
+ {t('product.barcode_scanner.try_again')}
316
+ </button>
317
+ )}
318
+ </div>
319
+ );
320
+ };
321
+
322
+ const renderLoading = () => {
323
+ if (settings?.customRenderers?.renderLoading) {
324
+ return settings.customRenderers.renderLoading({
325
+ text: t('product.barcode_scanner.searching')
326
+ });
327
+ }
328
+
329
+ return (
330
+ <div
331
+ className={twMerge(
332
+ 'absolute inset-0 flex items-center justify-center bg-black/60 z-10',
333
+ settings?.customStyles?.loadingContainer
334
+ )}
335
+ >
336
+ <div className="flex flex-col items-center">
337
+ {settings?.customRenderers?.renderLoadingSpinner ? (
338
+ settings.customRenderers.renderLoadingSpinner()
339
+ ) : (
340
+ <ProcessingSpinner
341
+ showText={false}
342
+ className={settings?.customStyles?.loadingSpinner}
343
+ />
344
+ )}
345
+ {settings?.customRenderers?.renderLoadingText ? (
346
+ settings.customRenderers.renderLoadingText({
347
+ text: t('product.barcode_scanner.searching')
348
+ })
349
+ ) : (
350
+ <span
351
+ className={twMerge(
352
+ 'text-white mt-4 text-sm',
353
+ settings?.customStyles?.loadingText
354
+ )}
355
+ >
356
+ {t('product.barcode_scanner.searching')}
357
+ </span>
358
+ )}
359
+ </div>
360
+ </div>
361
+ );
362
+ };
363
+
364
+ const renderError = () => {
365
+ if (!error) return null;
366
+
367
+ if (settings?.customRenderers?.renderError) {
368
+ return settings.customRenderers.renderError({
369
+ error: t(`product.barcode_scanner.errors.${error}`),
370
+ onRetry: clearError
371
+ });
372
+ }
373
+
374
+ return (
375
+ <div
376
+ className={twMerge(
377
+ 'absolute bottom-32 left-4 right-4 bg-red-500/90 text-white p-3 rounded-lg flex items-center justify-between z-20',
378
+ settings?.customStyles?.errorContainer
379
+ )}
380
+ >
381
+ {settings?.customRenderers?.renderErrorMessage ? (
382
+ settings.customRenderers.renderErrorMessage({
383
+ message: t(`product.barcode_scanner.errors.${error}`)
384
+ })
385
+ ) : (
386
+ <span className={settings?.customStyles?.errorMessage}>
387
+ {t(`product.barcode_scanner.errors.${error}`)}
388
+ </span>
389
+ )}
390
+ {settings?.customRenderers?.renderErrorRetryButton ? (
391
+ settings.customRenderers.renderErrorRetryButton({
392
+ onClick: clearError,
393
+ text: 'Close'
394
+ })
395
+ ) : (
396
+ <button
397
+ onClick={clearError}
398
+ className={twMerge(
399
+ 'ml-2 p-1 hover:bg-red-600 rounded',
400
+ settings?.customStyles?.errorRetryButton
401
+ )}
402
+ >
403
+ {settings?.customRenderers?.renderErrorIcon ? (
404
+ settings.customRenderers.renderErrorIcon()
405
+ ) : (
406
+ <svg
407
+ className="w-4 h-4"
408
+ fill="none"
409
+ viewBox="0 0 24 24"
410
+ stroke="currentColor"
411
+ >
412
+ <path
413
+ strokeLinecap="round"
414
+ strokeLinejoin="round"
415
+ strokeWidth={2}
416
+ d="M6 18L18 6M6 6l12 12"
417
+ />
418
+ </svg>
419
+ )}
420
+ </button>
421
+ )}
422
+ </div>
423
+ );
424
+ };
425
+
426
+ const renderProductCard = (product: Product) => {
427
+ if (settings?.customRenderers?.renderProductCard) {
428
+ return settings.customRenderers.renderProductCard({
429
+ product,
430
+ isSelected: true,
431
+ onRemove: () => removeProduct(product.pk)
432
+ });
433
+ }
434
+
435
+ const imageUrl = product.productimage_set?.[0]?.image || '';
436
+
437
+ return (
438
+ <div
439
+ key={product.pk}
440
+ className={twMerge(
441
+ 'relative w-[69px] h-[69px] flex-shrink-0',
442
+ settings?.customStyles?.productCard
443
+ )}
444
+ >
445
+ <div
446
+ className={twMerge(
447
+ 'w-full h-full bg-white rounded overflow-hidden',
448
+ settings?.customStyles?.productImageWrapper
449
+ )}
450
+ >
451
+ {settings?.customRenderers?.renderProductImage ? (
452
+ settings.customRenderers.renderProductImage({
453
+ imageUrl,
454
+ alt: product.name,
455
+ className: settings?.customStyles?.productImage
456
+ })
457
+ ) : (
458
+ <Image
459
+ src={imageUrl}
460
+ alt={product.name}
461
+ width={69}
462
+ height={69}
463
+ className={twMerge(
464
+ 'w-full h-full object-cover',
465
+ settings?.customStyles?.productImage
466
+ )}
467
+ />
468
+ )}
469
+ </div>
470
+
471
+ {/* Checkmark indicator */}
472
+ <div
473
+ className={twMerge(
474
+ 'absolute -bottom-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center border border-white',
475
+ settings?.customStyles?.productCheckmark
476
+ )}
477
+ >
478
+ {settings?.customRenderers?.renderProductCheckmark ? (
479
+ settings.customRenderers.renderProductCheckmark()
480
+ ) : (
481
+ <svg className="w-2 h-2 text-white" viewBox="0 0 8 6" fill="none">
482
+ <path
483
+ d="M1 3L3 5L7 1"
484
+ stroke="currentColor"
485
+ strokeWidth="1.5"
486
+ strokeLinecap="round"
487
+ strokeLinejoin="round"
488
+ />
489
+ </svg>
490
+ )}
491
+ </div>
492
+ </div>
493
+ );
494
+ };
495
+
496
+ const renderProductList = () => {
497
+ if (scannedProducts.length === 0) return null;
498
+
499
+ if (settings?.customRenderers?.renderProductList) {
500
+ return settings.customRenderers.renderProductList({
501
+ products: scannedProducts,
502
+ onRemove: (product) => removeProduct(product.pk)
503
+ });
504
+ }
505
+
506
+ return (
507
+ <div
508
+ className={twMerge(
509
+ 'absolute bottom-[180px] left-1/2 -translate-x-1/2 z-10',
510
+ settings?.customStyles?.productListContainer
511
+ )}
512
+ >
513
+ <div
514
+ className={twMerge(
515
+ 'flex gap-2 justify-center',
516
+ settings?.customStyles?.productList
517
+ )}
518
+ >
519
+ {scannedProducts.map(renderProductCard)}
520
+ </div>
521
+ </div>
522
+ );
523
+ };
524
+
525
+ const renderHelpText = () => {
526
+ const helpText =
527
+ scannedProducts.length > 0
528
+ ? t('product.barcode_scanner.scan_more_help')
529
+ : t('product.barcode_scanner.help_text');
530
+
531
+ if (settings?.customRenderers?.renderScannerHelpText) {
532
+ return settings.customRenderers.renderScannerHelpText({ text: helpText });
533
+ }
534
+
535
+ return (
536
+ <p
537
+ className={twMerge(
538
+ 'absolute bottom-[130px] left-4 right-4 text-white text-center text-base font-medium z-10',
539
+ settings?.customStyles?.scannerHelpText
540
+ )}
541
+ >
542
+ {helpText}
543
+ </p>
544
+ );
545
+ };
546
+
547
+ const renderContinueButton = () => {
548
+ const showButton =
549
+ settings?.showContinueToVTO !== false && scannedProducts.length > 0;
550
+ if (!showButton) return null;
551
+
552
+ if (settings?.customRenderers?.renderContinueButton) {
553
+ return settings.customRenderers.renderContinueButton({
554
+ onClick: handleContinueToVTO,
555
+ disabled: scannedProducts.length === 0,
556
+ text: t('product.barcode_scanner.continue_to_vto')
557
+ });
558
+ }
559
+
560
+ return (
561
+ <div
562
+ className={twMerge(
563
+ 'absolute bottom-4 left-4 right-4 z-10',
564
+ settings?.customStyles?.actionsContainer
565
+ )}
566
+ >
567
+ <button
568
+ onClick={handleContinueToVTO}
569
+ disabled={scannedProducts.length === 0}
570
+ className={twMerge(
571
+ 'w-full py-4 bg-black text-white rounded-full font-medium text-base transition-colors',
572
+ scannedProducts.length === 0 && 'opacity-50 cursor-not-allowed',
573
+ settings?.customStyles?.continueButton,
574
+ scannedProducts.length === 0 &&
575
+ settings?.customStyles?.continueButtonDisabled
576
+ )}
577
+ >
578
+ {t('product.barcode_scanner.continue_to_vto')}
579
+ </button>
580
+ </div>
581
+ );
582
+ };
583
+
584
+ return (
585
+ <div
586
+ className={twMerge(
587
+ 'fixed inset-0 z-[9999]',
588
+ settings?.customStyles?.modalOverlay
589
+ )}
590
+ style={cssVariables}
591
+ >
592
+ <div
593
+ className={twMerge(
594
+ 'flex flex-col h-full w-full bg-white',
595
+ settings?.customStyles?.modalContainer,
596
+ className
597
+ )}
598
+ >
599
+ {/* Header */}
600
+ {renderHeader()}
601
+
602
+ {/* Scanner Area */}
603
+ <div
604
+ className={twMerge(
605
+ 'relative flex-1 overflow-hidden',
606
+ settings?.customStyles?.modalContent
607
+ )}
608
+ >
609
+ {permissionDenied ? (
610
+ renderPermissionDenied()
611
+ ) : (
612
+ <>
613
+ <BarcodeScanner
614
+ onScan={handleScan}
615
+ onError={handleScannerError}
616
+ onPermissionDenied={handlePermissionDenied}
617
+ isActive={isScannerActive && isOpen}
618
+ settings={settings}
619
+ />
620
+
621
+ {isLoading && renderLoading()}
622
+ {renderError()}
623
+ {renderProductList()}
624
+ {renderHelpText()}
625
+ {renderContinueButton()}
626
+ </>
627
+ )}
628
+ </div>
629
+ </div>
630
+ </div>
631
+ );
632
+ }