Compare commits

..

2 Commits

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 { 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>

View File

@ -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__;

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