Compare commits
2 Commits
d7e11f9113
...
c955aecded
| Author | SHA1 | Date | |
|---|---|---|---|
| c955aecded | |||
| a4f7b6fcfa |
@ -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>
|
||||||
|
|||||||
@ -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__;
|
||||||
|
|||||||
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