Compare commits
No commits in common. "c955aecded26d0a673af3a56f45a38e53075906f" and "d7e11f911321a5ed7c8c46d5a02261f4dff424ac" have entirely different histories.
c955aecded
...
d7e11f9113
@ -8,7 +8,6 @@ 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';
|
||||||
|
|
||||||
@ -45,29 +44,12 @@ 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
|
contact_phone: z.string().optional().nullable(),
|
||||||
.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
|
postal_code: z.string().min(1, 'Postal code is required'),
|
||||||
.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, {
|
||||||
@ -102,7 +84,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:5173';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
// Remove protocol (http:// or https://)
|
// Remove protocol (http:// or https://)
|
||||||
return apiBaseUrl.replace(/^https?:\/\//, '');
|
return apiBaseUrl.replace(/^https?:\/\//, '');
|
||||||
};
|
};
|
||||||
@ -174,14 +156,6 @@ 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');
|
||||||
@ -315,8 +289,10 @@ 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,
|
||||||
logo_file_path: logoFilePath || undefined,
|
// Note: logo and favicon files would need to be uploaded separately via FormData
|
||||||
favicon_file_path: faviconFilePath || undefined,
|
// For now, we're just storing the file references
|
||||||
|
logo_file: logoFile ? logoFile.name : undefined,
|
||||||
|
favicon_file: faviconFile ? faviconFile.name : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -748,14 +724,9 @@ 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={async (e) => {
|
onChange={(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');
|
||||||
@ -767,68 +738,15 @@ 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="flex flex-col gap-2 mt-1">
|
<div className="text-xs text-[#6b7280] mt-1">
|
||||||
<div className="text-xs text-[#6b7280]">
|
Selected: {logoFile.name}
|
||||||
{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>
|
||||||
@ -851,14 +769,9 @@ 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={async (e) => {
|
onChange={(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');
|
||||||
@ -870,68 +783,15 @@ 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="flex flex-col gap-2 mt-1">
|
<div className="text-xs text-[#6b7280] mt-1">
|
||||||
<div className="text-xs text-[#6b7280]">
|
Selected: {faviconFile.name}
|
||||||
{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,12 +14,6 @@ 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__;
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
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