ifixkart-storefront/app/services/tracking/page.tsx

405 lines
14 KiB
TypeScript

/**
* @page Service bookings list (`app/services/tracking/page.tsx`)
* @purpose Look up a job number and list the customer's bookings. Details open on a separate page.
*/
'use client';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { formatCurrency } from '@/lib/utils';
import { serviceBookingService } from '@/services/api/serviceBooking';
import { setAccessToken } from '@/services/api/client';
import { useAuthStore } from '@/store/authStore';
import { Clock, Loader2, Search, ChevronRight, Smartphone, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
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';
import GoogleAuthModal from '@/components/auth/GoogleAuthModal';
function unwrapJob(raw: any): any {
if (!raw) return null;
if (raw.job && typeof raw.job === 'object') return { ...raw, ...raw.job };
return raw;
}
function statusBadgeClass(status?: string) {
const s = String(status || '').toUpperCase();
if (s === 'CLOSED' || s === 'DELIVERED' || s === 'BOOKED') {
return 'border-emerald-200 text-emerald-700 bg-emerald-50';
}
if (s === 'CANCELLED' || s === 'BOOKING_PENDING' || s === 'PENDING') {
return 'border-amber-200 text-amber-700 bg-amber-50';
}
if (s === 'FINAL_PAYMENT_PENDING') {
return 'border-red-200 text-red-700 bg-red-50';
}
return 'border-primary/20 text-primary bg-primary/5';
}
function formatStatus(status?: string) {
return String(status || 'UNKNOWN').replace(/_/g, ' ');
}
function firstPayment(job: any): any | null {
if (!job) return null;
const list = Array.isArray(job.payments)
? job.payments
: Array.isArray(job.payment_records)
? job.payment_records
: job.payment
? [job.payment]
: [];
return list[0] || null;
}
function resolvePayment(job: any) {
const payment = firstPayment(job);
const basePrice = Number(job?.base_price ?? job?.quoted_price ?? 0);
const paidFromPayment = Number(payment?.amount ?? payment?.paid_amount ?? 0);
const paidAmount =
paidFromPayment > 0 ? paidFromPayment : basePrice > 0 ? basePrice * 0.2 : 0;
const status =
payment?.status ||
payment?.payment_status ||
(String(job?.status || '').toUpperCase() === 'BOOKING_PENDING'
? 'PENDING'
: paidAmount > 0
? 'CAPTURED'
: '—');
return { paidAmount, status };
}
function jobDetailPath(item: { job_id?: string; job_no?: string }, fallback?: string) {
const id = item.job_id || item.job_no || fallback || '';
return id ? `/services/tracking/${encodeURIComponent(id)}` : '';
}
function TrackingListContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, accessToken, authReady, bootstrapAuth } =
useAuthStore();
const [inputJobNo, setInputJobNo] = useState('');
const [debouncedJobNo, setDebouncedJobNo] = useState('');
const [myJobs, setMyJobs] = useState<any[]>([]);
const [loadingMyJobs, setLoadingMyJobs] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedJobNo(inputJobNo.trim());
}, 350);
return () => window.clearTimeout(timer);
}, [inputJobNo]);
const filteredJobs = useMemo(() => {
const q = debouncedJobNo.toLowerCase();
if (!q) return myJobs;
return myJobs.filter((job) => {
const haystack = [
job.job_no,
job.job_id,
job.service_name,
job.device_brand,
job.device_model,
job.status,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
}, [myJobs, debouncedJobNo]);
useEffect(() => {
const fromQuery = searchParams.get('job_id') || searchParams.get('job_no');
if (fromQuery) {
router.replace(`/services/tracking/${encodeURIComponent(fromQuery)}`);
}
}, [searchParams, router]);
useEffect(() => {
void bootstrapAuth();
}, [bootstrapAuth]);
useEffect(() => {
if (accessToken) setAccessToken(accessToken);
}, [accessToken]);
useEffect(() => {
if (!authReady || !isAuthenticated) {
setMyJobs([]);
setLoadingMyJobs(false);
return;
}
void loadMyBookings();
}, [authReady, isAuthenticated, accessToken]);
const loadMyBookings = async () => {
setLoadingMyJobs(true);
const byKey = new Map<string, any>();
const addJob = (raw: any) => {
const item = unwrapJob(raw);
if (!item) return;
const key = String(item.job_id || item.job_no || '');
if (!key) return;
byKey.set(key, item);
};
try {
const token = useAuthStore.getState().accessToken;
if (token) setAccessToken(token);
const local = serviceBookingService.readLocalJobs();
local.forEach((ref) => {
if (ref.job_id || ref.job_no) {
addJob({
job_id: ref.job_id,
job_no: ref.job_no,
status: 'BOOKED',
service_name: 'Device repair',
});
}
});
await Promise.all(
local.map(async (ref) => {
const id = ref.job_id || ref.job_no;
if (!id) return;
try {
addJob(await serviceBookingService.fetchServiceJob(id));
} catch {
/* keep stub */
}
})
);
const sorted = Array.from(byKey.values()).sort((a, b) => {
const ta = new Date(a.created_at || a.updated_at || 0).getTime();
const tb = new Date(b.created_at || b.updated_at || 0).getTime();
return tb - ta;
});
setMyJobs(sorted);
} finally {
setLoadingMyJobs(false);
}
};
const openJobPage = (item?: { job_id?: string; job_no?: string }, fallback?: string) => {
const path = jobDetailPath(item || {}, fallback);
if (!path) return;
router.push(path);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
const q = inputJobNo.trim();
if (!q) {
toast.warning(
'Please enter a valid Service Job Number (e.g. SRV-1787644172).'
);
return;
}
const match = myJobs.find(
(j) => j.job_no === q || j.job_id === q
);
openJobPage(match, q);
};
if (!authReady) {
return (
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-[12px] font-medium">Loading...</span>
</div>
);
}
if (!isAuthenticated) {
return (
<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">
<Clock 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 see your repair bookings and track job status.
</p>
</div>
</div>
<div className="p-5 md:p-6 space-y-4">
<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>
<GoogleAuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-8 space-y-5">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm">
<div className="px-5 md:px-6 py-4 border-b border-gray-100 flex items-center gap-2.5">
<Clock className="w-5 h-5 text-primary shrink-0" />
<div>
<h1 className="text-[18px] md:text-[20px] font-bold text-[#1a1a1a] tracking-tight">
Track Your Device Repair
</h1>
<p className="text-[12px] text-gray-500 mt-0.5">
Enter your Service Booking Reference ID (e.g., SRV-1787644172)
</p>
</div>
</div>
<form
onSubmit={handleSearchSubmit}
className="p-5 md:p-6 flex flex-col sm:flex-row gap-3"
>
<div className="relative flex-1">
<Search className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="e.g. SRV-1787644172"
value={inputJobNo}
onChange={(e) => setInputJobNo(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-lg text-[13px] font-mono text-[#1a1a1a] outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
/>
</div>
<button
type="submit"
className="px-6 py-2.5 bg-primary hover:brightness-95 text-white font-semibold text-[13px] rounded-lg transition cursor-pointer shrink-0"
>
Track Status
</button>
</form>
</div>
<div className="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">
<h2 className="text-[16px] font-bold text-[#1a1a1a]">Your bookings</h2>
</div>
{loadingMyJobs ? (
<div className="py-10 flex items-center justify-center text-gray-400 gap-2">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<span className="text-[13px]">Loading your bookings...</span>
</div>
) : myJobs.length === 0 ? (
<div className="p-6 text-[13px] text-gray-500 text-center">
No service bookings found yet.
<div className="mt-3">
<Link href="/services" className="text-primary font-semibold hover:underline">
Book a repair
</Link>
</div>
</div>
) : filteredJobs.length === 0 ? (
<div className="p-6 text-[13px] text-gray-500 text-center">
No bookings match {debouncedJobNo}.
</div>
) : (
<div className="divide-y divide-gray-100">
{filteredJobs.map((item) => {
const id = item.job_id || item.job_no;
const device = [item.device_brand, item.device_model]
.filter(Boolean)
.join(' ');
const pay = resolvePayment(item);
return (
<button
key={id}
type="button"
onClick={() => openJobPage(item)}
className="w-full text-left px-5 md:px-6 py-4 flex items-center gap-3 hover:bg-gray-50 transition cursor-pointer"
>
<div className="w-10 h-10 rounded-lg bg-primary-light text-primary flex items-center justify-center shrink-0">
<Smartphone className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-bold text-[13px] text-[#1a1a1a]">
{item.job_no || item.job_id}
</span>
<span
className={`text-[10px] font-bold px-2 py-0.5 uppercase tracking-wide border rounded-md ${statusBadgeClass(item.status)}`}
>
{formatStatus(item.status)}
</span>
</div>
<p className="text-[12px] text-gray-500 mt-0.5 truncate">
{[item.service_name, device].filter(Boolean).join(' • ') ||
'Device repair'}
</p>
<p className="text-[11px] mt-0.5 font-semibold text-emerald-600">
{String(pay.status).toUpperCase() === 'CAPTURED' ||
String(item.status || '').toUpperCase() === 'BOOKED'
? `Advance paid${pay.paidAmount ? `${formatCurrency(pay.paidAmount)}` : ''}`
: `Payment ${formatStatus(pay.status)}`}
</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />
</button>
);
})}
</div>
)}
</div>
<GoogleAuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
/>
</div>
);
}
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 ServiceJobTrackingQueryPage() {
return (
<PageShell>
<Suspense
fallback={
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-[12px] font-medium">Loading tracking...</span>
</div>
}
>
<TrackingListContent />
</Suspense>
</PageShell>
);
}