ifixkart-storefront/services/api/serviceBooking.ts

490 lines
13 KiB
TypeScript

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<CatalogCache> {
if (
!force &&
catalogCache &&
Date.now() - catalogCache.loadedAt < CACHE_TTL_MS
) {
return catalogCache;
}
const [
brands,
series,
models,
serviceTypes,
repairServices,
repairVariants,
] = await Promise.all([
apiFetch<any[]>('/api/v1/catalog/brands/all'),
apiFetch<any[]>('/api/v1/catalog/device-series/all'),
apiFetch<any[]>('/api/v1/catalog/device-models/all'),
apiFetch<any[]>('/api/v1/catalog/service-types/all'),
apiFetch<any[]>('/api/v1/catalog/repair-services/all'),
apiFetch<any[]>('/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<string[]> {
return apiFetch<string[]>('/api/v1/catalog/device-types');
},
async fetchBrands(deviceType?: string): Promise<any[]> {
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<any[]> {
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<any[]> {
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<any> {
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<any>(
`/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<ServiceCatalogItem[]> {
return apiFetch<ServiceCatalogItem[]>('/api/v1/service/catalog');
},
fetchServiceJob(jobId: string): Promise<any> {
return apiFetch<any>(`/api/v1/service/jobs/${jobId}`);
},
async fetchServiceJobs(): Promise<any[]> {
const data = await apiFetch<any>('/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<AvailableSlot[]> {
try {
const data = await apiFetch<AvailableSlot[]>(
`/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<any> {
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<any> {
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<any> {
return apiFetch(`/api/v1/service/jobs/${jobId}/reschedule`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
fetchServicePageLayout(): Promise<any[]> {
return apiFetch<any[]>('/api/v1/storefront/layout/services');
},
/** Force refresh of cached admin catalog (brands/series/models/repairs) */
invalidateCatalogCache() {
catalogCache = null;
},
};