ifixkart-storefront/app/services/tracking/[job_id]/page.tsx

698 lines
29 KiB
TypeScript

/**
* @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 (
<div className="min-h-screen bg-[#F8F9FB] text-[#1E293B] flex flex-col font-sans">
<AnnouncementBar />
<Header />
<StickyHeaderSpacer />
<Navbar />
<main className="flex-grow pb-16">{children}</main>
<Footer />
<FloatingButtons />
</div>
);
}
export default function ServiceJobTrackingPage({
params,
}: {
params: Promise<Params>;
}) {
const { job_id } = use(params);
const { isAuthenticated, accessToken, authReady, bootstrapAuth } = useAuthStore();
const [job, setJob] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(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<boolean> => {
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 (
<PageShell>
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<span className="text-[12px] font-medium">Loading repair details...</span>
</div>
</PageShell>
);
}
if (!isAuthenticated) {
return (
<PageShell>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-10 flex justify-center">
<div className="max-w-[440px] w-full bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-5 md:px-6 py-4 border-b border-gray-100 flex items-center gap-2.5">
<Wrench className="w-5 h-5 text-primary shrink-0" />
<div>
<h1 className="text-[18px] font-bold text-[#1a1a1a] tracking-tight">
Track Your Device Repair
</h1>
<p className="text-[12px] text-gray-500 mt-0.5">
Sign in to view this repair job.
</p>
</div>
</div>
<div className="p-5 md:p-6">
<button
type="button"
onClick={() => setAuthModalOpen(true)}
className="w-full bg-primary hover:brightness-95 text-white font-semibold py-3.5 rounded-lg text-[14px] transition cursor-pointer flex items-center justify-center gap-2"
>
<KeyRound className="w-4 h-4" />
Continue with Google
</button>
</div>
</div>
</div>
<GoogleAuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
/>
</PageShell>
);
}
if (!job) {
return (
<PageShell>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-16 flex flex-col items-center justify-center gap-4 text-center">
<AlertTriangle className="w-12 h-12 text-amber-500" />
<h2 className="text-[18px] font-bold text-[#222]">
Service Job Not Found
</h2>
<p className="text-[13px] text-gray-500 max-w-sm">
We could not find job details matching ID{' '}
<span className="font-mono font-bold text-[#222]">{job_id}</span>.
{errorMsg ? ` ${errorMsg}` : ''}
</p>
<Link
href="/services/tracking"
className="mt-2 px-5 py-2.5 bg-primary hover:bg-primary-hover text-white text-[13px] font-semibold transition"
>
Back to Track Repair
</Link>
</div>
</PageShell>
);
}
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 (
<PageShell>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-8">
<Link
href="/services/tracking"
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-gray-500 hover:text-primary mb-4"
>
<ArrowLeft className="w-4 h-4" />
Back to your bookings
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div className="lg:col-span-2 space-y-5">
<div className="bg-white border border-[#e5e5e5]">
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="inline-flex items-center gap-2 text-[11px] font-semibold text-primary mb-1">
<Wrench className="w-3.5 h-3.5" />
<span>Real-Time Device Tracking</span>
</div>
<h1 className="text-[18px] md:text-[20px] font-bold text-[#222] tracking-tight">
Repair Job #{job.job_no}
</h1>
<p className="text-[12px] text-gray-500 mt-1">
Service: {job.service_name} Device: {job.device_brand}{' '}
{job.device_model}
</p>
</div>
<span
className={`px-2.5 py-1 text-[10px] font-bold border self-start uppercase tracking-wide ${getStatusBadge(job.status)}`}
>
{job.status}
</span>
</div>
<div className="p-5 md:p-6 space-y-4">
<h3 className="font-semibold text-[#222] text-[11px] uppercase tracking-wider">
Service Timeline
</h3>
{job.events?.length ? (
<div className="relative pl-6 border-l-2 border-[#e5e5e5] ml-2 space-y-5">
{job.events.map((event: any, index: number) => (
<div key={index} className="relative">
<span className="absolute -left-[31px] top-1 w-3.5 h-3.5 rounded-full bg-primary border-2 border-white" />
<div className="space-y-0.5">
<span className="font-bold text-[#222] text-[12px] uppercase tracking-wider block">
{event.event_type}
</span>
<span className="text-[13px] text-[#555] block leading-relaxed">
{event.notes}
</span>
<span className="text-[11px] text-gray-400 font-mono block">
{new Date(event.timestamp).toLocaleString()}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-[13px] text-gray-500">
No timeline events yet.
</p>
)}
</div>
</div>
{/* 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 (
<div className="space-y-5">
{/* Inspection Done Card */}
{inspectionMedia.length > 0 && (
<div className="bg-white border border-[#e5e5e5]">
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 text-[11px] font-bold bg-blue-50 text-primary border border-blue-200 rounded uppercase tracking-wide flex items-center gap-1.5">
<Video className="w-3.5 h-3.5" />
Inspection Done
</span>
<span className="text-[12px] text-gray-500 font-medium">Technician Media Proof</span>
</div>
</div>
<div className="p-5 md:p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{inspectionMedia.map((item: any, idx: number) => {
const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm');
return (
<div key={idx} className="relative rounded-lg overflow-hidden border border-gray-200 bg-gray-900 group">
{isVideo ? (
<video controls className="w-full h-48 object-cover">
<source src={item.url} type={item.mime_type || 'video/mp4'} />
Your browser does not support HTML5 video.
</video>
) : (
<div
className="relative w-full h-48 cursor-pointer overflow-hidden"
onClick={() => setPreviewImage(item.url)}
>
<BlurHashImage
src={item.url}
alt={`Inspection proof photo ${idx + 1}`}
blurHash={item.blur_hash}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white">
<Maximize2 className="w-6 h-6" />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
{/* Ready for Delivery Card */}
{deliveryMedia.length > 0 && (
<div className="bg-white border border-[#e5e5e5]">
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded uppercase tracking-wide flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" />
Ready for Delivery
</span>
<span className="text-[12px] text-gray-500 font-medium">Repaired Product Proof</span>
</div>
</div>
<div className="p-5 md:p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{deliveryMedia.map((item: any, idx: number) => {
const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm');
return (
<div key={idx} className="relative rounded-lg overflow-hidden border border-gray-200 bg-gray-900 group">
{isVideo ? (
<video controls className="w-full h-48 object-cover">
<source src={item.url} type={item.mime_type || 'video/mp4'} />
Your browser does not support HTML5 video.
</video>
) : (
<div
className="relative w-full h-48 cursor-pointer overflow-hidden"
onClick={() => setPreviewImage(item.url)}
>
<BlurHashImage
src={item.url}
alt={`Ready for delivery proof photo ${idx + 1}`}
blurHash={item.blur_hash}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white">
<Maximize2 className="w-6 h-6" />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
})()}
</div>
<div className="lg:col-span-1 space-y-5">
{(() => {
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 (
<div className="bg-white border border-[#e5e5e5] p-5 space-y-3 text-[13px]">
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2 flex items-center gap-1.5">
<CreditCard className="w-4 h-4 text-primary" /> Payment
</h4>
<div className="flex justify-between text-gray-500">
<span>Status</span>
<span
className={`font-bold ${
isPaidStatus ? 'text-emerald-600' : 'text-amber-600'
}`}
>
{displayPayStatus}
</span>
</div>
{paid > 0 && (
<div className="flex justify-between text-gray-500">
<span>Advance paid</span>
<span className="font-bold text-primary">
{formatCurrency(paid)}
</span>
</div>
)}
{totalQuote > 0 && (
<div className="flex justify-between text-gray-500">
<span>Balance due</span>
<span className="font-semibold text-[#222]">
{formatCurrency(remaining)}
</span>
</div>
)}
</div>
);
})()}
{job.appointment && (
<div className="bg-white border border-[#e5e5e5] p-5 space-y-3 text-[13px]">
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2">
Booking Schedule
</h4>
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-primary shrink-0 mt-0.5" />
<div>
<div className="font-bold text-[#222]">
{new Date(
job.appointment.scheduled_start
).toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
})}
</div>
<div className="text-gray-500 font-mono text-[12px] mt-0.5">
Start:{' '}
{new Date(
job.appointment.scheduled_start
).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
</div>
)}
{job.quote && (
<div className="bg-white border border-[#e5e5e5] p-5 space-y-4 text-[13px]">
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2 flex items-center gap-1.5">
<FileText className="w-4 h-4 text-primary" /> Estimate Quote V
{job.quote.version}
</h4>
<div className="space-y-2 text-[#555]">
<div className="flex justify-between">
<span>Subtotal</span>
<span className="font-semibold text-[#222]">
{formatCurrency(job.quote.subtotal)}
</span>
</div>
{job.quote.additional_damage_amount > 0 && (
<div className="flex justify-between text-amber-600 font-semibold">
<span>Additional Damage</span>
<span>
+{formatCurrency(job.quote.additional_damage_amount)}
</span>
</div>
)}
<div className="flex justify-between">
<span>GST Tax (18%)</span>
<span className="font-semibold text-[#222]">
{formatCurrency(job.quote.tax)}
</span>
</div>
<div className="flex justify-between border-t border-[#e5e5e5] pt-2 font-bold text-[14px] text-[#222]">
<span>Revised Total</span>
<span className="text-primary">
{formatCurrency(job.quote.total)}
</span>
</div>
</div>
{job.quote.status === 'PENDING_CUSTOMER' && (
<div className="space-y-2 pt-1">
<div className="flex gap-2">
<button
type="button"
disabled={actionLoading}
onClick={() => handleRespondToQuote('ACCEPT')}
className="flex-1 bg-primary hover:bg-primary-hover text-white font-semibold py-2.5 text-[12px] transition cursor-pointer disabled:opacity-60"
>
{actionLoading ? 'Approving...' : 'Accept Quote'}
</button>
<button
type="button"
disabled={actionLoading}
onClick={() => handleRespondToQuote('REJECT')}
className="flex-1 border border-[#e5e5e5] hover:bg-[#f7f9fc] text-[#333] font-semibold py-2.5 text-[12px] transition cursor-pointer disabled:opacity-60"
>
Reject
</button>
</div>
{job.quote.expires_at && (
<span className="text-[11px] text-gray-400 block text-center font-mono pt-1">
Expires on:{' '}
{new Date(job.quote.expires_at).toLocaleDateString()}
</span>
)}
</div>
)}
{job.status === 'QUOTE_ACCEPTED' &&
job.quote.additional_damage_amount > 0 && (
<button
type="button"
disabled={actionLoading}
onClick={() => handleMilestonePayment('ADDITIONAL')}
className="w-full bg-primary hover:bg-primary-hover text-white font-semibold py-3 text-[12px] transition flex items-center justify-center gap-2 cursor-pointer disabled:opacity-60"
>
<CreditCard className="w-4 h-4" />
<span>
Pay Additional Charge (
{formatCurrency(job.quote.additional_damage_amount)})
</span>
</button>
)}
</div>
)}
{job.status === 'FINAL_PAYMENT_PENDING' && (
<div className="bg-white border border-emerald-200 p-5 space-y-3 text-[13px]">
<h4 className="font-semibold text-emerald-700 uppercase tracking-wider text-[11px]">
Pay Outstanding Balance
</h4>
<p className="text-gray-500 leading-relaxed">
Repair complete! Please pay the remaining balance to download
the tax invoice and schedule pickup.
</p>
<button
type="button"
disabled={actionLoading}
onClick={() => handleMilestonePayment('FINAL')}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-3 text-[13px] transition cursor-pointer disabled:opacity-60"
>
Complete Final Payment
</button>
</div>
)}
<div className="bg-white border border-[#e5e5e5] p-5 text-[13px]">
<Link
href="/services/tracking"
className="text-primary font-semibold hover:underline"
>
Track another job
</Link>
</div>
</div>
</div>
</div>
{previewImage && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] w-full h-full flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImage}
alt="Proof media full view"
className="max-w-full max-h-full object-contain rounded-lg"
/>
</div>
</div>
)}
</PageShell>
);
}