From a4f7b6fcfa9f5cf3986fc5f7bcc803d6b07c191d Mon Sep 17 00:00:00 2001 From: Yashwin Date: Wed, 28 Jan 2026 11:51:26 +0530 Subject: [PATCH] Enhance CreateTenantWizard with improved file upload functionality for logo and favicon, including validation, preview, and error handling. Update contact details schema to enforce phone number and postal code formats. Adjust API base URL for local development. --- src/pages/CreateTenantWizard.tsx | 166 ++++++++++++++++++++++++++++--- src/services/api-client.ts | 6 ++ src/services/file-service.ts | 27 +++++ 3 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 src/services/file-service.ts diff --git a/src/pages/CreateTenantWizard.tsx b/src/pages/CreateTenantWizard.tsx index 0463418..5428231 100644 --- a/src/pages/CreateTenantWizard.tsx +++ b/src/pages/CreateTenantWizard.tsx @@ -8,6 +8,7 @@ import { Layout } from '@/components/layout/Layout'; import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import { tenantService } from '@/services/tenant-service'; import { moduleService } from '@/services/module-service'; +import { fileService } from '@/services/file-service'; import { showToast } from '@/utils/toast'; import { ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react'; @@ -44,12 +45,29 @@ const contactDetailsSchema = z confirmPassword: z.string().min(1, 'Confirm password is required'), first_name: z.string().min(1, 'First name is required'), last_name: z.string().min(1, 'Last name is required'), - contact_phone: z.string().optional().nullable(), + contact_phone: z + .string() + .optional() + .nullable() + .refine( + (val) => { + if (!val || val.trim() === '') return true; // Optional field, empty is valid + // Phone number regex: accepts formats like +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, etc. + const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/; + return phoneRegex.test(val.replace(/\s/g, '')); + }, + { + message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)', + } + ), address_line1: z.string().min(1, 'Address is required'), address_line2: z.string().optional().nullable(), city: z.string().min(1, 'City is required'), state: z.string().min(1, 'State is required'), - postal_code: z.string().min(1, 'Postal code is required'), + postal_code: z + .string() + .min(1, 'Postal code is required') + .regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'), country: z.string().min(1, 'Country is required'), }) .refine((data) => data.password === data.confirmPassword, { @@ -84,7 +102,7 @@ const subscriptionTierOptions = [ // Helper function to get base URL without protocol const getBaseUrlWithoutProtocol = (): string => { - const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5173'; // Remove protocol (http:// or https://) return apiBaseUrl.replace(/^https?:\/\//, ''); }; @@ -156,6 +174,14 @@ const CreateTenantWizard = (): ReactElement => { // File upload state for branding const [logoFile, setLogoFile] = useState(null); const [faviconFile, setFaviconFile] = useState(null); + const [logoFilePath, setLogoFilePath] = useState(null); + const [logoFileUrl, setLogoFileUrl] = useState(null); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); + const [faviconFilePath, setFaviconFilePath] = useState(null); + const [faviconFileUrl, setFaviconFileUrl] = useState(null); + const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(null); + const [isUploadingLogo, setIsUploadingLogo] = useState(false); + const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); // Auto-generate slug and domain from name const nameValue = tenantDetailsForm.watch('name'); @@ -289,10 +315,8 @@ const CreateTenantWizard = (): ReactElement => { primary_color: primary_color || undefined, secondary_color: secondary_color || undefined, accent_color: accent_color || undefined, - // Note: logo and favicon files would need to be uploaded separately via FormData - // For now, we're just storing the file references - logo_file: logoFile ? logoFile.name : undefined, - favicon_file: faviconFile ? faviconFile.name : undefined, + logo_file_path: logoFilePath || undefined, + favicon_file_path: faviconFilePath || undefined, }, }, }; @@ -724,9 +748,14 @@ const CreateTenantWizard = (): ReactElement => { id="logo-upload-wizard" type="file" accept="image/png,image/svg+xml,image/jpeg,image/jpg" - onChange={(e) => { + onChange={async (e) => { const file = e.target.files?.[0]; if (file) { + // Clean up previous preview URL if exists + if (logoPreviewUrl) { + URL.revokeObjectURL(logoPreviewUrl); + } + // Validate file size (2MB max) if (file.size > 2 * 1024 * 1024) { showToast.error('Logo file size must be less than 2MB'); @@ -738,15 +767,68 @@ const CreateTenantWizard = (): ReactElement => { showToast.error('Logo must be PNG, SVG, or JPG format'); return; } + // Create local preview URL immediately + const previewUrl = URL.createObjectURL(file); setLogoFile(file); + setLogoPreviewUrl(previewUrl); + setIsUploadingLogo(true); + try { + const response = await fileService.uploadSimple(file); + setLogoFilePath(response.data.file_path); + setLogoFileUrl(response.data.file_url); + // Keep preview URL as fallback, will be cleaned up on component unmount or file change + showToast.success('Logo uploaded successfully'); + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to upload logo. Please try again.'; + showToast.error(errorMessage); + setLogoFile(null); + // Clean up preview URL on error + URL.revokeObjectURL(previewUrl); + setLogoPreviewUrl(null); + setLogoFileUrl(null); + setLogoFilePath(null); + } finally { + setIsUploadingLogo(false); + } } }} className="hidden" /> {logoFile && ( -
- Selected: {logoFile.name} +
+
+ {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} +
+ {(logoPreviewUrl || logoFileUrl) && ( +
+ Logo preview { + console.error('Failed to load logo preview image', { + logoFileUrl, + logoPreviewUrl, + src: e.currentTarget.src, + }); + }} + onLoad={() => { + console.log('Logo preview loaded successfully', { + logoFileUrl, + logoPreviewUrl, + src: logoFileUrl || logoPreviewUrl, + }); + }} + /> +
+ )}
)}
@@ -769,9 +851,14 @@ const CreateTenantWizard = (): ReactElement => { id="favicon-upload-wizard" type="file" accept="image/x-icon,image/png,image/vnd.microsoft.icon" - onChange={(e) => { + onChange={async (e) => { const file = e.target.files?.[0]; if (file) { + // Clean up previous preview URL if exists + if (faviconPreviewUrl) { + URL.revokeObjectURL(faviconPreviewUrl); + } + // Validate file size (500KB max) if (file.size > 500 * 1024) { showToast.error('Favicon file size must be less than 500KB'); @@ -783,15 +870,68 @@ const CreateTenantWizard = (): ReactElement => { showToast.error('Favicon must be ICO or PNG format'); return; } + // Create local preview URL immediately + const previewUrl = URL.createObjectURL(file); setFaviconFile(file); + setFaviconPreviewUrl(previewUrl); + setIsUploadingFavicon(true); + try { + const response = await fileService.uploadSimple(file); + setFaviconFilePath(response.data.file_path); + setFaviconFileUrl(response.data.file_url); + // Keep preview URL as fallback, will be cleaned up on component unmount or file change + showToast.success('Favicon uploaded successfully'); + } catch (err: any) { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to upload favicon. Please try again.'; + showToast.error(errorMessage); + setFaviconFile(null); + // Clean up preview URL on error + URL.revokeObjectURL(previewUrl); + setFaviconPreviewUrl(null); + setFaviconFileUrl(null); + setFaviconFilePath(null); + } finally { + setIsUploadingFavicon(false); + } } }} className="hidden" /> {faviconFile && ( -
- Selected: {faviconFile.name} +
+
+ {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} +
+ {(faviconPreviewUrl || faviconFileUrl) && ( +
+ Favicon preview { + console.error('Failed to load favicon preview image', { + faviconFileUrl, + faviconPreviewUrl, + src: e.currentTarget.src, + }); + }} + onLoad={() => { + console.log('Favicon preview loaded successfully', { + faviconFileUrl, + faviconPreviewUrl, + src: faviconFileUrl || faviconPreviewUrl, + }); + }} + /> +
+ )}
)}
diff --git a/src/services/api-client.ts b/src/services/api-client.ts index 1161e1c..546551b 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -14,6 +14,12 @@ const apiClient: AxiosInstance = axios.create({ // Request interceptor to add auth token to ALL requests apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { + // For FormData requests, let axios automatically set Content-Type with boundary + // Remove the default application/json Content-Type if FormData is detected + if (config.data instanceof FormData && config.headers) { + delete config.headers['Content-Type']; + } + // Always try to get token from Redux store and add to Authorization header try { const store = (window as any).__REDUX_STORE__; diff --git a/src/services/file-service.ts b/src/services/file-service.ts new file mode 100644 index 0000000..5af78f8 --- /dev/null +++ b/src/services/file-service.ts @@ -0,0 +1,27 @@ +import apiClient from './api-client'; + +export interface FileUploadResponse { + success: boolean; + data: { + original_name: string; + file_path: string; + file_url: string; + mime_type: string; + file_size: number; + file_size_formatted: string; + uploaded_at: string; + }; +} + +export const fileService = { + uploadSimple: async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + // Axios automatically sets Content-Type to multipart/form-data with boundary for FormData + // The interceptor will still add the Authorization header + // Don't set Content-Type explicitly - let axios handle it with the correct boundary + const response = await apiClient.post('/files/upload/simple', formData); + return response.data; + }, +};