import { apiFetch } from './client'; import { getDemoBrands, getDemoModels, getDemoRepairConfig, getDemoSeries, getDemoSlots, isDemoCatalogId, } from '@/lib/demoRepairCatalog'; export interface ServiceCatalogItem { service_id: string; name: string; description: string | null; base_price: number; estimated_duration_minutes: number; workflow_type: string; } export interface AvailableSlot { start_time: string; end_time: string; technician_id: string; } export function liveCatalogId(id?: string | null): string | undefined { if (!id || isDemoCatalogId(id)) return undefined; return id; } export function isDemoSlot(slot: AvailableSlot | null | undefined): boolean { return Boolean(slot && isDemoCatalogId(slot.technician_id)); } export function matchServiceCatalogItem( catalog: ServiceCatalogItem[], categoryName?: string ): ServiceCatalogItem | undefined { const list = (catalog || []).filter((item) => item?.service_id); if (!list.length) return undefined; const q = String(categoryName || '').toLowerCase().trim(); if (!q) return list[0]; return ( list.find((item) => item.name.toLowerCase() === q) || list.find( (item) => q.includes(item.name.toLowerCase()) || item.name.toLowerCase().includes(q) ) || list[0] ); } export interface ServiceBookingPayload { device_id?: string; new_device?: { brand: string; model: string; model_number?: string; imei_primary?: string; imei_secondary?: string; color?: string; device_condition?: string; device_type?: string; notes?: string; }; service_id?: string; custom_service_name?: string; customer_name?: string; customer_email?: string; customer_phone?: string; source?: string; appointment?: { scheduled_start: string; scheduled_end: string; } | null; device_type?: string; brand_id?: string; series_id?: string; model_id?: string; service_type_id?: string; repair_service_id?: string; repair_variant_id?: string; priority?: string; fulfillment_type?: 'WALK_IN' | 'COURIER' | 'DOORSTEP_PICKUP'; alt_phone?: string; is_whatsapp_alt?: boolean; delivery_address?: string; pre_dispatch_video_id?: string; lock_type?: 'NONE' | 'PIN' | 'PASSWORD' | 'PATTERN'; lock_passcode?: string; } /** Same catalog sources as admin service-catalog page */ type CatalogCache = { brands: any[]; series: any[]; models: any[]; serviceTypes: any[]; repairServices: any[]; repairVariants: any[]; loadedAt: number; }; const LOCAL_JOBS_KEY = 'ifixkart_service_jobs'; let catalogCache: CatalogCache | null = null; const CACHE_TTL_MS = 60_000; async function loadAdminCatalog(force = false): Promise { if ( !force && catalogCache && Date.now() - catalogCache.loadedAt < CACHE_TTL_MS ) { return catalogCache; } const [ brands, series, models, serviceTypes, repairServices, repairVariants, ] = await Promise.all([ apiFetch('/api/v1/catalog/brands/all'), apiFetch('/api/v1/catalog/device-series/all'), apiFetch('/api/v1/catalog/device-models/all'), apiFetch('/api/v1/catalog/service-types/all'), apiFetch('/api/v1/catalog/repair-services/all'), apiFetch('/api/v1/catalog/repair-variants/all'), ]); catalogCache = { brands: Array.isArray(brands) ? brands : [], series: Array.isArray(series) ? series : [], models: Array.isArray(models) ? models : [], serviceTypes: Array.isArray(serviceTypes) ? serviceTypes : [], repairServices: Array.isArray(repairServices) ? repairServices : [], repairVariants: Array.isArray(repairVariants) ? repairVariants : [], loadedAt: Date.now(), }; return catalogCache; } function matchesDeviceType( itemDeviceType: string | null | undefined, selected?: string ): boolean { if (!selected) return true; const needle = selected.toLowerCase(); // Untagged catalog rows are treated as mobile (legacy phone seed) if (!itemDeviceType) return needle === 'mobile'; return String(itemDeviceType).toLowerCase() === needle; } function brandMatchesDeviceType(brand: any, deviceType?: string): boolean { if (!deviceType) return true; const types: string[] = Array.isArray(brand.device_types) ? brand.device_types : []; const needle = deviceType.toLowerCase(); // Untagged brands only appear under Mobile until admin sets device_types if (types.length === 0) return needle === 'mobile'; return types.some((t) => String(t).toLowerCase() === needle); } function isActive(item: any): boolean { return item?.is_active !== false && item?.status !== 'INACTIVE'; } /** Build repair categories the wizard expects, from admin repair-services + variants + service-types */ function buildRepairConfigFromCatalog( modelId: string, cache: CatalogCache ): { categories: any[] } { const typeMap = new Map( cache.serviceTypes.map((st) => [st.service_type_id, st]) ); const modelServices = cache.repairServices.filter( (rs) => rs.model_id === modelId ); const categories = modelServices .map((rs) => { const st = typeMap.get(rs.service_type_id); const variants = cache.repairVariants .filter( (v) => v.repair_service_id === rs.repair_service_id && String(v.status || 'ACTIVE').toUpperCase() !== 'INACTIVE' ) .map((v) => ({ variant_id: v.variant_id, name: v.name, price: Number(v.price) || 0, warranty_days: Number(v.warranty_days) || 0, duration_minutes: Number(v.duration_minutes) || 0, repair_service_id: v.repair_service_id, })); return { service_type_id: rs.service_type_id, repair_service_id: rs.repair_service_id, category_name: st?.name || rs.full_path || 'Repair', description: rs.description || st?.description || '', variants, }; }) .filter((c) => c.variants.length > 0); return { categories }; } export const serviceBookingService = { fetchDeviceTypes(): Promise { return apiFetch('/api/v1/catalog/device-types'); }, async fetchBrands(deviceType?: string): Promise { try { const cache = await loadAdminCatalog(); const filtered = cache.brands .filter(isActive) .filter((b) => brandMatchesDeviceType(b, deviceType)); if (filtered.length > 0) return filtered; } catch { /* demo fallback */ } // No admin brands tagged for this device category — use category demo catalog return getDemoBrands(deviceType); }, async fetchSeriesByBrand( brandId: string, deviceType?: string ): Promise { if (isDemoCatalogId(brandId)) { return getDemoSeries(brandId); } try { const cache = await loadAdminCatalog(); const list = cache.series .filter(isActive) .filter((s) => s.brand_id === brandId) .filter((s) => matchesDeviceType(s.device_type, deviceType)) .sort( (a, b) => (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0) || String(a.name || '').localeCompare(String(b.name || '')) ); if (list.length > 0) return list; } catch { /* fall through */ } // Live brand has no series for this device type return []; }, async fetchModelsBySeries( seriesId: string, deviceType?: string ): Promise { if (isDemoCatalogId(seriesId)) { return getDemoModels(seriesId); } try { const cache = await loadAdminCatalog(); const list = cache.models .filter(isActive) .filter((m) => m.series_id === seriesId) .filter((m) => matchesDeviceType(m.device_type, deviceType)) .sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')) ); if (list.length > 0) return list; } catch { /* fall through */ } return []; }, async fetchRepairConfig( modelId: string, deviceType?: string ): Promise { if (isDemoCatalogId(modelId)) { return getDemoRepairConfig(modelId, { deviceType }); } let modelName: string | undefined; let resolvedDeviceType = deviceType; try { const cache = await loadAdminCatalog(); const model = cache.models.find((m) => m.model_id === modelId); modelName = model?.name; if (!resolvedDeviceType) { resolvedDeviceType = model?.device_type || undefined; } const config = buildRepairConfigFromCatalog(modelId, cache); if (config.categories.length > 0) return config; try { const legacy = await apiFetch( `/api/v1/catalog/service/repair-config/${modelId}` ); if (legacy?.categories?.length) return legacy; } catch { /* ignore */ } } catch { /* fall through to hardcoded */ } // Hardcoded repair types per device category when admin has no mappings return getDemoRepairConfig(modelId, { modelName, deviceType: resolvedDeviceType, }); }, fetchServiceCatalog(): Promise { return apiFetch('/api/v1/service/catalog'); }, fetchServiceJob(jobId: string): Promise { return apiFetch(`/api/v1/service/jobs/${jobId}`); }, async fetchServiceJobs(): Promise { const data = await apiFetch('/api/v1/service/jobs'); if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.jobs)) return data.jobs; return []; }, rememberLocalJob(job: { job_id?: string; job_no?: string }) { if (!job?.job_id && !job?.job_no) return; try { const raw = localStorage.getItem(LOCAL_JOBS_KEY); const list: Array<{ job_id?: string; job_no?: string }> = raw ? JSON.parse(raw) : []; const next = [ { job_id: job.job_id, job_no: job.job_no }, ...list.filter( (j) => j.job_id !== job.job_id && j.job_no !== job.job_no ), ].slice(0, 20); localStorage.setItem(LOCAL_JOBS_KEY, JSON.stringify(next)); try { sessionStorage.setItem(LOCAL_JOBS_KEY, JSON.stringify(next)); } catch { /* ignore */ } } catch { /* ignore */ } }, readLocalJobs(): Array<{ job_id?: string; job_no?: string }> { try { const raw = localStorage.getItem(LOCAL_JOBS_KEY) || sessionStorage.getItem(LOCAL_JOBS_KEY); const list = raw ? JSON.parse(raw) : []; return Array.isArray(list) ? list : []; } catch { return []; } }, async fetchAvailableSlots( serviceId: string, targetDate: string ): Promise { try { const data = await apiFetch( `/api/v1/service/slots/available?service_id=${encodeURIComponent( serviceId )}&target_date=${encodeURIComponent(targetDate)}` ); if (Array.isArray(data) && data.length > 0) return data; } catch { /* fall through */ } return getDemoSlots(targetDate); }, createServiceBooking(payload: ServiceBookingPayload): Promise<{ job_id: string; job_no: string; appointment_id: string | null; hold_expires_at: string | null; total_price?: number; advance_deposit?: number; remaining_balance?: number; }> { return apiFetch('/api/v1/service/booking/create', { method: 'POST', body: JSON.stringify(payload), }); }, uploadMediaFile(file: File, entityType = 'service_job', entityId = 'media'): Promise<{ file_id: string; webp_path?: string; raw_path?: string }> { const formData = new FormData(); formData.append('file', file); formData.append('entity_type', entityType); formData.append('entity_id', entityId); return apiFetch('/api/v1/files/upload', { method: 'POST', body: formData, }); }, respondToQuote( jobId: string, quoteId: string, action: 'ACCEPT' | 'REJECT' ): Promise { return apiFetch( `/api/v1/service/jobs/${jobId}/quotes/${quoteId}/respond?action=${action}`, { method: 'POST', } ); }, initiateServicePayment( jobId: string, payload: { payment_type: string; amount: number; provider?: string; quote_id?: string; } ): Promise<{ payment_id: string; rzp_order_id: string; amount: number; currency: string; rzp_key_id?: string; }> { return apiFetch(`/api/v1/service/jobs/${jobId}/payments/initiate`, { method: 'POST', body: JSON.stringify(payload), }); }, verifyServicePayment(payload: { payment_id: string; razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string; }): Promise { return apiFetch('/api/v1/service/payments/verify', { method: 'POST', body: JSON.stringify(payload), }); }, rescheduleAppointment( jobId: string, payload: { scheduled_start: string; scheduled_end: string; reason?: string; } ): Promise { return apiFetch(`/api/v1/service/jobs/${jobId}/reschedule`, { method: 'POST', body: JSON.stringify(payload), }); }, fetchServicePageLayout(): Promise { return apiFetch('/api/v1/storefront/layout/services'); }, /** Force refresh of cached admin catalog (brands/series/models/repairs) */ invalidateCatalogCache() { catalogCache = null; }, };