@adlas/create-app 1.0.11 → 1.0.13
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/package.json +1 -1
- package/templates/boilerplate/assets/icons/chevron-down-icon.svg +3 -0
- package/templates/boilerplate/components/icons/ChevronDownIcon.tsx +8 -0
- package/templates/boilerplate/components/icons/index.ts +1 -0
- package/templates/boilerplate/components/ui/form/Form.tsx +1 -3
- package/templates/boilerplate/components/ui/index.ts +0 -1
- package/templates/boilerplate/validations/commonValidations.ts +1 -22
- package/templates/boilerplate/components/ui/DocumentUpload.tsx +0 -415
package/package.json
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
const ChevronDownIcon = (props: SVGProps<SVGSVGElement>) => (
|
|
4
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="foreground" width="1em" height="1em" {...props}>
|
|
5
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m4 6 4 4 4-4" />
|
|
6
|
+
</svg>
|
|
7
|
+
);
|
|
8
|
+
export default ChevronDownIcon;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ChevronDownIcon } from './ChevronDownIcon';
|
|
@@ -15,8 +15,6 @@ import type { FormFieldValidation } from '@/validations';
|
|
|
15
15
|
|
|
16
16
|
import { createZodErrorMap } from '@/validations/zodErrorMap';
|
|
17
17
|
|
|
18
|
-
import type { FileValidationError } from '../DocumentUpload';
|
|
19
|
-
|
|
20
18
|
import { FormContext } from './FormContext';
|
|
21
19
|
|
|
22
20
|
/**
|
|
@@ -87,7 +85,7 @@ type FormProps<T extends FieldValues> = {
|
|
|
87
85
|
|
|
88
86
|
export type FieldEvent = {
|
|
89
87
|
target?: {
|
|
90
|
-
value?: string | File |
|
|
88
|
+
value?: string | File | null;
|
|
91
89
|
};
|
|
92
90
|
};
|
|
93
91
|
|
|
@@ -2,8 +2,6 @@ import type { useTranslations } from 'next-intl';
|
|
|
2
2
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
|
-
import type { FileValidationError } from '@/components/ui';
|
|
6
|
-
|
|
7
5
|
// ==================== Types ====================
|
|
8
6
|
|
|
9
7
|
/**
|
|
@@ -176,22 +174,10 @@ export function optional<T extends z.ZodTypeAny>(schema: T) {
|
|
|
176
174
|
return schema.optional().or(z.literal(''));
|
|
177
175
|
}
|
|
178
176
|
|
|
179
|
-
/**
|
|
180
|
-
* Type guard for file validation errors
|
|
181
|
-
*/
|
|
182
|
-
const isFileValidationError = (value: unknown): value is FileValidationError => {
|
|
183
|
-
return (
|
|
184
|
-
typeof value === 'object' &&
|
|
185
|
-
value !== null &&
|
|
186
|
-
'isValidationError' in value &&
|
|
187
|
-
value.isValidationError === true
|
|
188
|
-
);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
177
|
/**
|
|
192
178
|
* Valid file input types for validation
|
|
193
179
|
*/
|
|
194
|
-
type FileInput = File | string |
|
|
180
|
+
type FileInput = File | string | null | undefined;
|
|
195
181
|
|
|
196
182
|
/**
|
|
197
183
|
* Creates a file validation schema with i18n support
|
|
@@ -200,13 +186,6 @@ type FileInput = File | string | FileValidationError | null | undefined;
|
|
|
200
186
|
export const createFileValidation = (required = false) =>
|
|
201
187
|
z.custom<FileInput>().superRefine((value, ctx) => {
|
|
202
188
|
// Handle validation error objects
|
|
203
|
-
if (isFileValidationError(value)) {
|
|
204
|
-
ctx.addIssue({
|
|
205
|
-
code: 'custom',
|
|
206
|
-
message: value.errorMessage,
|
|
207
|
-
});
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
189
|
|
|
211
190
|
// If required, reject null/undefined/empty string
|
|
212
191
|
if (required && !value) {
|
|
@@ -1,415 +0,0 @@
|
|
|
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);
|