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:
parent
4226f67923
commit
a4f7b6fcfa
@ -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<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
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
{logoFile && (
|
||||
<div className="text-xs text-[#6b7280] mt-1">
|
||||
Selected: {logoFile.name}
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<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>
|
||||
@ -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"
|
||||
/>
|
||||
</label>
|
||||
{faviconFile && (
|
||||
<div className="text-xs text-[#6b7280] mt-1">
|
||||
Selected: {faviconFile.name}
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<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>
|
||||
|
||||
@ -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__;
|
||||
|
||||
27
src/services/file-service.ts
Normal file
27
src/services/file-service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user