554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
/**
|
|
* @page Service Booking Success & Payment Confirmation (`app/services/success/page.tsx`)
|
|
* @purpose Storefront-chrome confirmation with booking + deposit payment summary.
|
|
*/
|
|
'use client';
|
|
|
|
import { Suspense, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { toast } from 'sonner';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { serviceBookingService } from '@/services/api/serviceBooking';
|
|
import { setAccessToken } from '@/services/api/client';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { parseMetadata } from '@/lib/layoutConfig';
|
|
import {
|
|
findServiceSection,
|
|
isServiceSectionVisible,
|
|
normalizeServiceLayout,
|
|
} from '@/lib/servicePageLayout';
|
|
import {
|
|
CheckCircle2,
|
|
Clock,
|
|
Home,
|
|
Loader2,
|
|
CreditCard,
|
|
ShieldCheck,
|
|
Smartphone,
|
|
Wrench,
|
|
ArrowRight,
|
|
} from 'lucide-react';
|
|
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 { ClientModalsContainer } from '@/components/ClientModalsContainer';
|
|
|
|
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 />
|
|
<ClientModalsContainer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function unwrapJob(raw: any): any {
|
|
if (!raw) return null;
|
|
if (raw.job && typeof raw.job === 'object') return { ...raw, ...raw.job };
|
|
return raw;
|
|
}
|
|
|
|
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 alreadyVerifiedMessage(message?: string) {
|
|
const text = String(message || '').toLowerCase();
|
|
return (
|
|
text.includes('already verified') ||
|
|
text.includes('already captured') ||
|
|
text.includes('already processed')
|
|
);
|
|
}
|
|
|
|
function BookingSuccessContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { accessToken, authReady, bootstrapAuth } = useAuthStore();
|
|
|
|
const jobNoParam = searchParams.get('job_no') || 'SRV-BOOKING';
|
|
const jobId = searchParams.get('job_id') || '';
|
|
const statusParam = searchParams.get('status') || '';
|
|
const qPaymentId = searchParams.get('payment_id') || '';
|
|
const qRzpPaymentId = searchParams.get('rzp_payment_id') || '';
|
|
const qRzpOrderId = searchParams.get('rzp_order_id') || '';
|
|
const qRzpSignature = searchParams.get('rzp_signature') || '';
|
|
const qAmount = Number(searchParams.get('amount') || 0);
|
|
const qMethod = searchParams.get('method') || '';
|
|
const needsVerify = searchParams.get('verify') === '1';
|
|
|
|
const [jobDetails, setJobDetails] = useState<any | null>(null);
|
|
const [layoutCMS, setLayoutCMS] = useState<any[]>([]);
|
|
const [verifyState, setVerifyState] = useState<'idle' | 'verifying' | 'done'>(
|
|
needsVerify ? 'verifying' : 'idle'
|
|
);
|
|
|
|
useEffect(() => {
|
|
void bootstrapAuth();
|
|
}, [bootstrapAuth]);
|
|
|
|
useEffect(() => {
|
|
if (accessToken) setAccessToken(accessToken);
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
toast.dismiss();
|
|
serviceBookingService.rememberLocalJob({
|
|
job_id: jobId || undefined,
|
|
job_no: jobNoParam !== 'SRV-BOOKING' ? jobNoParam : undefined,
|
|
});
|
|
}, [jobId, jobNoParam]);
|
|
|
|
useEffect(() => {
|
|
if (!needsVerify) {
|
|
setVerifyState('done');
|
|
if (jobId) void loadJobDetails(jobId);
|
|
return;
|
|
}
|
|
if (!authReady) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const finish = (verified: boolean) => {
|
|
if (cancelled) return;
|
|
setVerifyState('done');
|
|
const next = new URLSearchParams(searchParams.toString());
|
|
next.delete('verify');
|
|
next.delete('rzp_signature');
|
|
if (verified) next.set('status', 'PAID');
|
|
router.replace(`/services/success?${next.toString()}`);
|
|
if (jobId) void loadJobDetails(jobId);
|
|
};
|
|
|
|
const runVerify = async () => {
|
|
const token = useAuthStore.getState().accessToken;
|
|
if (token) setAccessToken(token);
|
|
|
|
if (!qPaymentId || !qRzpOrderId || !qRzpPaymentId || !qRzpSignature) {
|
|
finish(Boolean(qRzpPaymentId));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let timer = 0;
|
|
const verifyPromise = serviceBookingService.verifyServicePayment({
|
|
payment_id: qPaymentId,
|
|
razorpay_order_id: qRzpOrderId,
|
|
razorpay_payment_id: qRzpPaymentId,
|
|
razorpay_signature: qRzpSignature,
|
|
});
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timer = window.setTimeout(() => reject(new Error('timeout')), 10000);
|
|
});
|
|
try {
|
|
await Promise.race([verifyPromise, timeoutPromise]);
|
|
} finally {
|
|
window.clearTimeout(timer);
|
|
}
|
|
finish(true);
|
|
} catch (err: any) {
|
|
if (alreadyVerifiedMessage(err?.message) || err?.message === 'timeout') {
|
|
finish(true);
|
|
return;
|
|
}
|
|
toast.error(err?.message || 'Payment confirmation is taking longer than expected.');
|
|
finish(Boolean(qRzpPaymentId));
|
|
}
|
|
};
|
|
|
|
void runVerify();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authReady, needsVerify, jobId]);
|
|
|
|
useEffect(() => {
|
|
const loadLayout = async () => {
|
|
try {
|
|
const cms = await serviceBookingService.fetchServicePageLayout();
|
|
setLayoutCMS(normalizeServiceLayout(cms));
|
|
} catch {
|
|
/* defaults */
|
|
}
|
|
};
|
|
void loadLayout();
|
|
}, []);
|
|
|
|
const loadJobDetails = async (id: string) => {
|
|
try {
|
|
const data = await serviceBookingService.fetchServiceJob(id);
|
|
setJobDetails(data);
|
|
const item = unwrapJob(data);
|
|
serviceBookingService.rememberLocalJob({
|
|
job_id: item?.job_id || id,
|
|
job_no: item?.job_no || jobNoParam,
|
|
});
|
|
} catch {
|
|
/* query-param fallback */
|
|
}
|
|
};
|
|
|
|
const job = unwrapJob(jobDetails);
|
|
const payment = firstPayment(job);
|
|
const isPaid =
|
|
statusParam === 'PAID' ||
|
|
Boolean(qRzpPaymentId) ||
|
|
String(payment?.status || payment?.payment_status || '').toUpperCase() ===
|
|
'CAPTURED';
|
|
const isPending = statusParam === 'PENDING' && !isPaid;
|
|
|
|
const jobNo = job?.job_no || jobNoParam;
|
|
const status = job?.status || (isPending ? 'PENDING' : 'BOOKED');
|
|
const deviceLabel = [job?.device_brand, job?.device_model].filter(Boolean).join(' ');
|
|
const serviceName =
|
|
job?.service_name ||
|
|
job?.custom_service_name ||
|
|
job?.repair_category ||
|
|
'';
|
|
const variantName =
|
|
job?.variant_name ||
|
|
job?.repair_variant_name ||
|
|
payment?.variant_name ||
|
|
'';
|
|
|
|
const basePrice = Number(
|
|
job?.base_price ?? job?.quoted_price ?? payment?.base_amount ?? 0
|
|
);
|
|
const paidFromPayment = Number(payment?.amount ?? payment?.paid_amount ?? 0);
|
|
const paidFromQuery = qAmount > 1000 ? qAmount / 100 : qAmount;
|
|
const paidAmount =
|
|
paidFromPayment > 0
|
|
? paidFromPayment
|
|
: paidFromQuery > 0
|
|
? paidFromQuery
|
|
: basePrice > 0
|
|
? basePrice * 0.2
|
|
: 0;
|
|
const doorstepFee = job?.fulfillment_type === 'DOORSTEP_PICKUP' ? 250 : 0;
|
|
const totalQuote = basePrice + doorstepFee;
|
|
const remaining = Math.max(totalQuote - paidAmount, 0);
|
|
const paymentMethod =
|
|
payment?.provider ||
|
|
payment?.payment_method ||
|
|
qMethod ||
|
|
(isPending ? '—' : 'Razorpay');
|
|
const paymentType = payment?.payment_type || (isPending ? 'ADVANCE' : 'ADVANCE');
|
|
const paymentStatus =
|
|
payment?.status ||
|
|
payment?.payment_status ||
|
|
(isPaid ? 'CAPTURED' : isPending ? 'PENDING' : 'CAPTURED');
|
|
const gatewayPaymentId =
|
|
payment?.razorpay_payment_id ||
|
|
payment?.gateway_payment_id ||
|
|
payment?.provider_payment_id ||
|
|
qRzpPaymentId ||
|
|
'';
|
|
const internalPaymentId = payment?.payment_id || qPaymentId || '';
|
|
const paidAt = payment?.paid_at || payment?.created_at || payment?.verified_at;
|
|
const appointmentStart =
|
|
job?.appointment?.scheduled_start || job?.scheduled_start;
|
|
|
|
const howItWorksCMS = findServiceSection(layoutCMS, 'service_how_it_works');
|
|
const howMeta = parseMetadata(howItWorksCMS);
|
|
const showHow = isServiceSectionVisible(layoutCMS, 'service_how_it_works');
|
|
const nextSteps = howMeta.steps || [
|
|
{
|
|
number: '1',
|
|
title: 'Technician Assignment',
|
|
desc: 'Our system matches a certified repair specialist for your device model.',
|
|
},
|
|
{
|
|
number: '2',
|
|
title: 'On-Site Service',
|
|
desc: 'Technician visits your location at the requested time slot to fix your device.',
|
|
},
|
|
{
|
|
number: '3',
|
|
title: 'Inspect & Settle',
|
|
desc: 'Verify your device after repair, pay remaining balance, and get warranty cover.',
|
|
},
|
|
];
|
|
const nextTitle = howItWorksCMS?.title || 'What happens next?';
|
|
const nextSubtitle =
|
|
howItWorksCMS?.subtitle || 'Your repair journey continues with these steps';
|
|
|
|
const infoRows = useMemo(() => {
|
|
const rows: { label: string; value: ReactNode }[] = [
|
|
{ label: 'Booking reference', value: <span className="font-mono">{jobNo}</span> },
|
|
{ label: 'Job status', value: status },
|
|
];
|
|
if (deviceLabel) rows.push({ label: 'Device', value: deviceLabel });
|
|
if (serviceName) rows.push({ label: 'Service', value: serviceName });
|
|
if (variantName) rows.push({ label: 'Price variant', value: variantName });
|
|
if (appointmentStart) {
|
|
rows.push({
|
|
label: 'Scheduled slot',
|
|
value: new Date(appointmentStart).toLocaleString(),
|
|
});
|
|
}
|
|
return rows;
|
|
}, [jobNo, status, deviceLabel, serviceName, variantName, appointmentStart]);
|
|
|
|
if (verifyState === 'verifying' || (needsVerify && !authReady)) {
|
|
return (
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-24 flex flex-col items-center justify-center gap-3 text-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
<p className="text-[14px] font-semibold text-[#1a1a1a]">
|
|
Verifying payment
|
|
</p>
|
|
<p className="text-[12px] text-gray-500">
|
|
Please wait a moment. Do not refresh this page.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-8 space-y-6">
|
|
<div className="bg-white border border-[#e5e5e5] p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-[11px] font-semibold text-primary uppercase tracking-wider">
|
|
Repair booking
|
|
</div>
|
|
<h1 className="text-xl font-bold text-[#222] mt-0.5">
|
|
{isPending ? 'Payment confirmation' : 'Payment verified'}
|
|
</h1>
|
|
</div>
|
|
<div className="text-[12px] text-gray-500 font-semibold">
|
|
Reference:{' '}
|
|
<span className="font-mono font-bold text-[#222]">{jobNo}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-[#e5e5e5] p-6 sm:p-8 space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-center gap-5">
|
|
<div
|
|
className={`w-14 h-14 shrink-0 flex items-center justify-center border ${
|
|
isPending
|
|
? 'bg-amber-50 border-amber-200 text-amber-600'
|
|
: 'bg-emerald-50 border-emerald-200 text-emerald-600'
|
|
}`}
|
|
>
|
|
{isPending ? (
|
|
<Clock className="w-7 h-7" />
|
|
) : (
|
|
<CheckCircle2 className="w-7 h-7" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-2xl font-bold text-[#222] tracking-tight">
|
|
{isPending ? 'Booking received' : 'Payment verified'}
|
|
</h2>
|
|
<p className="text-gray-500 text-sm mt-1 max-w-xl leading-relaxed">
|
|
Service order{' '}
|
|
<span className="font-mono font-bold text-[#222]">{jobNo}</span>{' '}
|
|
{isPending
|
|
? 'is saved. Complete the advance payment to lock your technician slot.'
|
|
: 'is confirmed. Your advance payment is complete and our team is preparing your repair schedule.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
<div className="border border-[#e5e5e5] p-5 space-y-4">
|
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-[#222] border-b border-[#e5e5e5] pb-2">
|
|
<Smartphone className="w-4 h-4 text-primary" />
|
|
Booking details
|
|
</div>
|
|
<dl className="space-y-3 text-[13px]">
|
|
{infoRows.map((row) => (
|
|
<div key={row.label} className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">{row.label}</dt>
|
|
<dd className="font-semibold text-[#222] text-right">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
|
|
<div className="border border-[#e5e5e5] p-5 space-y-4">
|
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-[#222] border-b border-[#e5e5e5] pb-2">
|
|
<CreditCard className="w-4 h-4 text-primary" />
|
|
Payment information
|
|
</div>
|
|
<dl className="space-y-3 text-[13px]">
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Payment type</dt>
|
|
<dd className="font-semibold text-[#222]">{paymentType}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Method</dt>
|
|
<dd className="font-semibold text-[#222]">{paymentMethod}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Payment status</dt>
|
|
<dd
|
|
className={`font-bold ${
|
|
isPending ? 'text-amber-600' : 'text-emerald-600'
|
|
}`}
|
|
>
|
|
{['CAPTURED', 'PAID', 'SUCCESS', 'COMPLETED'].includes(String(paymentStatus).toUpperCase()) ? 'PAID' : String(paymentStatus).replace(/_/g, ' ')}
|
|
</dd>
|
|
</div>
|
|
{basePrice > 0 && (
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Base service quote</dt>
|
|
<dd className="font-semibold text-[#222]">
|
|
{formatCurrency(basePrice)}
|
|
</dd>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">
|
|
{isPending ? 'Advance due (20%)' : 'Advance paid (20%)'}
|
|
</dt>
|
|
<dd className="font-bold text-primary">
|
|
{paidAmount > 0 ? formatCurrency(paidAmount) : '—'}
|
|
</dd>
|
|
</div>
|
|
{basePrice > 0 && (
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Balance due at pickup</dt>
|
|
<dd className="font-semibold text-[#222]">
|
|
{formatCurrency(remaining)}
|
|
</dd>
|
|
</div>
|
|
)}
|
|
{internalPaymentId ? (
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Payment ID</dt>
|
|
<dd className="font-mono text-[12px] font-semibold text-[#222] break-all text-right">
|
|
{internalPaymentId}
|
|
</dd>
|
|
</div>
|
|
) : null}
|
|
{gatewayPaymentId ? (
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Razorpay payment ID</dt>
|
|
<dd className="font-mono text-[12px] font-semibold text-[#222] break-all text-right">
|
|
{gatewayPaymentId}
|
|
</dd>
|
|
</div>
|
|
) : null}
|
|
{paidAt ? (
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-gray-500">Paid at</dt>
|
|
<dd className="font-semibold text-[#222] text-right">
|
|
{new Date(paidAt).toLocaleString()}
|
|
</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
|
|
<div className="flex items-start gap-2 bg-[#F8F9FB] border border-[#e5e5e5] p-3 text-[12px] text-gray-500">
|
|
<ShieldCheck className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
|
<span>
|
|
Remaining balance is collected after the repair is completed
|
|
and verified. Warranty applies to the selected variant.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showHow && (
|
|
<div className="space-y-4 pt-2 border-t border-[#e5e5e5]">
|
|
<div>
|
|
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-wider">
|
|
<Wrench className="w-4 h-4" />
|
|
Next steps
|
|
</div>
|
|
<h3 className="text-lg font-bold text-[#222] mt-1">{nextTitle}</h3>
|
|
{nextSubtitle ? (
|
|
<p className="text-gray-500 text-xs mt-1">{nextSubtitle}</p>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className={`grid grid-cols-1 gap-4 ${
|
|
nextSteps.length >= 3
|
|
? 'md:grid-cols-3'
|
|
: nextSteps.length === 2
|
|
? 'md:grid-cols-2'
|
|
: ''
|
|
}`}
|
|
>
|
|
{nextSteps.map((step: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="bg-[#F8F9FB] p-5 border border-[#e5e5e5] space-y-2"
|
|
>
|
|
<span className="w-8 h-8 bg-primary text-white font-bold text-sm flex items-center justify-center">
|
|
{step.number || idx + 1}
|
|
</span>
|
|
<div className="font-bold text-[#222] text-sm">{step.title}</div>
|
|
<div className="text-xs text-gray-500 leading-relaxed">
|
|
{step.desc}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-2 border-t border-[#e5e5e5]">
|
|
<Link
|
|
href={`/services/tracking/${encodeURIComponent(jobId || jobNo)}`}
|
|
className="px-6 py-3 bg-primary hover:bg-primary-hover text-white font-bold text-[13px] flex items-center justify-center gap-2"
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
Track repair status
|
|
<ArrowRight className="w-4 h-4" />
|
|
</Link>
|
|
<Link
|
|
href="/services"
|
|
className="px-6 py-3 border border-[#e5e5e5] text-[#333] font-semibold text-[13px] hover:bg-[#f7f9fc] flex items-center justify-center gap-2"
|
|
>
|
|
Book another repair
|
|
</Link>
|
|
<Link
|
|
href="/"
|
|
className="px-6 py-3 border border-[#e5e5e5] text-[#333] font-semibold text-[13px] hover:bg-[#f7f9fc] flex items-center justify-center gap-2"
|
|
>
|
|
<Home className="w-4 h-4" />
|
|
Return to storefront
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServiceBookingSuccessPage() {
|
|
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-[13px] font-semibold">
|
|
Verifying payment
|
|
</span>
|
|
</div>
|
|
}
|
|
>
|
|
<BookingSuccessContent />
|
|
</Suspense>
|
|
</PageShell>
|
|
);
|
|
}
|