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

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>
);
}