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; + }, +};