/** * @page Service Job Real-Time Tracking & Payments (`app/services/tracking/[job_id]/page.tsx`) * @purpose Progressive timelines, quote approvals, and milestone payments — storefront chrome. */ 'use client'; import { useEffect, useState, use } from 'react'; import Link from 'next/link'; import { useAuthStore } from '@/store/authStore'; import { formatCurrency } from '@/lib/utils'; import { serviceBookingService } from '@/services/api/serviceBooking'; import { setAccessToken } from '@/services/api/client'; import { toast } from 'sonner'; import GoogleAuthModal from '@/components/auth/GoogleAuthModal'; import { Wrench, AlertTriangle, Calendar, CreditCard, Loader2, FileText, ArrowLeft, Video, CheckCircle2, Image as ImageIcon, Maximize2, KeyRound, } from 'lucide-react'; import { BlurHashImage } from '@/components/ui/BlurHashImage'; import { AnnouncementBar } from '@/layout/AnnouncementBar'; import { Header } from '@/layout/Header'; import { Navbar } from '@/layout/Navbar'; import { Footer } from '@/layout/Footer'; import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer'; import { FloatingButtons } from '@/components/FloatingButtons'; interface Params { job_id: string; } function PageShell({ children }: { children: React.ReactNode }) { return (
{children}
); } export default function ServiceJobTrackingPage({ params, }: { params: Promise; }) { const { job_id } = use(params); const { isAuthenticated, accessToken, authReady, bootstrapAuth } = useAuthStore(); const [job, setJob] = useState(null); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(null); const [actionLoading, setActionLoading] = useState(false); const [previewImage, setPreviewImage] = useState(null); const [authModalOpen, setAuthModalOpen] = useState(false); useEffect(() => { void bootstrapAuth(); }, [bootstrapAuth]); useEffect(() => { if (accessToken) setAccessToken(accessToken); }, [accessToken]); useEffect(() => { if (!authReady) return; if (!isAuthenticated) { setLoading(false); setJob(null); return; } void loadJobDetails(); }, [authReady, isAuthenticated, accessToken, job_id]); const loadJobDetails = async () => { const token = useAuthStore.getState().accessToken; if (token) setAccessToken(token); setLoading(true); setErrorMsg(null); try { const data = await serviceBookingService.fetchServiceJob(job_id); const item = data?.job && typeof data.job === 'object' ? { ...data, ...data.job } : data; setJob(item); serviceBookingService.rememberLocalJob({ job_id: item?.job_id || job_id, job_no: item?.job_no, }); } catch (err: any) { setErrorMsg(err.message || 'Failed to retrieve tracking details.'); } finally { setLoading(false); } }; const handleRespondToQuote = async (action: 'ACCEPT' | 'REJECT') => { if (!job?.quote?.quote_id) return; setActionLoading(true); setErrorMsg(null); try { await serviceBookingService.respondToQuote( job_id, job.quote.quote_id, action ); toast.success( `Quote ${action === 'ACCEPT' ? 'Accepted' : 'Rejected'} successfully.` ); await loadJobDetails(); } catch (err: any) { toast.error(err.message || 'Failed to respond to estimate quote.'); } finally { setActionLoading(false); } }; const loadRazorpayScript = (): Promise => { return new Promise((resolve) => { if ((window as any).Razorpay) { resolve(true); return; } const script = document.createElement('script'); script.src = 'https://checkout.razorpay.com/v1/checkout.js'; script.onload = () => resolve(true); script.onerror = () => resolve(false); document.body.appendChild(script); }); }; const handleMilestonePayment = async (type: 'ADDITIONAL' | 'FINAL') => { setActionLoading(true); setErrorMsg(null); try { const scriptLoaded = await loadRazorpayScript(); if (!scriptLoaded) { throw new Error( 'Razorpay SDK failed to load. Please verify your internet connection.' ); } const initPayment = await serviceBookingService.initiateServicePayment( job_id, { payment_type: type, amount: 0, quote_id: job?.quote?.quote_id || undefined, } ); const rzpKey = initPayment.rzp_key_id || process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID || 'rzp_test_TTUzPFYF0hRV89'; const options = { key: rzpKey, amount: initPayment.amount, currency: initPayment.currency || 'INR', name: 'iFixKart Repair Payments', description: `${type} milestone payment for repair #${job.job_no}`, order_id: initPayment.rzp_order_id, redirect: false, theme: { color: '#1976F3' }, handler: async function (response: any) { try { toast.loading('Verifying milestone payment...'); await serviceBookingService.verifyServicePayment({ payment_id: initPayment.payment_id, razorpay_order_id: response.razorpay_order_id, razorpay_payment_id: response.razorpay_payment_id, razorpay_signature: response.razorpay_signature, }); toast.success('Payment verified successfully!'); await loadJobDetails(); } catch (vErr: any) { toast.error(vErr.message || 'Milestone verification failed.'); } finally { setActionLoading(false); } }, modal: { escape: true, handleback: true, confirm_close: true, ondismiss: function () { setActionLoading(false); }, }, }; const rzp = new (window as any).Razorpay(options); rzp.open(); } catch (err: any) { toast.error(err.message || 'Failed to initialize payment gateway.'); setActionLoading(false); } }; if (!authReady || (isAuthenticated && loading)) { return (
Loading repair details...
); } if (!isAuthenticated) { return (

Track Your Device Repair

Sign in to view this repair job.

setAuthModalOpen(false)} />
); } if (!job) { return (

Service Job Not Found

We could not find job details matching ID{' '} {job_id}. {errorMsg ? ` ${errorMsg}` : ''}

Back to Track Repair
); } const getStatusBadge = (status: string) => { switch (status) { case 'CLOSED': case 'DELIVERED': return 'border-emerald-200 text-emerald-700 bg-emerald-50'; case 'CANCELLED': return 'border-red-200 text-red-700 bg-red-50'; case 'BOOKED': case 'REPAIR_IN_PROGRESS': case 'INSPECTION_PENDING': return 'border-primary/20 text-primary bg-primary/5'; default: return 'border-[#e5e5e5] text-[#555] bg-[#f7f9fc]'; } }; return (
Back to your bookings
Real-Time Device Tracking

Repair Job #{job.job_no}

Service: {job.service_name} • Device: {job.device_brand}{' '} {job.device_model}

{job.status}

Service Timeline

{job.events?.length ? (
{job.events.map((event: any, index: number) => (
{event.event_type} {event.notes} {new Date(event.timestamp).toLocaleString()}
))}
) : (

No timeline events yet.

)}
{/* Inspection Done & Ready for Delivery Proof Media Sections (Image 2 Location) */} {(() => { const mediaList: any[] = Array.isArray(job.media) ? job.media : []; const inspectionMedia = mediaList.filter((m) => m.category === 'INSPECTION_DONE'); const deliveryMedia = mediaList.filter((m) => m.category === 'READY_FOR_DELIVERY'); if (inspectionMedia.length === 0 && deliveryMedia.length === 0) return null; return (
{/* Inspection Done Card */} {inspectionMedia.length > 0 && (
Technician Media Proof
{inspectionMedia.map((item: any, idx: number) => { const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm'); return (
{isVideo ? ( ) : (
setPreviewImage(item.url)} >
)}
); })}
)} {/* Ready for Delivery Card */} {deliveryMedia.length > 0 && (
Ready for Delivery Repaired Product Proof
{deliveryMedia.map((item: any, idx: number) => { const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm'); return (
{isVideo ? ( ) : (
setPreviewImage(item.url)} >
)}
); })}
)}
); })()}
{(() => { const payments = Array.isArray(job.payments) ? job.payments : job.payment ? [job.payment] : []; const payment = payments[0] || job.payment; const basePrice = Number(job.base_price || 0); const doorstepFee = job.fulfillment_type === 'DOORSTEP_PICKUP' ? 250 : 0; const totalQuote = basePrice + doorstepFee; const paid = Number(payment?.amount || (totalQuote ? totalQuote * 0.2 : 0)); const remaining = Math.max(totalQuote - paid, 0); const payStatusRaw = payment?.status || payment?.payment_status || (job.status === 'BOOKING_PENDING' ? 'PENDING' : 'PAID'); const payStatusUpper = String(payStatusRaw).toUpperCase(); const isPaidStatus = ['CAPTURED', 'PAID', 'SUCCESS', 'COMPLETED'].includes(payStatusUpper); const displayPayStatus = isPaidStatus ? 'PAID' : payStatusUpper.replace(/_/g, ' '); return (

Payment

Status {displayPayStatus}
{paid > 0 && (
Advance paid {formatCurrency(paid)}
)} {totalQuote > 0 && (
Balance due {formatCurrency(remaining)}
)}
); })()} {job.appointment && (

Booking Schedule

{new Date( job.appointment.scheduled_start ).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric', })}
Start:{' '} {new Date( job.appointment.scheduled_start ).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', })}
)} {job.quote && (

Estimate Quote V {job.quote.version}

Subtotal {formatCurrency(job.quote.subtotal)}
{job.quote.additional_damage_amount > 0 && (
Additional Damage +{formatCurrency(job.quote.additional_damage_amount)}
)}
GST Tax (18%) {formatCurrency(job.quote.tax)}
Revised Total {formatCurrency(job.quote.total)}
{job.quote.status === 'PENDING_CUSTOMER' && (
{job.quote.expires_at && ( Expires on:{' '} {new Date(job.quote.expires_at).toLocaleDateString()} )}
)} {job.status === 'QUOTE_ACCEPTED' && job.quote.additional_damage_amount > 0 && ( )}
)} {job.status === 'FINAL_PAYMENT_PENDING' && (

Pay Outstanding Balance

Repair complete! Please pay the remaining balance to download the tax invoice and schedule pickup.

)}
Track another job
{previewImage && (
setPreviewImage(null)} >
{/* eslint-disable-next-line @next/next/no-img-element */} Proof media full view
)}
); }