490 lines
13 KiB
TypeScript
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;
|
|
},
|
|
};
|