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.

This commit is contained in:
Yashwin 2026-01-28 11:51:26 +05:30
parent 4226f67923
commit a4f7b6fcfa
3 changed files with 186 additions and 13 deletions

View File

@ -8,6 +8,7 @@ import { Layout } from '@/components/layout/Layout';
import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react'; 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'), confirmPassword: z.string().min(1, 'Confirm password is required'),
first_name: z.string().min(1, 'First name is required'), first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last 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_line1: z.string().min(1, 'Address is required'),
address_line2: z.string().optional().nullable(), address_line2: z.string().optional().nullable(),
city: z.string().min(1, 'City is required'), city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State 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'), country: z.string().min(1, 'Country is required'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
@ -84,7 +102,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL without protocol // Helper function to get base URL without protocol
const getBaseUrlWithoutProtocol = (): string => { 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://) // Remove protocol (http:// or https://)
return apiBaseUrl.replace(/^https?:\/\//, ''); return apiBaseUrl.replace(/^https?:\/\//, '');
}; };
@ -156,6 +174,14 @@ const CreateTenantWizard = (): ReactElement => {
// File upload state for branding // File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
// Auto-generate slug and domain from name // Auto-generate slug and domain from name
const nameValue = tenantDetailsForm.watch('name'); const nameValue = tenantDetailsForm.watch('name');
@ -289,10 +315,8 @@ const CreateTenantWizard = (): ReactElement => {
primary_color: primary_color || undefined, primary_color: primary_color || undefined,
secondary_color: secondary_color || undefined, secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined, accent_color: accent_color || undefined,
// Note: logo and favicon files would need to be uploaded separately via FormData logo_file_path: logoFilePath || undefined,
// For now, we're just storing the file references favicon_file_path: faviconFilePath || undefined,
logo_file: logoFile ? logoFile.name : undefined,
favicon_file: faviconFile ? faviconFile.name : undefined,
}, },
}, },
}; };
@ -724,9 +748,14 @@ const CreateTenantWizard = (): ReactElement => {
id="logo-upload-wizard" id="logo-upload-wizard"
type="file" type="file"
accept="image/png,image/svg+xml,image/jpeg,image/jpg" accept="image/png,image/svg+xml,image/jpeg,image/jpg"
onChange={(e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Clean up previous preview URL if exists
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
// Validate file size (2MB max) // Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
showToast.error('Logo file size must be less than 2MB'); 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'); showToast.error('Logo must be PNG, SVG, or JPG format');
return; return;
} }
// Create local preview URL immediately
const previewUrl = URL.createObjectURL(file);
setLogoFile(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" className="hidden"
/> />
</label> </label>
{logoFile && ( {logoFile && (
<div className="text-xs text-[#6b7280] mt-1"> <div className="flex flex-col gap-2 mt-1">
Selected: {logoFile.name} <div className="text-xs text-[#6b7280]">
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
</div>
{(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2">
<img
key={logoPreviewUrl || logoFileUrl}
src={logoPreviewUrl || logoFileUrl || ''}
alt="Logo preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', maxHeight: '80px' }}
onError={(e) => {
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,
});
}}
/>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -769,9 +851,14 @@ const CreateTenantWizard = (): ReactElement => {
id="favicon-upload-wizard" id="favicon-upload-wizard"
type="file" type="file"
accept="image/x-icon,image/png,image/vnd.microsoft.icon" accept="image/x-icon,image/png,image/vnd.microsoft.icon"
onChange={(e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Clean up previous preview URL if exists
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
// Validate file size (500KB max) // Validate file size (500KB max)
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
showToast.error('Favicon file size must be less than 500KB'); 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'); showToast.error('Favicon must be ICO or PNG format');
return; return;
} }
// Create local preview URL immediately
const previewUrl = URL.createObjectURL(file);
setFaviconFile(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" className="hidden"
/> />
</label> </label>
{faviconFile && ( {faviconFile && (
<div className="text-xs text-[#6b7280] mt-1"> <div className="flex flex-col gap-2 mt-1">
Selected: {faviconFile.name} <div className="text-xs text-[#6b7280]">
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
</div>
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2">
<img
key={faviconFileUrl || faviconPreviewUrl || ''}
src={faviconPreviewUrl || faviconFileUrl || ''}
alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
style={{ display: 'block', width: '64px', height: '64px' }}
onError={(e) => {
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,
});
}}
/>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -14,6 +14,12 @@ const apiClient: AxiosInstance = axios.create({
// Request interceptor to add auth token to ALL requests // Request interceptor to add auth token to ALL requests
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (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 // Always try to get token from Redux store and add to Authorization header
try { try {
const store = (window as any).__REDUX_STORE__; const store = (window as any).__REDUX_STORE__;

View File

@ -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<FileUploadResponse> => {
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<FileUploadResponse>('/files/upload/simple', formData);
return response.data;
},
};