vasuzex 2.1.4 → 2.1.5

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.
@@ -832,9 +832,44 @@ export class Model extends GuruORMModel {
832
832
  query = query.whereNull(this.deletedAt);
833
833
  }
834
834
 
835
+ // Monkey-patch the update method to add timestamps
836
+ const modelClass = this;
837
+ const originalUpdate = query.update;
838
+
839
+ query.update = async function(data) {
840
+ // Add updated_at if timestamps are enabled
841
+ if (modelClass.timestamps && modelClass.updatedAt && data[modelClass.updatedAt] === undefined) {
842
+ data[modelClass.updatedAt] = new Date();
843
+ }
844
+ return await originalUpdate.call(this, data);
845
+ };
846
+
835
847
  return query;
836
848
  }
837
849
 
850
+ /**
851
+ * Override where() to pass through model context
852
+ */
853
+ static where(...args) {
854
+ const query = this.query();
855
+ return query.where(...args);
856
+ }
857
+
858
+ /**
859
+ * Static update method with timestamps
860
+ * Override to add updated_at automatically
861
+ */
862
+ static async update(id, attributes) {
863
+ // Add updated_at if timestamps are enabled
864
+ if (this.timestamps && this.updatedAt && attributes[this.updatedAt] === undefined) {
865
+ attributes[this.updatedAt] = new Date();
866
+ }
867
+
868
+ const pk = this.primaryKey || 'id';
869
+ const query = this.query();
870
+ return await query.where(pk, id).update(attributes);
871
+ }
872
+
838
873
  /**
839
874
  * Query builder with soft deleted records
840
875
  */
@@ -171,6 +171,27 @@ export class QueryBuilder {
171
171
  * Update records
172
172
  */
173
173
  async update(data) {
174
+ // Add updated_at if model class has timestamps enabled
175
+ if (this.query._modelClass) {
176
+ const modelClass = this.query._modelClass;
177
+ if (modelClass.timestamps && modelClass.updatedAt && data[modelClass.updatedAt] === undefined) {
178
+ data[modelClass.updatedAt] = new Date();
179
+ }
180
+ }
181
+
182
+ return await this.query.update(data);
183
+ }
184
+
185
+ /**
186
+ * Update records with timestamps (for use with Model)
187
+ * @param {Object} data - Data to update
188
+ * @param {boolean} withTimestamps - Whether to add updated_at timestamp
189
+ * @returns {Promise}
190
+ */
191
+ async updateWithTimestamps(data, withTimestamps = true) {
192
+ if (withTimestamps) {
193
+ data.updated_at = new Date();
194
+ }
174
195
  return await this.query.update(data);
175
196
  }
176
197
 
@@ -5,6 +5,7 @@ import { LogServiceProvider } from '../Services/Log/LogServiceProvider.js';
5
5
  import { HashServiceProvider } from './Providers/HashServiceProvider.js';
6
6
  import { ValidationServiceProvider } from './Providers/ValidationServiceProvider.js';
7
7
  import { EncryptionServiceProvider } from './Providers/EncryptionServiceProvider.js';
8
+ import { StorageServiceProvider } from '../Services/Storage/StorageServiceProvider.js';
8
9
 
9
10
  /**
10
11
  * BaseApp - Base class for application-level apps (Express apps)
@@ -38,6 +39,7 @@ export class BaseApp extends Application {
38
39
  this.register(HashServiceProvider);
39
40
  this.register(ValidationServiceProvider);
40
41
  this.register(EncryptionServiceProvider);
42
+ this.register(StorageServiceProvider);
41
43
  }
42
44
 
43
45
  /**
@@ -14,6 +14,7 @@ export class S3StorageProvider extends BaseStorageProvider {
14
14
  this.key = config.key;
15
15
  this.secret = config.secret;
16
16
  this.baseUrl = config.url;
17
+ this.forcePathStyle = config.forcePathStyle || false; // For MinIO compatibility
17
18
  this.s3Client = null;
18
19
  }
19
20
 
@@ -30,6 +31,7 @@ export class S3StorageProvider extends BaseStorageProvider {
30
31
  this.s3Client = new S3Client({
31
32
  region: this.region,
32
33
  endpoint: this.endpoint,
34
+ forcePathStyle: this.forcePathStyle, // Required for MinIO
33
35
  credentials: {
34
36
  accessKeyId: this.key,
35
37
  secretAccessKey: this.secret,
@@ -132,6 +134,25 @@ export class S3StorageProvider extends BaseStorageProvider {
132
134
  return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${path}`;
133
135
  }
134
136
 
137
+ /**
138
+ * Get presigned URL for temporary access
139
+ * @param {string} path - File path
140
+ * @param {number} expiresIn - Expiration time in seconds (default: 7 days)
141
+ * @returns {Promise<string>} Presigned URL
142
+ */
143
+ async temporaryUrl(path, expiresIn = 604800) {
144
+ const client = await this.getClient();
145
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
146
+ const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
147
+
148
+ const command = new GetObjectCommand({
149
+ Bucket: this.bucket,
150
+ Key: path,
151
+ });
152
+
153
+ return await getSignedUrl(client, command, { expiresIn });
154
+ }
155
+
135
156
  /**
136
157
  * Get file size
137
158
  */
@@ -26,10 +26,6 @@ export class StorageServiceProvider extends ServiceProvider {
26
26
  */
27
27
  async boot() {
28
28
  // Storage service is ready to use
29
- if (this.config('filesystems.default')) {
30
- const storage = this.make('storage');
31
- console.log(`[StorageServiceProvider] Storage service initialized with driver: ${this.config('filesystems.default')}`);
32
- }
33
29
  }
34
30
  }
35
31
 
@@ -219,12 +219,13 @@ export class FileValidator {
219
219
  }
220
220
 
221
221
  /**
222
- * Validation Error
222
+ * File Validation Error
223
+ * Renamed to avoid conflict with framework's ValidationError
223
224
  */
224
- export class ValidationError extends Error {
225
+ export class FileValidationError extends Error {
225
226
  constructor(message, errors = []) {
226
227
  super(message);
227
- this.name = 'ValidationError';
228
+ this.name = 'FileValidationError';
228
229
  this.errors = errors;
229
230
  }
230
231
  }
@@ -2,7 +2,7 @@ export { UploadManager } from './UploadManager.js';
2
2
  export { LocalDriver } from './Drivers/LocalDriver.js';
3
3
  export { S3Driver } from './Drivers/S3Driver.js';
4
4
  export { DigitalOceanSpacesDriver } from './Drivers/DigitalOceanSpacesDriver.js';
5
- export { FileValidator, ValidationError } from './FileValidator.js';
5
+ export { FileValidator, FileValidationError } from './FileValidator.js';
6
6
  export { ImageProcessor } from './ImageProcessor.js';
7
7
  export { SecurityScanner, SecurityError } from './SecurityScanner.js';
8
8
  export { UploadServiceProvider } from './UploadServiceProvider.js';
@@ -26,6 +26,43 @@ export class Validator {
26
26
  return { error: null, value: result.value };
27
27
  }
28
28
 
29
+ /**
30
+ * Get nested value from object using dot notation
31
+ * @param {Object} obj - Object to search
32
+ * @param {String} path - Dot notation path (e.g., 'user.profile.name')
33
+ * @returns {*} Value at path or undefined
34
+ *
35
+ * @example
36
+ * const data = { user: { profile: { name: 'John' } } };
37
+ * Validator.getNestedValue(data, 'user.profile.name'); // 'John'
38
+ */
39
+ static getNestedValue(obj, path) {
40
+ return path.split('.').reduce((current, key) => current?.[key], obj);
41
+ }
42
+
43
+ /**
44
+ * Validate required fields in data object
45
+ * @param {Object} data - Data object to validate
46
+ * @param {Array<String>} fields - Required field names (supports dot notation)
47
+ * @returns {Object} Object with errors (empty if valid)
48
+ *
49
+ * @example
50
+ * const errors = Validator.validateRequiredFields(data, ['name', 'user.email']);
51
+ * if (Object.keys(errors).length > 0) {
52
+ * throw new ValidationError(errors);
53
+ * }
54
+ */
55
+ static validateRequiredFields(data, fields) {
56
+ const errors = {};
57
+ for (const field of fields) {
58
+ const value = this.getNestedValue(data, field);
59
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
60
+ errors[field] = `${field} is required`;
61
+ }
62
+ }
63
+ return errors;
64
+ }
65
+
29
66
  /**
30
67
  * Validate middleware
31
68
  */
@@ -2,66 +2,131 @@
2
2
  * Form Error Handling Utilities
3
3
  *
4
4
  * Framework-agnostic error handling utilities for forms
5
- * Handles backend validation errors and maps them to form fields
5
+ * Handles backend validation errors (Joi) and maps them to form fields
6
+ * Compatible with both Yup (Formik) and Joi (backend) error formats
6
7
  * Similar to Laravel's error bag functionality
7
8
  */
8
9
 
9
10
  /**
10
11
  * Handle API errors in forms with Formik
11
- * Automatically maps backend validation errors to form fields
12
+ * Automatically maps backend validation errors (Joi) to Formik field errors
13
+ * Works seamlessly alongside Yup validation
12
14
  * Shows appropriate toast notifications
13
15
  *
14
16
  * @param {Object} error - Error object from API call
15
- * @param {Function} setFieldError - Formik's setFieldError function
17
+ * @param {Function} setStatus - Formik's setStatus function to store backend errors separately
16
18
  * @param {Object} options - Configuration options
17
19
  * @param {string} options.fallbackMessage - Message to show if no specific error message
18
20
  * @param {Function} options.onValidationError - Callback for validation errors
19
21
  * @param {Function} options.onError - Callback for other errors
20
22
  * @param {boolean} options.showToast - Whether to show toast notification (default: true)
21
23
  * @param {boolean} options.logError - Whether to log error to console (default: true)
24
+ * @param {Object} options.toast - Toast library instance (react-toastify, sonner, etc.)
22
25
  *
23
26
  * @example
24
27
  * ```javascript
25
- * import { handleFormError } from '@neastore-js/web-utils';
28
+ * import { handleFormError } from 'vasuzex/react';
29
+ * import { toast } from 'react-toastify';
26
30
  *
27
- * const handleSubmit = async (values, { setFieldError }) => {
31
+ * const handleSubmit = async (values, { setStatus }) => {
28
32
  * try {
29
33
  * await brandService.create(values);
30
34
  * toast.success("Brand created successfully");
35
+ * setStatus({ backendErrors: null }); // Clear backend errors on success
31
36
  * } catch (error) {
32
- * handleFormError(error, setFieldError, {
33
- * fallbackMessage: "Failed to save brand"
37
+ * handleFormError(error, setStatus, {
38
+ * fallbackMessage: "Failed to save brand",
39
+ * toast
34
40
  * });
35
41
  * }
36
42
  * };
37
43
  * ```
44
+ *
45
+ * @example With nested fields (Joi backend validation)
46
+ * ```javascript
47
+ * // Backend returns:
48
+ * {
49
+ * "success": false,
50
+ * "message": "Validation failed",
51
+ * "errors": {
52
+ * "phone": "Phone must be exactly 10 digits",
53
+ * "bankdetails.ifscCode": "Invalid IFSC code format",
54
+ * "owner.email": "Owner email must be a valid email address"
55
+ * }
56
+ * }
57
+ *
58
+ * // Stored in Formik status (doesn't conflict with Yup validation):
59
+ * // setStatus({
60
+ * // backendErrors: {
61
+ * // 'phone': 'Phone must be exactly 10 digits',
62
+ * // 'bankdetails': { 'ifscCode': 'Invalid IFSC code format' },
63
+ * // 'owner': { 'email': 'Owner email must be a valid email address' }
64
+ * // }
65
+ * // })
66
+ * ```
38
67
  */
39
- export const handleFormError = (error, setFieldError, options = {}) => {
68
+ export const handleFormError = (error, setStatus, options = {}) => {
40
69
  const {
41
70
  fallbackMessage = 'An error occurred',
42
71
  onValidationError,
43
72
  onError,
44
73
  showToast = true,
45
74
  logError = true,
75
+ toast: toastLib,
46
76
  } = options;
47
77
 
48
78
  // Log error for debugging
49
79
  if (logError) {
50
80
  console.error('Form error:', error);
81
+ console.log('[handleFormError] error.isValidationError:', error.isValidationError);
82
+ console.log('[handleFormError] error.errors:', error.errors);
83
+ console.log('[handleFormError] typeof error.errors:', typeof error.errors);
51
84
  }
52
85
 
53
86
  // Handle validation errors (422) - attach to form fields
87
+ // Works with both Joi (backend) and Yup (frontend) error formats
54
88
  if (error.isValidationError && error.errors && typeof error.errors === 'object') {
55
- // Set field-level errors from backend
89
+ console.log('[handleFormError] Processing validation errors...');
90
+
91
+ // Convert flat dot-notation errors to nested structure
92
+ // Backend: { "bankdetails.ifscCode": "error" }
93
+ // Formik needs: { bankdetails: { ifscCode: "error" } }
94
+ const formikErrors = {};
95
+
56
96
  Object.keys(error.errors).forEach((field) => {
57
- if (setFieldError) {
58
- setFieldError(field, error.errors[field]);
97
+ console.log(`[handleFormError] Processing field "${field}":`, error.errors[field]);
98
+
99
+ if (field.includes('.')) {
100
+ // Nested field: convert "bankdetails.ifscCode" to { bankdetails: { ifscCode: "error" } }
101
+ const parts = field.split('.');
102
+ let current = formikErrors;
103
+
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ if (!current[parts[i]]) {
106
+ current[parts[i]] = {};
107
+ }
108
+ current = current[parts[i]];
109
+ }
110
+
111
+ current[parts[parts.length - 1]] = error.errors[field];
112
+ } else {
113
+ // Top-level field
114
+ formikErrors[field] = error.errors[field];
59
115
  }
60
116
  });
117
+
118
+ console.log('[handleFormError] Storing backend errors in status:', formikErrors);
119
+
120
+ // Store backend validation errors in Formik status
121
+ // This prevents conflict with Yup validation
122
+ // Components should check status.backendErrors first, then fall back to Formik errors
123
+ if (setStatus) {
124
+ setStatus({ backendErrors: formikErrors });
125
+ }
61
126
 
62
- // Show error message
63
- if (showToast) {
64
- console.error(error.message || 'Please fix the validation errors');
127
+ // Show error toast with validation message
128
+ if (showToast && toastLib) {
129
+ toastLib.error(error.message || 'Please fix the validation errors');
65
130
  }
66
131
 
67
132
  // Call custom validation error handler if provided
@@ -94,8 +159,8 @@ export const handleFormError = (error, setFieldError, options = {}) => {
94
159
  // Backend MUST send error.message (standardized)
95
160
  const errorMessage = error.message || fallbackMessage;
96
161
 
97
- if (showToast) {
98
- console.error(errorMessage);
162
+ if (showToast && toastLib) {
163
+ toastLib.error(errorMessage);
99
164
  }
100
165
 
101
166
  // Call custom error handler if provided
@@ -112,6 +177,7 @@ export const handleFormError = (error, setFieldError, options = {}) => {
112
177
  /**
113
178
  * Handle API errors without Formik
114
179
  * For simple forms or non-Formik scenarios
180
+ * Returns errors object for manual handling
115
181
  *
116
182
  * @param {Object} error - Error object from API call
117
183
  * @param {Object} options - Configuration options
@@ -119,16 +185,26 @@ export const handleFormError = (error, setFieldError, options = {}) => {
119
185
  * @param {Function} options.onError - Callback for errors
120
186
  * @param {boolean} options.showToast - Whether to show toast notification (default: true)
121
187
  * @param {boolean} options.logError - Whether to log error to console (default: true)
188
+ * @param {Object} options.toast - Toast library instance (react-toastify, sonner, etc.)
122
189
  *
123
190
  * @example
124
191
  * ```javascript
125
- * import { handleApiError } from '@neastore-js/web-utils';
192
+ * import { handleApiError } from 'vasuzex/react';
193
+ * import { toast } from 'react-toastify';
126
194
  *
127
195
  * try {
128
196
  * await api.delete('/brands/123');
129
197
  * toast.success("Brand deleted");
130
198
  * } catch (error) {
131
- * handleApiError(error, { fallbackMessage: "Failed to delete brand" });
199
+ * const result = handleApiError(error, {
200
+ * fallbackMessage: "Failed to delete brand",
201
+ * toast
202
+ * });
203
+ *
204
+ * // Access validation errors if needed
205
+ * if (result.type === 'validation') {
206
+ * console.log(result.errors); // { field: "error message" }
207
+ * }
132
208
  * }
133
209
  * ```
134
210
  */
@@ -138,6 +214,7 @@ export const handleApiError = (error, options = {}) => {
138
214
  onError,
139
215
  showToast = true,
140
216
  logError = true,
217
+ toast: toastLib,
141
218
  } = options;
142
219
 
143
220
  // Log error for debugging
@@ -150,9 +227,9 @@ export const handleApiError = (error, options = {}) => {
150
227
  const errorMessage = error.message || fallbackMessage;
151
228
 
152
229
  // Show toast notification
153
- if (showToast && !error.isPermissionError) {
230
+ if (showToast && toastLib && !error.isPermissionError) {
154
231
  // Don't show toast for permission errors as api-client already shows SweetAlert
155
- toast.error(errorMessage);
232
+ toastLib.error(errorMessage);
156
233
  }
157
234
 
158
235
  // Call custom error handler if provided
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Enhanced Photo Manager with DnD, Bulk Operations, and Custom Components
3
+ *
4
+ * Production-ready photo management component with:
5
+ * - Drag & drop upload
6
+ * - Sortable photos (drag to reorder)
7
+ * - Bulk selection and delete
8
+ * - Custom component support
9
+ * - Upload progress tracking
10
+ * - Responsive grid layout
11
+ *
12
+ * @module components/PhotoManager/EnhancedPhotoManager
13
+ */
14
+
15
+ import { useState, useEffect } from "react";
16
+ import PropTypes from "prop-types";
17
+ import {
18
+ DndContext,
19
+ closestCenter,
20
+ KeyboardSensor,
21
+ PointerSensor,
22
+ useSensor,
23
+ useSensors,
24
+ } from "@dnd-kit/core";
25
+ import {
26
+ arrayMove,
27
+ SortableContext,
28
+ sortableKeyboardCoordinates,
29
+ rectSortingStrategy,
30
+ } from "@dnd-kit/sortable";
31
+
32
+ /**
33
+ * Enhanced Photo Manager Component
34
+ *
35
+ * @param {Object} props
36
+ * @param {string} props.entityId - Entity ID (store, product, etc.)
37
+ * @param {Object} props.photosHook - Hook with photos management functions
38
+ * @param {string} [props.title="Photos"] - Section title
39
+ * @param {string} [props.description] - Section description
40
+ * @param {string} [props.emptyStateText="No photos uploaded yet"] - Empty state message
41
+ * @param {number} [props.maxFiles=20] - Maximum number of photos allowed
42
+ * @param {string} [props.gridCols="grid-cols-2 sm:grid-cols-3 md:grid-cols-4"] - Grid columns CSS
43
+ * @param {Object} props.components - Custom components (UploadDropZone, PhotoCard, etc.)
44
+ *
45
+ * @example
46
+ * <EnhancedPhotoManager
47
+ * entityId={storeId}
48
+ * photosHook={useStorePhotos(storeId)}
49
+ * title="Store Photos"
50
+ * maxFiles={20}
51
+ * components={{
52
+ * UploadDropZone,
53
+ * PhotoCard,
54
+ * UploadProgressCard,
55
+ * TrashIcon,
56
+ * }}
57
+ * />
58
+ */
59
+ export function EnhancedPhotoManager({
60
+ entityId,
61
+ photosHook,
62
+ title = "Photos",
63
+ description,
64
+ emptyStateText = "No photos uploaded yet",
65
+ maxFiles = 20,
66
+ gridCols = "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
67
+ components = {},
68
+ }) {
69
+ const {
70
+ photos = [],
71
+ uploadingFiles = [],
72
+ loading = false,
73
+ fetchPhotos,
74
+ uploadPhotos,
75
+ deletePhoto,
76
+ deleteMultiplePhotos,
77
+ reorderPhotos,
78
+ } = photosHook;
79
+
80
+ const [selectedPhotos, setSelectedPhotos] = useState([]);
81
+ const [sortedPhotos, setSortedPhotos] = useState([]);
82
+
83
+ // Extract custom components
84
+ const UploadDropZone = components.UploadDropZone;
85
+ const PhotoCard = components.PhotoCard;
86
+ const UploadProgressCard = components.UploadProgressCard;
87
+ const TrashIcon = components.TrashIcon;
88
+
89
+ // Sensors for drag & drop
90
+ const sensors = useSensors(
91
+ useSensor(PointerSensor, {
92
+ activationConstraint: {
93
+ distance: 8,
94
+ },
95
+ }),
96
+ useSensor(KeyboardSensor, {
97
+ coordinateGetter: sortableKeyboardCoordinates,
98
+ }),
99
+ );
100
+
101
+ // Fetch photos on mount
102
+ useEffect(() => {
103
+ if (entityId && fetchPhotos) {
104
+ fetchPhotos();
105
+ }
106
+ }, [entityId, fetchPhotos]);
107
+
108
+ // Update sorted photos when photos change
109
+ useEffect(() => {
110
+ setSortedPhotos(photos);
111
+ }, [photos]);
112
+
113
+ // Handle file selection
114
+ const handleFilesSelected = async (files) => {
115
+ if (uploadPhotos) {
116
+ await uploadPhotos(files);
117
+ }
118
+ };
119
+
120
+ // Handle photo selection toggle
121
+ const handlePhotoSelect = (photoId) => {
122
+ setSelectedPhotos((prev) =>
123
+ prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId],
124
+ );
125
+ };
126
+
127
+ // Handle single photo delete
128
+ const handlePhotoDelete = async (photoId) => {
129
+ if (deletePhoto && confirm("Delete this photo?")) {
130
+ await deletePhoto(photoId);
131
+ setSelectedPhotos((prev) => prev.filter((id) => id !== photoId));
132
+ }
133
+ };
134
+
135
+ // Handle bulk delete
136
+ const handleBulkDelete = async () => {
137
+ if (
138
+ selectedPhotos.length > 0 &&
139
+ deleteMultiplePhotos &&
140
+ confirm(`Delete ${selectedPhotos.length} selected photo(s)?`)
141
+ ) {
142
+ await deleteMultiplePhotos(selectedPhotos);
143
+ setSelectedPhotos([]);
144
+ }
145
+ };
146
+
147
+ // Handle drag end - reorder photos
148
+ const handleDragEnd = async (event) => {
149
+ const { active, over } = event;
150
+
151
+ if (active.id !== over.id) {
152
+ const oldIndex = sortedPhotos.findIndex((p) => p.id === active.id);
153
+ const newIndex = sortedPhotos.findIndex((p) => p.id === over.id);
154
+
155
+ const newOrder = arrayMove(sortedPhotos, oldIndex, newIndex);
156
+ setSortedPhotos(newOrder);
157
+
158
+ // Update backend
159
+ if (reorderPhotos) {
160
+ await reorderPhotos(newOrder);
161
+ }
162
+ }
163
+ };
164
+
165
+ // Select all photos
166
+ const handleSelectAll = () => {
167
+ if (selectedPhotos.length === sortedPhotos.length) {
168
+ setSelectedPhotos([]);
169
+ } else {
170
+ setSelectedPhotos(sortedPhotos.map((p) => p.id));
171
+ }
172
+ };
173
+
174
+ const canUpload = sortedPhotos.length < maxFiles;
175
+ const hasSelection = selectedPhotos.length > 0;
176
+
177
+ return (
178
+ <div className="vasuzex-enhanced-photo-manager space-y-6">
179
+ {/* Header */}
180
+ <div className="flex items-start justify-between">
181
+ <div>
182
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
183
+ {description && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>}
184
+ </div>
185
+
186
+ {/* Bulk Actions */}
187
+ {hasSelection && (
188
+ <div className="flex items-center gap-2">
189
+ <span className="text-sm text-gray-600 dark:text-gray-400">
190
+ {selectedPhotos.length} selected
191
+ </span>
192
+ <button
193
+ type="button"
194
+ onClick={handleBulkDelete}
195
+ className="px-3 py-1.5 text-sm font-medium rounded-lg
196
+ bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400
197
+ hover:bg-red-100 dark:hover:bg-red-900/30
198
+ transition-colors duration-200"
199
+ >
200
+ {TrashIcon && <TrashIcon className="w-4 h-4 inline mr-1" />}
201
+ Delete
202
+ </button>
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ {/* Upload Drop Zone */}
208
+ {canUpload && UploadDropZone && (
209
+ <UploadDropZone
210
+ onFilesSelected={handleFilesSelected}
211
+ disabled={loading}
212
+ maxFiles={maxFiles - sortedPhotos.length}
213
+ />
214
+ )}
215
+
216
+ {/* Info Bar */}
217
+ <div className="flex items-center justify-between text-sm">
218
+ <div className="text-gray-600 dark:text-gray-400">
219
+ {sortedPhotos.length} / {maxFiles} photos
220
+ </div>
221
+
222
+ {sortedPhotos.length > 0 && (
223
+ <button
224
+ type="button"
225
+ onClick={handleSelectAll}
226
+ className="text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 font-medium"
227
+ >
228
+ {selectedPhotos.length === sortedPhotos.length ? "Deselect All" : "Select All"}
229
+ </button>
230
+ )}
231
+ </div>
232
+
233
+ {/* Uploading Files Progress */}
234
+ {uploadingFiles.length > 0 && UploadProgressCard && (
235
+ <div className={`grid ${gridCols} gap-4`}>
236
+ {uploadingFiles.map((file) => (
237
+ <UploadProgressCard key={file.id} file={file} />
238
+ ))}
239
+ </div>
240
+ )}
241
+
242
+ {/* Photos Grid with DnD */}
243
+ {sortedPhotos.length > 0 && PhotoCard && (
244
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
245
+ <SortableContext items={sortedPhotos.map((p) => p.id)} strategy={rectSortingStrategy}>
246
+ <div className={`grid ${gridCols} gap-4`}>
247
+ {sortedPhotos.map((photo, index) => (
248
+ <PhotoCard
249
+ key={photo.id}
250
+ photo={{ ...photo, order: index }}
251
+ isSelected={selectedPhotos.includes(photo.id)}
252
+ onSelect={handlePhotoSelect}
253
+ onDelete={handlePhotoDelete}
254
+ disabled={loading}
255
+ />
256
+ ))}
257
+ </div>
258
+ </SortableContext>
259
+ </DndContext>
260
+ )}
261
+
262
+ {/* Empty State */}
263
+ {sortedPhotos.length === 0 && uploadingFiles.length === 0 && !loading && (
264
+ <div className="text-center py-12 text-gray-500 dark:text-gray-400">
265
+ <p>{emptyStateText}</p>
266
+ </div>
267
+ )}
268
+
269
+ {/* Loading State */}
270
+ {loading && sortedPhotos.length === 0 && (
271
+ <div className="text-center py-12">
272
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
273
+ <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading photos...</p>
274
+ </div>
275
+ )}
276
+ </div>
277
+ );
278
+ }
279
+
280
+ EnhancedPhotoManager.propTypes = {
281
+ /** Entity ID (store, product, etc.) */
282
+ entityId: PropTypes.string.isRequired,
283
+ /** Photos hook with management functions */
284
+ photosHook: PropTypes.shape({
285
+ photos: PropTypes.array,
286
+ uploadingFiles: PropTypes.array,
287
+ loading: PropTypes.bool,
288
+ fetchPhotos: PropTypes.func,
289
+ uploadPhotos: PropTypes.func,
290
+ deletePhoto: PropTypes.func,
291
+ deleteMultiplePhotos: PropTypes.func,
292
+ reorderPhotos: PropTypes.func,
293
+ }).isRequired,
294
+ /** Section title */
295
+ title: PropTypes.string,
296
+ /** Section description */
297
+ description: PropTypes.string,
298
+ /** Empty state message */
299
+ emptyStateText: PropTypes.string,
300
+ /** Maximum number of photos */
301
+ maxFiles: PropTypes.number,
302
+ /** Grid columns CSS classes */
303
+ gridCols: PropTypes.string,
304
+ /** Custom components */
305
+ components: PropTypes.shape({
306
+ UploadDropZone: PropTypes.elementType,
307
+ PhotoCard: PropTypes.elementType,
308
+ UploadProgressCard: PropTypes.elementType,
309
+ TrashIcon: PropTypes.elementType,
310
+ }),
311
+ };
312
+
313
+ export default EnhancedPhotoManager;
@@ -8,11 +8,13 @@
8
8
 
9
9
  import { useState, useRef, useCallback } from 'react';
10
10
  import PropTypes from 'prop-types';
11
+ import { EnhancedPhotoManager } from './EnhancedPhotoManager.jsx';
11
12
 
12
13
  /**
13
14
  * Photo upload and management component
14
15
  *
15
16
  * @param {Object} props
17
+ * @param {string} [props.variant='basic'] - 'basic' or 'enhanced' for advanced features
16
18
  * @param {Array<string|Object>} [props.photos=[]] - Initial photos (URLs or objects)
17
19
  * @param {Function} props.onPhotosChange - Callback when photos change
18
20
  * @param {Function} [props.onUpload] - Custom upload handler
@@ -24,6 +26,13 @@ import PropTypes from 'prop-types';
24
26
  * @param {boolean} [props.disabled] - Disable uploads
25
27
  * @param {Function} [props.getPhotoUrl] - Get URL from photo object
26
28
  *
29
+ * Enhanced variant props:
30
+ * @param {string|number} props.entityId - Entity ID (for enhanced variant)
31
+ * @param {Object} props.photosHook - Custom photos hook (for enhanced variant)
32
+ * @param {string} [props.title] - Title for enhanced variant
33
+ * @param {string} [props.description] - Description for enhanced variant
34
+ * @param {Object} [props.components] - Custom components for enhanced variant
35
+ *
27
36
  * @example
28
37
  * <PhotoManager
29
38
  * photos={photos}
@@ -39,6 +48,7 @@ import PropTypes from 'prop-types';
39
48
  * />
40
49
  */
41
50
  export function PhotoManager({
51
+ variant = 'basic',
42
52
  photos = [],
43
53
  onPhotosChange,
44
54
  onUpload,
@@ -49,7 +59,12 @@ export function PhotoManager({
49
59
  className = '',
50
60
  disabled = false,
51
61
  getPhotoUrl = (photo) => typeof photo === 'string' ? photo : photo.url,
62
+ ...enhancedProps
52
63
  }) {
64
+ // If enhanced variant requested, use EnhancedPhotoManager
65
+ if (variant === 'enhanced') {
66
+ return <EnhancedPhotoManager maxFiles={maxPhotos} {...enhancedProps} />;
67
+ }
53
68
  const [uploading, setUploading] = useState(false);
54
69
  const [dragActive, setDragActive] = useState(false);
55
70
  const [error, setError] = useState(null);
@@ -292,6 +307,8 @@ PhotoManager.propTypes = {
292
307
  }),
293
308
  ])
294
309
  ),
310
+ /** Component variant - 'basic' for simple manager, 'enhanced' for advanced features */
311
+ variant: PropTypes.oneOf(['basic', 'enhanced']),
295
312
  /** Callback when photos change */
296
313
  onPhotosChange: PropTypes.func,
297
314
  /** Alternative prop name for onChange */
@@ -318,6 +335,23 @@ PhotoManager.propTypes = {
318
335
  getPhotoUrl: PropTypes.func,
319
336
  /** Helper text displayed below upload area */
320
337
  helperText: PropTypes.string,
338
+ // Enhanced variant props
339
+ /** Entity ID (required for enhanced variant) */
340
+ entityId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
341
+ /** Photos hook with photos, loading, error, upload, etc. (required for enhanced variant) */
342
+ photosHook: PropTypes.object,
343
+ /** Title for enhanced variant */
344
+ title: PropTypes.string,
345
+ /** Description for enhanced variant */
346
+ description: PropTypes.string,
347
+ /** Custom components for enhanced variant */
348
+ components: PropTypes.object,
349
+ /** Grid columns class for enhanced variant */
350
+ gridCols: PropTypes.string,
351
+ /** Empty state text for enhanced variant */
352
+ emptyStateText: PropTypes.string,
353
+ /** Max files for enhanced variant */
354
+ maxFiles: PropTypes.number,
321
355
  };
322
356
 
323
357
  PhotoManager.defaultProps = {
@@ -0,0 +1,423 @@
1
+ # PhotoManager Component
2
+
3
+ Reusable photo management component for all vasuzex-based applications.
4
+
5
+ ## Features
6
+
7
+ - **Two Variants**: Basic and Enhanced
8
+ - **Drag & Drop**: Reorder photos by dragging
9
+ - **Bulk Operations**: Select multiple photos and delete them together
10
+ - **Upload Progress**: Real-time upload tracking
11
+ - **Custom Components**: Override default UI components
12
+ - **Responsive Grid**: Configurable grid layouts
13
+ - **Type Safety**: PropTypes validation
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pnpm add @dnd-kit/core @dnd-kit/sortable react-dropzone
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ Simple photo manager with upload and delete:
24
+
25
+ ```jsx
26
+ import { PhotoManager } from 'vasuzex/react';
27
+
28
+ function MyComponent() {
29
+ const [photos, setPhotos] = useState([]);
30
+
31
+ return (
32
+ <PhotoManager
33
+ photos={photos}
34
+ onPhotosChange={setPhotos}
35
+ onUpload={async (file) => {
36
+ // Upload file and return URL
37
+ const formData = new FormData();
38
+ formData.append('photo', file);
39
+ const res = await fetch('/api/upload', {
40
+ method: 'POST',
41
+ body: formData
42
+ });
43
+ const data = await res.json();
44
+ return data.url;
45
+ }}
46
+ maxPhotos={10}
47
+ />
48
+ );
49
+ }
50
+ ```
51
+
52
+ ## Enhanced Usage
53
+
54
+ Advanced photo manager with DnD, bulk operations, and custom components:
55
+
56
+ ```jsx
57
+ import { PhotoManager } from 'vasuzex/react';
58
+ import { useStorePhotos } from './hooks/useStorePhotos';
59
+ import { PhotoCard, UploadDropZone, UploadProgressCard } from './components';
60
+ import { TrashBinIcon } from './icons';
61
+
62
+ function StorePhotosTab({ storeId }) {
63
+ const photosHook = useStorePhotos(storeId);
64
+
65
+ return (
66
+ <PhotoManager
67
+ variant="enhanced"
68
+ entityId={storeId}
69
+ photosHook={photosHook}
70
+ title="Store Photos"
71
+ description="Upload and manage store photos. Drag to reorder display priority."
72
+ emptyStateText="Upload your first store photo"
73
+ maxFiles={20}
74
+ gridCols="grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
75
+ components={{
76
+ UploadDropZone,
77
+ PhotoCard,
78
+ UploadProgressCard,
79
+ TrashIcon: TrashBinIcon,
80
+ }}
81
+ />
82
+ );
83
+ }
84
+ ```
85
+
86
+ ## Custom Photos Hook
87
+
88
+ The enhanced variant requires a custom hook that provides photo management functions:
89
+
90
+ ```jsx
91
+ import { useState, useEffect, useCallback } from 'react';
92
+ import apiClient from '../config/api';
93
+
94
+ export function useStorePhotos(storeId) {
95
+ const [photos, setPhotos] = useState([]);
96
+ const [loading, setLoading] = useState(false);
97
+ const [error, setError] = useState(null);
98
+ const [uploadingFiles, setUploadingFiles] = useState([]);
99
+ const [selectedPhotos, setSelectedPhotos] = useState([]);
100
+
101
+ // Fetch photos
102
+ const fetchPhotos = useCallback(async () => {
103
+ if (!storeId) return;
104
+
105
+ setLoading(true);
106
+ setError(null);
107
+
108
+ try {
109
+ const response = await apiClient.get(`/api/stores/${storeId}/photos`);
110
+ setPhotos(response.data.data || []);
111
+ } catch (err) {
112
+ setError(err.message);
113
+ console.error('Failed to fetch photos:', err);
114
+ } finally {
115
+ setLoading(false);
116
+ }
117
+ }, [storeId]);
118
+
119
+ // Upload photo
120
+ const uploadPhoto = async (file, type = 'gallery') => {
121
+ const uploadId = `${Date.now()}-${file.name}`;
122
+
123
+ setUploadingFiles((prev) => [
124
+ ...prev,
125
+ {
126
+ id: uploadId,
127
+ name: file.name,
128
+ progress: 0,
129
+ status: 'uploading',
130
+ },
131
+ ]);
132
+
133
+ try {
134
+ const formData = new FormData();
135
+ formData.append('photo', file);
136
+ formData.append('type', type);
137
+
138
+ const response = await apiClient.post(
139
+ `/api/stores/${storeId}/photos`,
140
+ formData,
141
+ {
142
+ headers: { 'Content-Type': 'multipart/form-data' },
143
+ onUploadProgress: (progressEvent) => {
144
+ const percentCompleted = Math.round(
145
+ (progressEvent.loaded * 100) / progressEvent.total
146
+ );
147
+ setUploadingFiles((prev) =>
148
+ prev.map((f) =>
149
+ f.id === uploadId ? { ...f, progress: percentCompleted } : f
150
+ )
151
+ );
152
+ },
153
+ }
154
+ );
155
+
156
+ setUploadingFiles((prev) =>
157
+ prev.map((f) =>
158
+ f.id === uploadId ? { ...f, status: 'completed' } : f
159
+ )
160
+ );
161
+
162
+ await fetchPhotos();
163
+
164
+ setTimeout(() => {
165
+ setUploadingFiles((prev) => prev.filter((f) => f.id !== uploadId));
166
+ }, 2000);
167
+
168
+ return response.data.data;
169
+ } catch (err) {
170
+ setUploadingFiles((prev) =>
171
+ prev.map((f) =>
172
+ f.id === uploadId ? { ...f, status: 'error' } : f
173
+ )
174
+ );
175
+ throw err;
176
+ }
177
+ };
178
+
179
+ // Delete photo
180
+ const deletePhoto = async (photoId) => {
181
+ try {
182
+ await apiClient.delete(`/api/stores/${storeId}/photos/${photoId}`);
183
+ await fetchPhotos();
184
+ } catch (err) {
185
+ console.error('Failed to delete photo:', err);
186
+ throw err;
187
+ }
188
+ };
189
+
190
+ // Bulk delete
191
+ const bulkDeletePhotos = async (photoIds) => {
192
+ try {
193
+ await apiClient.post(`/api/stores/${storeId}/photos/bulk-delete`, {
194
+ photoIds,
195
+ });
196
+ await fetchPhotos();
197
+ setSelectedPhotos([]);
198
+ } catch (err) {
199
+ console.error('Failed to bulk delete photos:', err);
200
+ throw err;
201
+ }
202
+ };
203
+
204
+ // Reorder photos
205
+ const reorderPhotos = async (photoIds) => {
206
+ try {
207
+ await apiClient.post(`/api/stores/${storeId}/photos/reorder`, {
208
+ photoIds,
209
+ });
210
+ await fetchPhotos();
211
+ } catch (err) {
212
+ console.error('Failed to reorder photos:', err);
213
+ throw err;
214
+ }
215
+ };
216
+
217
+ // Set primary photo
218
+ const setPrimaryPhoto = async (photoId) => {
219
+ try {
220
+ await apiClient.post(`/api/stores/${storeId}/photos/${photoId}/set-primary`);
221
+ await fetchPhotos();
222
+ } catch (err) {
223
+ console.error('Failed to set primary photo:', err);
224
+ throw err;
225
+ }
226
+ };
227
+
228
+ // Fetch photos on mount
229
+ useEffect(() => {
230
+ fetchPhotos();
231
+ }, [fetchPhotos]);
232
+
233
+ return {
234
+ photos,
235
+ loading,
236
+ error,
237
+ uploadingFiles,
238
+ selectedPhotos,
239
+ setSelectedPhotos,
240
+ uploadPhoto,
241
+ deletePhoto,
242
+ bulkDeletePhotos,
243
+ reorderPhotos,
244
+ setPrimaryPhoto,
245
+ refetch: fetchPhotos,
246
+ };
247
+ }
248
+ ```
249
+
250
+ ## Custom Components
251
+
252
+ ### PhotoCard
253
+
254
+ ```jsx
255
+ import { useSortable } from '@dnd-kit/sortable';
256
+ import { CSS } from '@dnd-kit/utilities';
257
+
258
+ export function PhotoCard({
259
+ photo,
260
+ isSelected,
261
+ onSelect,
262
+ onDelete,
263
+ TrashIcon
264
+ }) {
265
+ const {
266
+ attributes,
267
+ listeners,
268
+ setNodeRef,
269
+ transform,
270
+ transition,
271
+ isDragging,
272
+ } = useSortable({ id: photo.id });
273
+
274
+ const style = {
275
+ transform: CSS.Transform.toString(transform),
276
+ transition,
277
+ opacity: isDragging ? 0.5 : 1,
278
+ };
279
+
280
+ return (
281
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
282
+ {/* Your photo card UI */}
283
+ </div>
284
+ );
285
+ }
286
+ ```
287
+
288
+ ### UploadDropZone
289
+
290
+ ```jsx
291
+ import { useDropzone } from 'react-dropzone';
292
+
293
+ export function UploadDropZone({ onDrop, maxFiles, UploadIcon }) {
294
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
295
+ onDrop,
296
+ accept: { 'image/*': [] },
297
+ multiple: true,
298
+ maxFiles,
299
+ });
300
+
301
+ return (
302
+ <div {...getRootProps()}>
303
+ <input {...getInputProps()} />
304
+ {/* Your dropzone UI */}
305
+ </div>
306
+ );
307
+ }
308
+ ```
309
+
310
+ ### UploadProgressCard
311
+
312
+ ```jsx
313
+ export function UploadProgressCard({ file }) {
314
+ return (
315
+ <div>
316
+ <div>{file.name}</div>
317
+ <div>Progress: {file.progress}%</div>
318
+ <div>Status: {file.status}</div>
319
+ </div>
320
+ );
321
+ }
322
+ ```
323
+
324
+ ## Props
325
+
326
+ ### Basic Variant
327
+
328
+ | Prop | Type | Default | Description |
329
+ |------|------|---------|-------------|
330
+ | variant | `'basic' \| 'enhanced'` | `'basic'` | Component variant |
331
+ | photos | `Array` | `[]` | Initial photos (URLs or objects) |
332
+ | onPhotosChange | `Function` | - | Callback when photos change |
333
+ | onUpload | `Function` | - | Custom upload handler |
334
+ | maxPhotos | `Number` | `10` | Maximum number of photos |
335
+ | maxFileSize | `Number` | `5242880` | Max file size (bytes) |
336
+ | acceptedTypes | `Array<string>` | Image types | Accepted MIME types |
337
+ | multiple | `Boolean` | `true` | Allow multiple uploads |
338
+ | className | `String` | `''` | Additional CSS classes |
339
+ | disabled | `Boolean` | `false` | Disable uploads |
340
+
341
+ ### Enhanced Variant
342
+
343
+ | Prop | Type | Default | Description |
344
+ |------|------|---------|-------------|
345
+ | variant | `'enhanced'` | - | Must be 'enhanced' |
346
+ | entityId | `String \| Number` | - | Entity ID (required) |
347
+ | photosHook | `Object` | - | Custom photos hook (required) |
348
+ | title | `String` | - | Section title |
349
+ | description | `String` | - | Section description |
350
+ | emptyStateText | `String` | - | Empty state message |
351
+ | maxFiles | `Number` | `10` | Maximum files allowed |
352
+ | gridCols | `String` | - | Tailwind grid columns class |
353
+ | components | `Object` | - | Custom components |
354
+
355
+ ### Components Object
356
+
357
+ ```typescript
358
+ {
359
+ UploadDropZone?: Component,
360
+ PhotoCard?: Component,
361
+ UploadProgressCard?: Component,
362
+ TrashIcon?: Component,
363
+ }
364
+ ```
365
+
366
+ ## Use Cases
367
+
368
+ ### Admin App - Store Photos
369
+ ```jsx
370
+ <PhotoManager variant="enhanced" entityId={storeId} photosHook={useStorePhotos(storeId)} />
371
+ ```
372
+
373
+ ### Business App - Business Profile Photos
374
+ ```jsx
375
+ <PhotoManager variant="enhanced" entityId={businessId} photosHook={useBusinessPhotos(businessId)} />
376
+ ```
377
+
378
+ ### Customer App - Product Photos (if needed)
379
+ ```jsx
380
+ <PhotoManager variant="enhanced" entityId={productId} photosHook={useProductPhotos(productId)} />
381
+ ```
382
+
383
+ ### Registration - Business Registration Photos
384
+ ```jsx
385
+ <PhotoManager variant="enhanced" entityId={registrationId} photosHook={useRegistrationPhotos(registrationId)} />
386
+ ```
387
+
388
+ ## Backend Requirements
389
+
390
+ The enhanced variant expects these API endpoints:
391
+
392
+ ```
393
+ GET /api/{entity}/{id}/photos - List photos
394
+ POST /api/{entity}/{id}/photos - Upload photo
395
+ DELETE /api/{entity}/{id}/photos/{photoId} - Delete photo
396
+ POST /api/{entity}/{id}/photos/bulk-delete - Bulk delete
397
+ POST /api/{entity}/{id}/photos/reorder - Reorder photos
398
+ POST /api/{entity}/{id}/photos/{photoId}/set-primary - Set primary
399
+ ```
400
+
401
+ ## Database Schema
402
+
403
+ ```sql
404
+ CREATE TABLE entity_photos (
405
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
406
+ entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
407
+ url TEXT NOT NULL,
408
+ type VARCHAR(50) DEFAULT 'gallery',
409
+ display_order INTEGER DEFAULT 0,
410
+ is_primary BOOLEAN DEFAULT false,
411
+ metadata JSONB,
412
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
413
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
414
+ deleted_at TIMESTAMP
415
+ );
416
+
417
+ CREATE INDEX idx_entity_photos_entity ON entity_photos(entity_id) WHERE deleted_at IS NULL;
418
+ CREATE INDEX idx_entity_photos_order ON entity_photos(entity_id, display_order) WHERE deleted_at IS NULL;
419
+ ```
420
+
421
+ ## License
422
+
423
+ MIT
@@ -7,4 +7,5 @@
7
7
  */
8
8
 
9
9
  export { PhotoManager } from './PhotoManager.jsx';
10
+ export { EnhancedPhotoManager } from './EnhancedPhotoManager.jsx';
10
11
  export { default } from './PhotoManager.jsx';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vasuzex",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Laravel-inspired framework for Node.js monorepos - V2 with optimized dependencies",
5
5
  "type": "module",
6
6
  "main": "./framework/index.js",
@@ -89,6 +89,10 @@
89
89
  "pnpm": ">=10.0.0"
90
90
  },
91
91
  "dependencies": {
92
+ "@aws-sdk/client-s3": "^3.948.0",
93
+ "@aws-sdk/s3-request-presigner": "^3.948.0",
94
+ "@dnd-kit/core": "^6.3.1",
95
+ "@dnd-kit/sortable": "^10.0.0",
92
96
  "@reduxjs/toolkit": "^2.5.0",
93
97
  "axios": "^1.13.2",
94
98
  "bcrypt": "^6.0.0",
@@ -114,15 +118,20 @@
114
118
  "ora": "^8.2.0",
115
119
  "pg": "^8.16.3",
116
120
  "plop": "^4.0.1",
121
+ "prop-types": "^15.8.1",
117
122
  "razorpay": "^2.9.6",
118
123
  "react": "^18.2.0",
119
124
  "react-dom": "^18.2.0",
125
+ "react-icons": "^5.5.0",
120
126
  "react-router-dom": "^6.28.0",
127
+ "react-toastify": "^11.0.5",
121
128
  "redux-persist": "^6.0.0",
122
129
  "sharp": "^0.33.5",
123
130
  "stripe": "^20.0.0",
124
131
  "svelte": "^4.2.0",
125
- "vue": "^3.4.0"
132
+ "sweetalert2": "^11.26.3",
133
+ "vue": "^3.4.0",
134
+ "yup": "^1.7.1"
126
135
  },
127
136
  "devDependencies": {
128
137
  "@jest/globals": "^29.7.0",