@adlas/create-app 1.0.7 → 1.0.9

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,415 @@
1
+ 'use client';
2
+
3
+ import type { ChangeEvent, DragEvent, KeyboardEvent } from 'react';
4
+
5
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
6
+
7
+ import { Spinner } from '@heroui/react';
8
+ import clsx from 'clsx';
9
+ import { useLocale, useTranslations } from 'next-intl';
10
+ import Image from 'next/image';
11
+
12
+ import { uploadImageToSaleor } from '@/services/user';
13
+ import {
14
+ compressImage,
15
+ FILE_CONFIG,
16
+ formatFileSize,
17
+ getAllowedExtensions,
18
+ getFileInfoText,
19
+ } from '@/utils/file';
20
+ import { getOptimalCompressionOptions } from '@/utils/file/imageCompression';
21
+
22
+ import { Button } from './Button';
23
+ import { Icon } from './Icon';
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ export type FileValidationError = {
30
+ isValidationError: true;
31
+ errorMessage: string;
32
+ };
33
+
34
+ type DocumentUploadProps = {
35
+ label: string;
36
+ description?: string;
37
+ onChange: (file: File | FileValidationError | null) => void;
38
+ onImageUploaded?: (imageId: string, imageUrl: string) => void;
39
+ onError?: (error: string) => void;
40
+ className?: string;
41
+ value?: string | null;
42
+ hasError?: boolean;
43
+ errorMessage?: string;
44
+ accept?: string;
45
+ required?: boolean;
46
+ maxSize?: number;
47
+ /** If true, file will not be uploaded immediately - parent handles upload */
48
+ deferUpload?: boolean;
49
+ };
50
+
51
+ // ============================================================================
52
+ // Constants
53
+ // ============================================================================
54
+
55
+ const CONTAINER_HEIGHT = 'h-40';
56
+ const ICON_SIZE = 'size-5';
57
+
58
+ // ============================================================================
59
+ // Helper Functions
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Validates a file against size and type constraints
64
+ */
65
+ function validateFile(
66
+ file: File,
67
+ accept: string,
68
+ maxFileSize: number,
69
+ t: ReturnType<typeof useTranslations>,
70
+ locale: 'en' | 'de',
71
+ ): string | null {
72
+ // Check file size
73
+ if (file.size > maxFileSize) {
74
+ return t('fileSizeError', { maxSize: formatFileSize(maxFileSize, locale) });
75
+ }
76
+
77
+ // Check file type
78
+ const allowedExtensions = getAllowedExtensions(accept);
79
+ const fileExtension = file.name.split('.').pop()?.toUpperCase();
80
+
81
+ if (fileExtension && !allowedExtensions.includes(fileExtension)) {
82
+ return t('fileTypeError');
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Cleans up blob URL if it exists
90
+ */
91
+ function revokeObjectURL(url: string | null) {
92
+ if (url && url.startsWith('blob:')) {
93
+ URL.revokeObjectURL(url);
94
+ }
95
+ }
96
+
97
+ // ============================================================================
98
+ // Component
99
+ // ============================================================================
100
+
101
+ function DocumentUploadComponent({
102
+ label,
103
+ onChange,
104
+ onImageUploaded,
105
+ onError,
106
+ className,
107
+ errorMessage,
108
+ value,
109
+ hasError,
110
+ accept = 'image/*,.pdf',
111
+ maxSize,
112
+ deferUpload = false,
113
+ }: DocumentUploadProps) {
114
+ const t = useTranslations('FileUpload');
115
+ const locale = useLocale() as 'en' | 'de';
116
+ const [previewUrl, setPreviewUrl] = useState<string | null>(value || null);
117
+ const [isDragOver, setIsDragOver] = useState(false);
118
+ const [validationError, setValidationError] = useState<string | null>(null);
119
+ const [isCompressing, setIsCompressing] = useState(false);
120
+ const [isUploading, setIsUploading] = useState(false);
121
+ const inputRef = useRef<HTMLInputElement | null>(null);
122
+
123
+ const maxFileSize = maxSize || FILE_CONFIG.MAX_FILE_SIZE;
124
+
125
+ // ============================================================================
126
+ // Effects
127
+ // ============================================================================
128
+
129
+ // Sync previewUrl with value prop from form
130
+ useEffect(() => {
131
+ if (value && typeof value === 'string') {
132
+ setPreviewUrl(value);
133
+ }
134
+ }, [value]);
135
+
136
+ // Cleanup object URLs on unmount or when preview changes
137
+ useEffect(() => {
138
+ return () => {
139
+ revokeObjectURL(previewUrl);
140
+ };
141
+ }, [previewUrl]);
142
+
143
+ // ============================================================================
144
+ // Handlers
145
+ // ============================================================================
146
+
147
+ const handleFileChange = useCallback(
148
+ async (file: File | null) => {
149
+ setValidationError(null);
150
+
151
+ if (file) {
152
+ try {
153
+ let processedFile = file;
154
+
155
+ // Compress image files before validation
156
+ if (file.type.startsWith('image/')) {
157
+ setIsCompressing(true);
158
+ const compressionOptions = getOptimalCompressionOptions(file);
159
+
160
+ processedFile = await compressImage(file, compressionOptions);
161
+ setIsCompressing(false);
162
+ }
163
+
164
+ const error = validateFile(processedFile, accept, maxFileSize, t, locale);
165
+
166
+ if (error) {
167
+ setValidationError(error);
168
+ setPreviewUrl(null);
169
+ onChange({
170
+ isValidationError: true,
171
+ errorMessage: error,
172
+ });
173
+ if (inputRef.current) {
174
+ inputRef.current.value = '';
175
+ }
176
+
177
+ return;
178
+ }
179
+
180
+ // Set preview immediately for better UX
181
+ const objectUrl = URL.createObjectURL(processedFile);
182
+ setPreviewUrl(objectUrl);
183
+ onChange(processedFile);
184
+
185
+ // Upload to Saleor if it's an image and deferUpload is false
186
+ if (!deferUpload && processedFile.type.startsWith('image/') && onImageUploaded) {
187
+ setIsUploading(true);
188
+
189
+ try {
190
+ const result = await uploadImageToSaleor(processedFile);
191
+
192
+ if (result.success && result.imageId && result.imageUrl) {
193
+ // Notify parent component with the uploaded image URL
194
+ onImageUploaded(result.imageId, result.imageUrl);
195
+ // Revoke the blob URL before setting the new URL
196
+ revokeObjectURL(previewUrl);
197
+ setPreviewUrl(result.imageUrl);
198
+ } else {
199
+ console.error('❌ Image upload failed:', result.error);
200
+ onError?.(result.error || t('uploadFailed'));
201
+ setValidationError(result.error || t('uploadFailed'));
202
+ }
203
+ } catch (uploadError) {
204
+ console.error('❌ Image upload error:', uploadError);
205
+ const errorMsg =
206
+ uploadError instanceof Error ? uploadError.message : t('uploadFailed');
207
+
208
+ onError?.(errorMsg);
209
+ setValidationError(errorMsg);
210
+ } finally {
211
+ setIsUploading(false);
212
+ }
213
+ }
214
+ } catch (compressionError) {
215
+ console.error('Image compression failed:', compressionError);
216
+ const errorMsg = t('compressionError');
217
+
218
+ setValidationError(errorMsg);
219
+ setPreviewUrl(null);
220
+ onChange({
221
+ isValidationError: true,
222
+ errorMessage: errorMsg,
223
+ });
224
+ if (inputRef.current) {
225
+ inputRef.current.value = '';
226
+ }
227
+ setIsCompressing(false);
228
+ }
229
+ } else {
230
+ onChange(file);
231
+ setPreviewUrl(file);
232
+ }
233
+ },
234
+ [accept, maxFileSize, onChange, onError, onImageUploaded, previewUrl, t, locale, deferUpload],
235
+ );
236
+
237
+ const handleChange = useCallback(
238
+ (e: ChangeEvent<HTMLInputElement>) => {
239
+ const file = e.target.files?.[0] || null;
240
+ handleFileChange(file);
241
+ },
242
+ [handleFileChange],
243
+ );
244
+
245
+ const handleDragOver = useCallback(
246
+ (e: DragEvent<HTMLDivElement>) => {
247
+ e.preventDefault();
248
+ if (!isCompressing && !isUploading) {
249
+ setIsDragOver(true);
250
+ }
251
+ },
252
+ [isCompressing, isUploading],
253
+ );
254
+
255
+ const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
256
+ e.preventDefault();
257
+ setIsDragOver(false);
258
+ }, []);
259
+
260
+ const handleDrop = useCallback(
261
+ (e: DragEvent<HTMLDivElement>) => {
262
+ e.preventDefault();
263
+ setIsDragOver(false);
264
+
265
+ if (isCompressing || isUploading) {
266
+ return;
267
+ }
268
+
269
+ const files = e.dataTransfer.files;
270
+
271
+ if (files.length > 0) {
272
+ handleFileChange(files[0] || null);
273
+ }
274
+ },
275
+ [handleFileChange, isCompressing, isUploading],
276
+ );
277
+
278
+ const handleTriggerInput = useCallback(() => {
279
+ if (!isCompressing && !isUploading) {
280
+ inputRef.current?.click();
281
+ }
282
+ }, [isCompressing, isUploading]);
283
+
284
+ const handleRemoveFile = useCallback(() => {
285
+ setValidationError(null);
286
+ if (inputRef.current) {
287
+ inputRef.current.value = '';
288
+ }
289
+ handleTriggerInput();
290
+ }, [handleTriggerInput]);
291
+
292
+ const handleKeyDown = useCallback(
293
+ (e: KeyboardEvent<HTMLDivElement>) => {
294
+ if (e.key === 'Enter' || e.key === ' ') {
295
+ e.preventDefault();
296
+ handleTriggerInput();
297
+ }
298
+ },
299
+ [handleTriggerInput],
300
+ );
301
+
302
+ // ============================================================================
303
+ // Render
304
+ // ============================================================================
305
+
306
+ return (
307
+ <div
308
+ className={clsx(
309
+ 'relative flex w-full cursor-pointer flex-col items-center justify-center overflow-hidden border border-dashed border-default-200 p-4 transition-all duration-200 hover:border-default-500',
310
+ CONTAINER_HEIGHT,
311
+ {
312
+ 'border-danger!': hasError || validationError,
313
+ 'border-default-500! bg-default-50!': isDragOver,
314
+ 'border-transparent!': previewUrl,
315
+ 'hover:border-default-300':
316
+ !hasError && !isDragOver && !validationError && !isCompressing && !isUploading,
317
+ 'cursor-not-allowed! opacity-60': isCompressing || isUploading,
318
+ },
319
+ className,
320
+ )}
321
+ role="button"
322
+ tabIndex={0}
323
+ onClick={handleTriggerInput}
324
+ onDragLeave={handleDragLeave}
325
+ onDragOver={handleDragOver}
326
+ onDrop={handleDrop}
327
+ onKeyDown={handleKeyDown}
328
+ >
329
+ {/* Background Image */}
330
+ {previewUrl && (
331
+ <>
332
+ <Image fill alt="Preview" className="object-cover" src={previewUrl} />
333
+ {/* Dark Overlay */}
334
+ <div className="absolute inset-0 bg-black/30" />
335
+ </>
336
+ )}
337
+
338
+ {/* Content */}
339
+ <div className="relative z-10 flex flex-col items-center justify-center">
340
+ <div className="flex items-center justify-center">
341
+ {isCompressing || isUploading ? (
342
+ <Spinner color="white" />
343
+ ) : (
344
+ !previewUrl && (
345
+ <Icon
346
+ className={clsx(ICON_SIZE, 'stroke-default-700 stroke-1.5', {
347
+ 'stroke-danger!': hasError || validationError,
348
+ })}
349
+ name="UploadIcon"
350
+ />
351
+ )
352
+ )}
353
+ </div>
354
+ <input
355
+ ref={inputRef}
356
+ accept={accept}
357
+ className="hidden"
358
+ type="file"
359
+ onChange={handleChange}
360
+ />
361
+ <div className="mt-2 flex flex-col items-center justify-center text-center">
362
+ {isCompressing || isUploading ? null : (
363
+ <span
364
+ className={clsx('text-base leading-6 font-semibold', {
365
+ 'text-white': previewUrl,
366
+ 'text-default-800': !previewUrl,
367
+ })}
368
+ >
369
+ {label}
370
+ </span>
371
+ )}
372
+ {(hasError || validationError) && (
373
+ <span className="mt-0.5 text-sm leading-5 font-medium text-danger">
374
+ {validationError || errorMessage}
375
+ </span>
376
+ )}
377
+ {!hasError && !validationError && (
378
+ <p
379
+ className={clsx('mt-0.5 text-sm leading-5 font-medium', {
380
+ 'text-white': previewUrl,
381
+ 'text-default-600': !previewUrl,
382
+ })}
383
+ >
384
+ {isCompressing
385
+ ? t('compressing')
386
+ : isUploading
387
+ ? t('uploading')
388
+ : previewUrl
389
+ ? null
390
+ : t('clickOrDrag')}
391
+ </p>
392
+ )}
393
+ {!previewUrl && (
394
+ <p className="mt-3 text-xs leading-4 font-medium text-default-500">
395
+ {getFileInfoText(accept, maxFileSize, locale, t('format'), t('max'))}
396
+ </p>
397
+ )}
398
+ {previewUrl && !isCompressing && !isUploading && (
399
+ <Button
400
+ className="mt-2 bg-white text-primary"
401
+ size="sm"
402
+ variant="solid"
403
+ onClick={e => e.stopPropagation()}
404
+ onPress={handleRemoveFile}
405
+ >
406
+ {t('changeImage')}
407
+ </Button>
408
+ )}
409
+ </div>
410
+ </div>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ export const DocumentUpload = memo(DocumentUploadComponent);
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import type { SVGProps } from 'react';
4
+
5
+ import { memo } from 'react';
6
+
7
+ import clsx from 'clsx';
8
+
9
+ import * as Icons from '@/components/icons';
10
+
11
+ export type IconName = keyof typeof Icons;
12
+
13
+ /**
14
+ * Icon component for displaying SVG icons
15
+ *
16
+ * @example
17
+ * // Single color icon (default: size-5, stroke-1.5)
18
+ * <Icon name="HeartIcon" className="stroke-blue-500" />
19
+ *
20
+ * @example
21
+ * // Two-tone icon with nth-child selectors
22
+ * <Icon
23
+ * name="CheckBookmarkIcon"
24
+ * className="[&_path:nth-child(1)]:stroke-primary [&_path:nth-child(2)]:stroke-default-300"
25
+ * />
26
+ */
27
+ export type IconProps = {
28
+ name: IconName;
29
+ } & SVGProps<SVGSVGElement>;
30
+
31
+ const IconComponent = ({ name, className = '', ...props }: IconProps) => {
32
+ const Component = Icons[name];
33
+
34
+ if (!Component) {
35
+ return null;
36
+ }
37
+
38
+ return <Component className={clsx('size-4 stroke-1.5 outline-none', className)} {...props} />;
39
+ };
40
+
41
+ IconComponent.displayName = 'Icon';
42
+
43
+ export const Icon = memo(IconComponent);
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import { extendVariants, Input as HerouiInput } from '@heroui/react';
6
+
7
+ const BaseInput = extendVariants(HerouiInput, {
8
+ variants: {
9
+ color: {},
10
+ isDisabled: { true: {} },
11
+ size: {
12
+ sm: {},
13
+ md: {},
14
+ lg: {},
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ size: 'md',
19
+ },
20
+ compoundVariants: [],
21
+ });
22
+
23
+ const Input = (props: ComponentProps<typeof BaseInput>) => {
24
+ return <BaseInput {...props} />;
25
+ };
26
+
27
+ export { Input };
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import {
6
+ extendVariants,
7
+ Modal as HeroUIModal,
8
+ ModalBody,
9
+ ModalContent,
10
+ ModalFooter,
11
+ ModalHeader,
12
+ } from '@heroui/react';
13
+
14
+ import { Button } from './Button';
15
+ import { Icon } from './Icon';
16
+
17
+ const Modal = extendVariants(HeroUIModal, {
18
+ slots: {
19
+ backdrop: '',
20
+ base: '',
21
+ wrapper: '',
22
+ body: '',
23
+ closeButton: '',
24
+ },
25
+ variants: {
26
+ radius: {},
27
+ },
28
+ defaultVariants: {
29
+ radius: 'none',
30
+ shadow: 'none',
31
+ placement: 'center',
32
+ backdrop: 'blur',
33
+ },
34
+ });
35
+
36
+ const CustomModal = (props: ComponentProps<typeof Modal>) => {
37
+ const { ...restProps } = props;
38
+
39
+ return (
40
+ <Modal
41
+ closeButton={
42
+ <Button isIconOnly size="sm" variant="flat">
43
+ <Icon name="CloseIcon" className="size-5! stroke-default-700" />
44
+ </Button>
45
+ }
46
+ {...restProps}
47
+ />
48
+ );
49
+ };
50
+
51
+ export { CustomModal as Modal, ModalBody, ModalContent, ModalFooter, ModalHeader };
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import type { ChangeEvent, ComponentProps } from 'react';
4
+
5
+ import { Input } from './Input';
6
+
7
+ type NumberInputProps = {
8
+ formatOptions?: Intl.NumberFormatOptions;
9
+ onValueChange?: (value: string) => void;
10
+ };
11
+
12
+ const NumberInput = (props: ComponentProps<typeof Input> & NumberInputProps) => {
13
+ const { value, onChange, onValueChange, formatOptions, ...restProps } = props;
14
+
15
+ const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
16
+ let rawInput = e.target.value;
17
+
18
+ if (rawInput.startsWith(' ')) {
19
+ rawInput = rawInput.trimStart();
20
+ e.target.value = rawInput;
21
+ }
22
+
23
+ const processedInput = rawInput.replace(/\D/g, '');
24
+
25
+ if (processedInput !== rawInput) {
26
+ e.target.value = processedInput;
27
+ }
28
+
29
+ if (onChange && typeof onChange === 'function') {
30
+ const newEvent = {
31
+ ...e,
32
+ target: {
33
+ ...e.target,
34
+ value: processedInput,
35
+ },
36
+ };
37
+
38
+ onChange(newEvent);
39
+ }
40
+
41
+ if (onValueChange) {
42
+ if (processedInput === '') {
43
+ onValueChange('');
44
+ } else {
45
+ onValueChange(processedInput);
46
+ }
47
+ }
48
+ };
49
+
50
+ // Apply number formatting if formatOptions are provided
51
+ const formattedValue =
52
+ formatOptions && value
53
+ ? new Intl.NumberFormat(undefined, formatOptions).format(Number(value))
54
+ : value;
55
+
56
+ return <Input type="text" value={formattedValue} onInput={handleInput} {...restProps} />;
57
+ };
58
+
59
+ export { NumberInput };
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+ import type { ComponentProps, FormEvent } from 'react';
3
+
4
+ import { useState } from 'react';
5
+
6
+ import { Button } from './Button';
7
+ import { Icon } from './Icon';
8
+ import { Input } from './Input';
9
+
10
+ const PasswordInput = (props: ComponentProps<typeof Input>) => {
11
+ const [isVisible, setIsVisible] = useState(false);
12
+ const { onInput, ...restProps } = props;
13
+
14
+ const toggleVisibility = () => setIsVisible(!isVisible);
15
+
16
+ const handleInput = (e: FormEvent<HTMLInputElement>) => {
17
+ const target = e.target as HTMLInputElement;
18
+
19
+ if (target.value.startsWith(' ')) {
20
+ target.value = target.value.trimStart();
21
+ }
22
+
23
+ if (onInput) {
24
+ onInput(e);
25
+ }
26
+ };
27
+
28
+ return (
29
+ <Input
30
+ endContent={
31
+ <Button
32
+ size="sm"
33
+ variant="light"
34
+ aria-label="toggle password visibility"
35
+ className="min-w-0 self-center focus:outline-none"
36
+ onPress={toggleVisibility}
37
+ >
38
+ <Icon
39
+ name={isVisible ? 'EyeSlashIcon' : 'EyeIcon'}
40
+ className="pointer-events-none size-4 stroke-default-400 stroke-1.5"
41
+ />
42
+ </Button>
43
+ }
44
+ type={isVisible ? 'text' : 'password'}
45
+ onInput={handleInput}
46
+ {...restProps}
47
+ />
48
+ );
49
+ };
50
+
51
+ export { PasswordInput };
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import {
6
+ extendVariants,
7
+ Radio as HerouiRadio,
8
+ RadioGroup as HerouiRadioGroup,
9
+ } from '@heroui/react';
10
+
11
+ const RadioGroup = extendVariants(HerouiRadioGroup, {
12
+ variants: {
13
+ orientation: {
14
+ vertical: {
15
+ wrapper: '',
16
+ },
17
+ horizontal: {
18
+ wrapper: '',
19
+ },
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ orientation: 'horizontal',
24
+ },
25
+ });
26
+
27
+ const BaseRadio = extendVariants(HerouiRadio, {
28
+ variants: {
29
+ color: {},
30
+ isDisabled: { true: {} },
31
+ size: {
32
+ sm: {},
33
+ md: {},
34
+ lg: {},
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ size: 'md',
39
+ },
40
+ compoundVariants: [],
41
+ });
42
+
43
+ type RadioProps = ComponentProps<typeof BaseRadio>;
44
+ const Radio = (props: RadioProps) => {
45
+ const { ...restProps } = props;
46
+
47
+ return <BaseRadio {...restProps}>{restProps.children}</BaseRadio>;
48
+ };
49
+
50
+ export { Radio, RadioGroup };