ifixkart-storefront/app/services/page.tsx

1612 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @page Cashify-Style Progressive Service Scheduler Booking Wizard (`app/services/page.tsx`)
* @purpose Unified drill-down wizard (Device Type -> Brand -> Series -> Model -> Repair Category/Variant -> Slot -> 20% Advance Checkout) + Dynamic CMS Sections (Hero, How It Works, Why Choose Us, FAQs).
*/
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { formatCurrency } from '@/lib/utils';
import {
serviceBookingService,
AvailableSlot,
ServiceBookingPayload,
isDemoSlot,
liveCatalogId,
matchServiceCatalogItem,
} from '@/services/api/serviceBooking';
import { isDemoCatalogId } from '@/lib/demoRepairCatalog';
import { storefrontService } from '@/services/api/storefrontService';
import { setAccessToken } from '@/services/api/client';
import GoogleAuthModal from '@/components/auth/GoogleAuthModal';
import { toast } from 'sonner';
import {
ShieldCheck,
Lock,
Loader2,
ArrowRight,
ArrowLeft,
ChevronDown,
Clock,
HelpCircle,
Check,
Truck,
Store,
Video,
Phone,
MapPin,
KeyRound,
RotateCcw,
Upload,
} 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';
import { PhoneInput } from '@/components/PhoneInput';
import { FormSelect } from '@/components/services/FormSelect';
import { parseMetadata } from '@/lib/layoutConfig';
import {
findServiceSection,
formatDeviceTypeLabel,
getDeviceTypeIcon,
getWhyUsIcon,
isServiceSectionVisible,
normalizeDeviceTypes,
normalizeServiceLayout,
} from '@/lib/servicePageLayout';
type WizardStep = 'selection' | 'schedule' | 'review';
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>
);
}
export default function ServiceBookingPage() {
const router = useRouter();
const { isAuthenticated, accessToken, bootstrapAuth } = useAuthStore();
const [authModalOpen, setAuthModalOpen] = useState(false);
const [activeStep, setActiveStep] = useState<WizardStep>('selection');
const [loadingBrands, setLoadingBrands] = useState(false);
const [loadingSeries, setLoadingSeries] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [advancePercent, setAdvancePercent] = useState<number>(20);
const [loadingRepair, setLoadingRepair] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [checkoutOverlay, setCheckoutOverlay] = useState<
null | 'preparing' | 'verifying'
>(null);
const paymentHandledRef = useRef(false);
// Dynamic CMS Layout Sections State
const [layoutCMS, setLayoutCMS] = useState<any[]>([]);
const [openFaqIdx, setOpenFaqIdx] = useState<number | null>(0);
const [deviceTypes, setDeviceTypes] = useState<string[]>([
'mobile',
'laptop',
'tablet',
'smartwatch',
]);
const [loadingDeviceTypes, setLoadingDeviceTypes] = useState(true);
// Catalog Progressive Selections
const [deviceType, setDeviceType] = useState<string>('mobile');
const [brands, setBrands] = useState<any[]>([]);
const [selectedBrand, setSelectedBrand] = useState<any | null>(null);
const [seriesList, setSeriesList] = useState<any[]>([]);
const [selectedSeries, setSelectedSeries] = useState<any | null>(null);
const [models, setModels] = useState<any[]>([]);
const [selectedModel, setSelectedModel] = useState<any | null>(null);
const [repairConfig, setRepairConfig] = useState<any | null>(null);
const [selectedCategory, setSelectedCategory] = useState<any | null>(null);
const [selectedVariant, setSelectedVariant] = useState<any | null>(null);
// Scheduling State
const [targetDate, setTargetDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [availableSlots, setAvailableSlots] = useState<AvailableSlot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);
// Fulfillment & Customer Form State
const [fulfillmentMode, setFulfillmentMode] = useState<'WALK_IN' | 'DELIVERY'>('WALK_IN');
const [deliverySubOption, setDeliverySubOption] = useState<'COURIER' | 'PICKUP'>('PICKUP');
const [custName, setCustName] = useState<string>('');
const [custEmail, setCustEmail] = useState<string>('');
const [altPhone, setAltPhone] = useState<string>('');
const [isWhatsappAlt, setIsWhatsappAlt] = useState<boolean>(true);
const [deliveryAddress, setDeliveryAddress] = useState<string>('');
// Lock Credentials State
const [lockType, setLockType] = useState<'NONE' | 'PIN' | 'PASSWORD' | 'PATTERN'>('NONE');
const [lockPasscode, setLockPasscode] = useState<string>('');
const [patternNodes, setPatternNodes] = useState<number[]>([]);
// Pre-dispatch Video Upload State
const [preDispatchVideoFile, setPreDispatchVideoFile] = useState<File | null>(null);
const [preDispatchVideoId, setPreDispatchVideoId] = useState<string | null>(null);
const [uploadingVideo, setUploadingVideo] = useState(false);
const handlePatternNodeClick = (node: number) => {
if (patternNodes.includes(node)) {
toast.warning(`Node ${node} is already part of the pattern sequence.`);
return;
}
if (patternNodes.length >= 9) {
toast.warning('Maximum 9 nodes allowed in pattern.');
return;
}
const newSeq = [...patternNodes, node];
setPatternNodes(newSeq);
setLockPasscode(newSeq.join('-'));
};
const resetPattern = () => {
setPatternNodes([]);
setLockPasscode('');
};
const fulfillmentType = fulfillmentMode === 'WALK_IN'
? 'WALK_IN'
: (deliverySubOption === 'PICKUP' ? 'DOORSTEP_PICKUP' : 'COURIER');
const pickupFee = fulfillmentType === 'DOORSTEP_PICKUP' ? 250 : 0;
const basePrice = selectedVariant?.price || 0;
const totalQuote = basePrice + pickupFee;
const advancePrice = Math.round(totalQuote * (advancePercent / 100));
const remainingPrice = totalQuote - advancePrice;
// Fetch dynamic advance percent from CMS settings
useEffect(() => {
storefrontService.getSettings().then((s) => {
if (s.advance_percent !== undefined && s.advance_percent !== null) {
setAdvancePercent(Number(s.advance_percent));
}
}).catch(() => {}); // graceful fallback — keeps 20%
}, []);
useEffect(() => {
void bootstrapAuth();
}, [bootstrapAuth]);
useEffect(() => {
if (accessToken) {
setAccessToken(accessToken);
}
}, [accessToken]);
// Load CMS Service Layout Configuration from Backend Admin CMS
useEffect(() => {
const loadLayout = async () => {
try {
const cms = await serviceBookingService.fetchServicePageLayout();
setLayoutCMS(normalizeServiceLayout(cms));
} catch {
// Graceful fallback to rich defaults if offline or empty
}
};
void loadLayout();
}, []);
// Device categories from catalog API (admin-managed catalog)
useEffect(() => {
const loadDeviceTypes = async () => {
setLoadingDeviceTypes(true);
try {
const types = normalizeDeviceTypes(
await serviceBookingService.fetchDeviceTypes()
);
setDeviceTypes(types);
setDeviceType((prev) => (types.includes(prev) ? prev : types[0]));
} catch {
setDeviceTypes(['mobile', 'laptop', 'tablet', 'smartwatch']);
} finally {
setLoadingDeviceTypes(false);
}
};
void loadDeviceTypes();
}, []);
// Load Brands when Device Type changes
useEffect(() => {
if (!deviceType) return;
loadBrands(deviceType);
}, [deviceType]);
const loadBrands = async (type: string) => {
setLoadingBrands(true);
try {
const data = await serviceBookingService.fetchBrands(type);
setBrands(data);
setSelectedBrand(null);
setSelectedSeries(null);
setSelectedModel(null);
setRepairConfig(null);
setSelectedCategory(null);
setSelectedVariant(null);
} catch (err: any) {
toast.error(err.message || 'Failed to load brands.');
} finally {
setLoadingBrands(false);
}
};
const handleDeviceTypeChange = (type: string) => {
setDeviceType(type);
setSelectedBrand(null);
setSelectedSeries(null);
setSelectedModel(null);
setRepairConfig(null);
setSelectedCategory(null);
setSelectedVariant(null);
};
const handleSelectBrand = async (brand: any) => {
setSelectedBrand(brand);
setSelectedSeries(null);
setSelectedModel(null);
setRepairConfig(null);
setSelectedCategory(null);
setSelectedVariant(null);
setLoadingSeries(true);
try {
const series = await serviceBookingService.fetchSeriesByBrand(brand.brand_id, deviceType);
setSeriesList(series);
} catch (err: any) {
toast.error(err.message || 'Failed to load device series.');
} finally {
setLoadingSeries(false);
}
};
const handleSelectSeries = async (series: any) => {
setSelectedSeries(series);
setSelectedModel(null);
setRepairConfig(null);
setSelectedCategory(null);
setSelectedVariant(null);
setLoadingModels(true);
try {
const mdlList = await serviceBookingService.fetchModelsBySeries(series.series_id);
setModels(mdlList);
} catch (err: any) {
toast.error(err.message || 'Failed to load models.');
} finally {
setLoadingModels(false);
}
};
const handleSelectModel = async (model: any) => {
setSelectedModel(model);
setSelectedCategory(null);
setSelectedVariant(null);
setLoadingRepair(true);
try {
const config = await serviceBookingService.fetchRepairConfig(model.model_id);
setRepairConfig(config);
if (config.categories && config.categories.length > 0) {
setSelectedCategory(config.categories[0]);
if (config.categories[0].variants && config.categories[0].variants.length > 0) {
setSelectedVariant(config.categories[0].variants[0]);
}
}
} catch (err: any) {
toast.error(err.message || 'Failed to load repair options.');
} finally {
setLoadingRepair(false);
}
};
// Slot querying triggered when moving to Schedule step
useEffect(() => {
if (activeStep === 'schedule' && selectedVariant) {
loadSlots();
}
}, [activeStep, targetDate, selectedVariant, selectedCategory]);
const loadSlots = async () => {
setLoadingSlots(true);
setSelectedSlot(null);
try {
let serviceId = 'OTHER_SERVICE';
try {
const catalog = await serviceBookingService.fetchServiceCatalog();
serviceId =
matchServiceCatalogItem(catalog, selectedCategory?.category_name)?.service_id ||
catalog[0]?.service_id ||
'OTHER_SERVICE';
} catch {
/* keep OTHER_SERVICE */
}
const slots = await serviceBookingService.fetchAvailableSlots(serviceId, targetDate);
setAvailableSlots(slots);
} catch (err: any) {
toast.error(err.message || 'Failed to load scheduling slots.');
} finally {
setLoadingSlots(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 handleCreateBooking = async () => {
if (!isAuthenticated) {
toast.info('Please sign in to confirm your booking.');
setAuthModalOpen(true);
return;
}
if (!selectedVariant || !selectedModel) {
toast.warning('Please select your device and repair option.');
return;
}
if (fulfillmentMode === 'WALK_IN' && !selectedSlot) {
toast.warning('Please pick an appointment date and slot for Walk-in fulfillment.');
return;
}
if (fulfillmentMode === 'DELIVERY' && (!deliveryAddress || !deliveryAddress.trim())) {
toast.warning('Please enter your delivery/pickup address.');
return;
}
if (lockType === 'PIN') {
if (!/^\d{4,8}$/.test(lockPasscode.trim())) {
toast.error('PIN must be strictly 4 to 8 numeric digits (0-9).');
return;
}
} else if (lockType === 'PATTERN') {
if (patternNodes.length < 4) {
toast.error('Pattern lock must consist of at least 4 nodes.');
return;
}
}
paymentHandledRef.current = false;
setSubmitting(true);
setCheckoutOverlay('preparing');
try {
let videoId = preDispatchVideoId;
if (preDispatchVideoFile && !videoId) {
setUploadingVideo(true);
try {
const uploaded = await serviceBookingService.uploadMediaFile(preDispatchVideoFile);
videoId = uploaded.file_id;
} catch (err: any) {
toast.error(err.message || 'Pre-courier video upload failed.');
setSubmitting(false);
setCheckoutOverlay(null);
return;
} finally {
setUploadingVideo(false);
}
}
const catalog = await serviceBookingService.fetchServiceCatalog().catch(() => []);
const catalogService = matchServiceCatalogItem(
catalog,
selectedCategory?.category_name
);
const usingDemoRepair =
isDemoCatalogId(selectedVariant.variant_id) ||
isDemoCatalogId(selectedCategory?.service_type_id) ||
isDemoCatalogId(selectedCategory?.repair_service_id);
const serviceId = usingDemoRepair
? catalogService?.service_id || 'OTHER_SERVICE'
: catalogService?.service_id;
const customServiceName = [
selectedCategory?.category_name,
selectedVariant.name,
]
.filter(Boolean)
.join(' — ');
const canLockSlot =
fulfillmentMode === 'WALK_IN' && selectedSlot && !isDemoSlot(selectedSlot);
const preferredSlotNote =
fulfillmentMode === 'WALK_IN' && selectedSlot && !canLockSlot
? `Preferred walk-in slot: ${selectedSlot.start_time} – ${selectedSlot.end_time}`
: undefined;
const payload: ServiceBookingPayload = {
new_device: {
brand: selectedBrand?.name || 'Generic',
model: selectedModel.name,
device_type: deviceType,
notes: preferredSlotNote,
},
service_id: serviceId || 'OTHER_SERVICE',
custom_service_name: customServiceName || undefined,
customer_name: custName.trim() || undefined,
customer_email: custEmail.trim() || undefined,
device_type: deviceType,
brand_id: liveCatalogId(selectedBrand?.brand_id),
series_id: liveCatalogId(selectedSeries?.series_id),
model_id: liveCatalogId(selectedModel.model_id),
service_type_id: liveCatalogId(selectedCategory?.service_type_id),
repair_service_id: liveCatalogId(selectedCategory?.repair_service_id),
repair_variant_id: liveCatalogId(selectedVariant.variant_id),
source: 'ONLINE',
fulfillment_type: fulfillmentType,
alt_phone: altPhone || undefined,
is_whatsapp_alt: isWhatsappAlt,
delivery_address: fulfillmentMode === 'DELIVERY' ? deliveryAddress.trim() : undefined,
pre_dispatch_video_id: videoId || undefined,
lock_type: lockType,
lock_passcode: lockType !== 'NONE' ? lockPasscode : undefined,
appointment: canLockSlot
? {
scheduled_start: selectedSlot.start_time,
scheduled_end: selectedSlot.end_time,
}
: null,
};
const bookingRes = await serviceBookingService.createServiceBooking(payload);
serviceBookingService.rememberLocalJob({
job_id: bookingRes.job_id,
job_no: bookingRes.job_no,
});
const rzpLoaded = await loadRazorpayScript();
if (!rzpLoaded) {
throw new Error('Razorpay SDK failed to load. Please check your network connection.');
}
const pmtAmount = bookingRes.advance_deposit || advancePrice;
const pmtOrder = await serviceBookingService.initiateServicePayment(bookingRes.job_id, {
payment_type: 'ADVANCE',
amount: Math.max(1, Math.round(Number(pmtAmount) || 0)),
provider: 'RAZORPAY',
});
const rzpKey =
pmtOrder.rzp_key_id ||
process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID ||
'rzp_test_TTUzPFYF0hRV89';
const goToSuccess = (
extra: Record<string, string>,
overlay: 'preparing' | 'verifying' | null = 'verifying'
) => {
paymentHandledRef.current = true;
if (overlay) setCheckoutOverlay(overlay);
const qs = new URLSearchParams({
job_no: bookingRes.job_no,
job_id: bookingRes.job_id,
payment_id: String(pmtOrder.payment_id || ''),
amount: String(pmtOrder.amount || Math.round(selectedVariant.price * 0.2)),
method: 'Razorpay',
...extra,
});
router.replace(`/services/success?${qs.toString()}`);
};
const options = {
key: rzpKey,
amount: pmtOrder.amount,
currency: pmtOrder.currency || 'INR',
name: 'iFixKart Commerce',
description: `${advancePercent}% Advance Booking Deposit (${bookingRes.job_no})`,
order_id: pmtOrder.rzp_order_id,
redirect: false,
handler: (response: any) => {
goToSuccess({
rzp_payment_id: String(response.razorpay_payment_id || ''),
rzp_order_id: String(response.razorpay_order_id || ''),
rzp_signature: String(response.razorpay_signature || ''),
verify: '1',
});
},
modal: {
escape: true,
handleback: true,
confirm_close: true,
ondismiss: () => {
if (paymentHandledRef.current) return;
setCheckoutOverlay(null);
setSubmitting(false);
toast.info('Payment window closed. Booking saved as pending.');
router.push(
`/services/success?job_no=${bookingRes.job_no}&job_id=${bookingRes.job_id}&status=PENDING`
);
}
},
theme: {
color: '#2563eb'
}
};
try {
const rzp = new (window as any).Razorpay(options);
rzp.on('payment.failed', function (response: any) {
if (paymentHandledRef.current) return;
setCheckoutOverlay(null);
setSubmitting(false);
toast.error(response.error?.description || 'Payment transaction failed.');
});
rzp.open();
} catch (sdkError: any) {
setCheckoutOverlay(null);
setSubmitting(false);
toast.error(sdkError.message || 'Error opening payment checkout.');
}
} catch (err: any) {
setCheckoutOverlay(null);
setSubmitting(false);
toast.error(err.message || 'Booking process failed. Please try again.');
}
};
const howItWorksCMS = findServiceSection(layoutCMS, 'service_how_it_works');
const whyUsCMS = findServiceSection(layoutCMS, 'service_why_us');
const faqCMS = findServiceSection(layoutCMS, 'service_faq');
const howMeta = parseMetadata(howItWorksCMS);
const whyMeta = parseMetadata(whyUsCMS);
const faqMeta = parseMetadata(faqCMS);
const showHow = isServiceSectionVisible(layoutCMS, 'service_how_it_works');
const showWhy = isServiceSectionVisible(layoutCMS, 'service_why_us');
const showFaq = isServiceSectionVisible(layoutCMS, 'service_faq');
const howTitle = howItWorksCMS?.title || 'How it works';
const howSubtitle =
howItWorksCMS?.subtitle || 'Get your device restored in 3 simple steps';
const howSteps = howMeta.steps || [
{
number: '1',
title: 'Check Price',
desc: 'Select your phone model and choice of repairs to see your custom instant pricing quote.',
},
{
number: '2',
title: 'Schedule Service',
desc: 'Choose a convenient date and location - your home, office, or our local service workshop.',
},
{
number: '3',
title: 'Get Device Repaired',
desc: 'Our certified technician handles the repair on-site. Test it, pay, and receive your 6-month warranty.',
},
];
const whyTitle = whyUsCMS?.title || 'Why Choose iFixKart?';
const whySubtitle =
whyUsCMS?.subtitle || 'Top tier hardware repair guarantees';
const whyCards = whyMeta.cards || [
{
title: 'Premium Quality Parts',
desc: 'We source only certified top-grade components to guarantee perfect compatibility and longevity.',
icon: 'ShieldCheck',
},
{
title: 'Instant On-Site Repair',
desc: 'No need to leave your phone at a shop for days. Most repairs completed in under 45 minutes.',
icon: 'Clock',
},
{
title: '6 Months Warranty',
desc: 'Every screen and spare part replacement includes our worry-free protection warranty cover.',
icon: 'ThumbsUp',
},
];
const faqTitle = faqCMS?.title || 'Frequently Asked Questions';
const faqSubtitle = faqCMS?.subtitle || 'Got questions? We have answers.';
const faqList = faqMeta.faqs || [
{
question: 'What happens when I place my booking?',
answer:
'Our support rep confirms the time slot. A technician is assigned and visits your address to handle the repair on-site.',
},
{
question: 'How do I pay for the service?',
answer:
'You pay only after the repair is complete and verified. We accept UPI, Cards, Cash, and online payments.',
},
{
question: 'Is there a warranty on parts replaced?',
answer:
'Yes! All screens and battery replacements include a worry-free 6-month replacement warranty cover.',
},
{
question: 'Will my data remain secure?',
answer:
'We strictly ensure data security. Repair is conducted in front of you, ensuring zero access to storage components.',
},
];
const cmsBlocks = useMemo(() => {
const blocks: Array<{ key: string; order: number }> = [];
if (showHow) {
blocks.push({
key: 'how',
order: Number(howItWorksCMS?.display_order) || 13,
});
}
if (showWhy) {
blocks.push({
key: 'why',
order: Number(whyUsCMS?.display_order) || 14,
});
}
if (showFaq) {
blocks.push({
key: 'faq',
order: Number(faqCMS?.display_order) || 15,
});
}
return blocks.sort((a, b) => a.order - b.order);
}, [
showHow,
showWhy,
showFaq,
howItWorksCMS?.display_order,
whyUsCMS?.display_order,
faqCMS?.display_order,
]);
const stepIndex = ['selection', 'schedule', 'review'].indexOf(activeStep);
const wizardSteps: { id: WizardStep; label: string; num: number }[] = [
{ id: 'selection', label: 'Repair', num: 1 },
{ id: 'schedule', label: 'Schedule', num: 2 },
{ id: 'review', label: 'Checkout', num: 3 },
];
const tileClass = (selected: boolean) =>
`p-3.5 border rounded-lg text-center transition-all cursor-pointer ${
selected
? 'border-primary bg-primary/5 text-primary shadow-sm'
: 'border-gray-200 bg-white text-[#1a1a1a] hover:border-primary/40 hover:bg-gray-50'
}`;
const fieldLabel = 'block font-semibold text-[#1a1a1a] text-[11px] mb-1.5';
const fieldHint = 'text-gray-500 text-[12px] mt-0.5 mb-3';
const selectClass =
'w-full min-h-[46px] bg-white border border-gray-200 rounded-lg px-3.5 py-2.5 text-[13px] text-[#1a1a1a] outline-none focus:border-primary focus:ring-2 focus:ring-primary/15';
const primaryBtn =
'inline-flex items-center justify-center gap-1.5 bg-primary hover:brightness-95 text-white font-bold text-[13px] py-3 px-5 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
const ghostBtn =
'inline-flex items-center justify-center gap-1.5 border border-gray-200 text-[#555] font-semibold text-[13px] py-2.5 px-4 rounded-lg hover:bg-gray-50 transition cursor-pointer';
return (
<PageShell>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-10 space-y-6">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-visible">
<div className="px-5 md:px-6 py-4 border-b border-gray-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-[18px] md:text-[20px] font-bold text-[#1a1a1a]">
Book a repair
</h2>
<p className="text-[12px] text-gray-500 mt-0.5">
Choose your device, service, and appointment in a few steps.
</p>
</div>
<div className="flex items-center gap-1 sm:gap-2 overflow-x-auto">
{wizardSteps.map((s, idx) => {
const done = idx < stepIndex;
const active = s.id === activeStep;
const locked = idx > stepIndex;
return (
<div key={s.id} className="flex items-center gap-1 sm:gap-2">
{idx > 0 && (
<div
className={`hidden sm:block w-8 h-px ${
done || active ? 'bg-primary' : 'bg-gray-200'
}`}
/>
)}
<button
type="button"
disabled={locked}
onClick={() => setActiveStep(s.id)}
className={`flex items-center gap-1.5 text-[12px] font-semibold transition-colors ${
active
? 'text-primary'
: done
? 'text-[#1a1a1a]'
: 'text-gray-400 cursor-not-allowed'
}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border ${
active
? 'bg-primary border-primary text-white'
: done
? 'bg-primary/10 border-primary text-primary'
: 'bg-white border-gray-200 text-gray-400'
}`}
>
{done ? <Check className="w-3 h-3" /> : s.num}
</span>
<span className="hidden sm:inline whitespace-nowrap">{s.label}</span>
</button>
</div>
);
})}
</div>
</div>
<div className="p-5 md:p-6">
{activeStep === 'selection' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<form
onSubmit={(e) => {
e.preventDefault();
if (!selectedVariant) {
toast.warning('Please complete device and repair details.');
return;
}
setActiveStep('schedule');
}}
className="lg:col-span-2 space-y-5"
>
<div>
<label className={fieldLabel}>Device type</label>
{loadingDeviceTypes ? (
<div className="py-6 flex justify-center items-center text-gray-400">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : (
<div className="flex flex-wrap gap-2">
{deviceTypes.map((id) => {
const Icon = getDeviceTypeIcon(id);
const isSelected = deviceType === id;
return (
<button
key={id}
type="button"
onClick={() => handleDeviceTypeChange(id)}
className={`inline-flex items-center gap-2 h-11 px-3.5 rounded-lg border text-[13px] font-semibold transition ${
isSelected
? 'border-primary bg-primary/5 text-primary'
: 'border-gray-200 bg-white text-[#1a1a1a] hover:border-primary/40'
}`}
>
<Icon className="w-4 h-4" />
{formatDeviceTypeLabel(id)}
</button>
);
})}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormSelect
label="Brand"
placeholder={
brands.length === 0 && !loadingBrands
? `No brands for ${formatDeviceTypeLabel(deviceType)}`
: 'Select brand'
}
value={selectedBrand?.brand_id || ''}
loading={loadingBrands}
disabled={!loadingBrands && brands.length === 0}
searchable
options={brands.map((b) => ({
value: b.brand_id,
label: b.name,
imageUrl: b.logo_url,
}))}
onChange={(value) => {
const brand = brands.find((b) => b.brand_id === value);
if (brand) void handleSelectBrand(brand);
}}
/>
<FormSelect
label="Series"
placeholder={
!selectedBrand
? 'Select a brand first'
: seriesList.length === 0 && !loadingSeries
? 'No series available'
: 'Select series'
}
value={selectedSeries?.series_id || ''}
loading={loadingSeries}
disabled={!selectedBrand || (!loadingSeries && seriesList.length === 0)}
searchable
options={seriesList.map((s) => ({
value: s.series_id,
label: s.name,
}))}
onChange={(value) => {
const series = seriesList.find((s) => s.series_id === value);
if (series) void handleSelectSeries(series);
}}
/>
<FormSelect
label="Model"
placeholder={
!selectedSeries
? 'Select a series first'
: models.length === 0 && !loadingModels
? 'No models available'
: 'Select model'
}
value={selectedModel?.model_id || ''}
loading={loadingModels}
disabled={!selectedSeries || (!loadingModels && models.length === 0)}
searchable
options={models.map((m) => ({
value: m.model_id,
label: m.name,
hint: m.release_year ? `Released ${m.release_year}` : undefined,
}))}
onChange={(value) => {
const model = models.find((m) => m.model_id === value);
if (model) void handleSelectModel(model);
}}
/>
<FormSelect
label="Repair type"
placeholder={
!selectedModel
? 'Select a model first'
: !repairConfig?.categories?.length && !loadingRepair
? 'No repair types available'
: 'Select repair type'
}
value={
selectedCategory?.service_type_id
? String(selectedCategory.service_type_id)
: ''
}
loading={loadingRepair}
disabled={
!selectedModel ||
(!loadingRepair && !repairConfig?.categories?.length)
}
options={(repairConfig?.categories || []).map((cat: any) => ({
value: String(cat.service_type_id),
label: cat.category_name,
}))}
onChange={(value) => {
const cat = repairConfig?.categories?.find(
(c: any) => String(c.service_type_id) === value
);
if (!cat) return;
setSelectedCategory(cat);
setSelectedVariant(cat.variants?.[0] || null);
}}
/>
</div>
{selectedCategory && (
<div>
<label className={fieldLabel}>Replacement quality</label>
<p className={fieldHint}>
Choose the part quality for your {selectedModel?.name}
</p>
<div className="space-y-2.5">
{selectedCategory.variants.map((v: any) => {
const isSel = selectedVariant?.variant_id === v.variant_id;
return (
<button
key={v.variant_id}
type="button"
onClick={() => setSelectedVariant(v)}
className={`w-full text-left rounded-lg border px-4 py-3.5 transition flex items-start gap-3 ${
isSel
? 'border-primary bg-primary/5'
: 'border-gray-200 bg-white hover:border-primary/40'
}`}
>
<span
className={`mt-0.5 w-4 h-4 rounded-full border flex items-center justify-center shrink-0 ${
isSel ? 'border-primary' : 'border-gray-300'
}`}
>
{isSel ? (
<span className="w-2 h-2 rounded-full bg-primary" />
) : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h5 className="font-semibold text-[13px] text-[#1a1a1a]">
{v.name}
</h5>
<div className="text-[14px] font-bold text-primary shrink-0">
{formatCurrency(v.price)}
</div>
</div>
{selectedCategory.description ? (
<p className="text-[12px] text-gray-500 mt-1">
{selectedCategory.description}
</p>
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-gray-500 mt-2">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5 text-primary" />
{v.warranty_days} Days Warranty
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-primary" />
~{v.duration_minutes} Mins
</span>
</div>
</div>
</button>
);
})}
</div>
</div>
)}
<div className="flex justify-end pt-1">
<button type="submit" disabled={!selectedVariant} className={primaryBtn}>
<span>Proceed to schedule slot</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</form>
<aside className="lg:col-span-1">
<div className="lg:sticky lg:top-28 bg-gray-50 border border-gray-100 rounded-xl p-4 space-y-3">
<h3 className="text-[13px] font-bold text-[#1a1a1a]">Booking summary</h3>
{[
['Device', formatDeviceTypeLabel(deviceType)],
['Brand', selectedBrand?.name],
['Series', selectedSeries?.name],
['Model', selectedModel?.name],
['Repair', selectedCategory?.category_name],
['Quality', selectedVariant?.name],
].map(([label, value]) => (
<div
key={label}
className="flex items-start justify-between gap-3 text-[12px]"
>
<span className="text-gray-500">{label}</span>
<span className="font-semibold text-[#1a1a1a] text-right">
{value || '—'}
</span>
</div>
))}
{selectedVariant ? (
<div className="pt-3 border-t border-gray-200 space-y-1.5">
<div className="flex justify-between text-[12px] text-gray-500">
<span>Service quote</span>
<span>{formatCurrency(basePrice)}</span>
</div>
<div className="flex justify-between text-[13px] font-bold text-primary">
<span>Advance due</span>
<span>{formatCurrency(advancePrice)}</span>
</div>
</div>
) : (
<p className="text-[12px] text-gray-400 pt-2 border-t border-gray-200">
Complete the form to see pricing.
</p>
)}
</div>
</aside>
</div>
)}
{activeStep === 'schedule' && (
<form
onSubmit={(e) => {
e.preventDefault();
if (fulfillmentMode === 'WALK_IN' && !selectedSlot) {
toast.warning('Please select an appointment slot for Walk-in fulfillment.');
return;
}
setActiveStep('review');
}}
className="space-y-5"
>
<div>
<h3 className="text-[16px] font-bold text-[#1a1a1a]">Fulfillment & Schedule Allocation</h3>
<p className="text-gray-500 text-[13px] mt-0.5">
Choose how you would like to submit your device for repair.
</p>
</div>
{/* Fulfillment Mode Choice Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
type="button"
onClick={() => setFulfillmentMode('WALK_IN')}
className={`p-4 rounded-xl border text-left transition-all ${
fulfillmentMode === 'WALK_IN'
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<Store className={`w-5 h-5 ${fulfillmentMode === 'WALK_IN' ? 'text-primary' : 'text-gray-400'}`} />
<div>
<h4 className="text-[14px] font-bold text-[#1a1a1a]">Walk-in (Store Visit)</h4>
<p className="text-[12px] text-gray-500 mt-0.5">Visit our certified repair center at your scheduled time.</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => setFulfillmentMode('DELIVERY')}
className={`p-4 rounded-xl border text-left transition-all ${
fulfillmentMode === 'DELIVERY'
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<Truck className={`w-5 h-5 ${fulfillmentMode === 'DELIVERY' ? 'text-primary' : 'text-gray-400'}`} />
<div>
<h4 className="text-[14px] font-bold text-[#1a1a1a]">Delivery / Send Device</h4>
<p className="text-[12px] text-gray-500 mt-0.5">Ship via courier or request doorstep agent pickup.</p>
</div>
</div>
</button>
</div>
{/* WALK_IN: Render Schedule Picker */}
{fulfillmentMode === 'WALK_IN' && (
<div className="space-y-4 pt-2">
<div className="max-w-sm">
<label className={fieldLabel} htmlFor="service-date">
Appointment date
</label>
<input
id="service-date"
type="date"
required
min={new Date().toISOString().split('T')[0]}
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
className={selectClass}
/>
</div>
<div>
<label className={fieldLabel}>Time slot</label>
<p className={fieldHint}>Select your preferred service window</p>
{loadingSlots ? (
<div className="py-12 flex justify-center items-center text-gray-400">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : availableSlots.length === 0 ? (
<div className="p-8 text-center bg-gray-50 border border-dashed border-gray-200 rounded-lg text-gray-500 text-[13px]">
No slots available on this date. Please pick another date.
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{availableSlots.map((slot, idx) => {
const startStr = new Date(slot.start_time).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const endStr = new Date(slot.end_time).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={idx}
type="button"
onClick={() => setSelectedSlot(slot)}
className={tileClass(isSelected)}
>
<div className="text-[13px] font-bold">
{startStr} – {endStr}
</div>
<div className="text-[11px] text-gray-400 mt-1">
Available window
</div>
</button>
);
})}
</div>
)}
</div>
</div>
)}
{/* DELIVERY: Render Courier vs Doorstep Pickup Sub-Options */}
{fulfillmentMode === 'DELIVERY' && (
<div className="p-4 bg-gray-50 border border-gray-200 rounded-xl space-y-3 mt-2">
<h4 className="text-[13px] font-bold text-[#1a1a1a]">Select Delivery Method</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className={`flex items-start gap-3 p-3.5 rounded-lg border cursor-pointer transition-all ${
deliverySubOption === 'COURIER' ? 'bg-white border-primary ring-2 ring-primary/10 shadow-sm' : 'bg-white border-gray-200'
}`}>
<input
type="radio"
name="delSub"
checked={deliverySubOption === 'COURIER'}
onChange={() => setDeliverySubOption('COURIER')}
className="mt-0.5 accent-primary"
/>
<div>
<div className="text-[13px] font-bold text-[#1a1a1a]">Courier Shipping</div>
<div className="text-[11px] text-gray-500 mt-0.5">Ship your device using any courier service to our store.</div>
<div className="text-[11px] font-semibold text-emerald-600 mt-1.5">Fulfillment Fee: FREE (₹0)</div>
</div>
</label>
<label className={`flex items-start gap-3 p-3.5 rounded-lg border cursor-pointer transition-all ${
deliverySubOption === 'PICKUP' ? 'bg-white border-primary ring-2 ring-primary/10 shadow-sm' : 'bg-white border-gray-200'
}`}>
<input
type="radio"
name="delSub"
checked={deliverySubOption === 'PICKUP'}
onChange={() => setDeliverySubOption('PICKUP')}
className="mt-0.5 accent-primary"
/>
<div>
<div className="text-[13px] font-bold text-[#1a1a1a]">Doorstep Pickup Agent</div>
<div className="text-[11px] text-gray-500 mt-0.5">We send a technician or pickup agent directly to your address.</div>
<div className="text-[11px] font-semibold text-primary mt-1.5">Doorstep Pickup Fee: +₹250</div>
</div>
</label>
</div>
</div>
)}
<div className="flex justify-between pt-2">
<button
type="button"
onClick={() => setActiveStep('selection')}
className={ghostBtn}
>
<ArrowLeft className="w-4 h-4" /> Back to details
</button>
<button
type="submit"
disabled={fulfillmentMode === 'WALK_IN' && !selectedSlot}
className={primaryBtn}
>
<span>Proceed to Customer Form</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</form>
)}
{activeStep === 'review' && selectedVariant && (
<div className="space-y-6">
<div>
<h3 className="text-[16px] font-bold text-[#1a1a1a]">Customer Details & Review</h3>
<p className="text-gray-500 text-[13px] mt-0.5">Provide contact details, delivery address, and device lock info before payment.</p>
</div>
{/* Customer Contact & Delivery Form */}
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h4 className="text-[14px] font-bold text-[#1a1a1a] flex items-center gap-2">
<Phone className="w-4 h-4 text-primary" /> Customer Contact Information
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<PhoneInput
label="Alternative Phone Number"
value={altPhone}
onChange={setAltPhone}
/>
<label className="flex items-center gap-2 text-[12px] text-gray-600 mt-1.5 cursor-pointer">
<input
type="checkbox"
checked={isWhatsappAlt}
onChange={(e) => setIsWhatsappAlt(e.target.checked)}
className="rounded text-primary accent-primary"
/>
<span>Available on WhatsApp for repair updates</span>
</label>
</div>
{fulfillmentMode === 'DELIVERY' && (
<div className="sm:col-span-2">
<label className={fieldLabel}>
<MapPin className="w-3.5 h-3.5 inline text-primary mr-1" />
Delivery / Pickup Address *
</label>
<textarea
required
rows={2}
placeholder="Enter complete street address, door number, landmark, and pincode..."
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
className="w-full px-3 py-2 text-[13px] border border-gray-200 rounded-lg focus:outline-none focus:border-primary"
/>
</div>
)}
</div>
{/* Additional Delivery Security Controls */}
{fulfillmentMode === 'DELIVERY' && (
<div className="pt-4 border-t border-gray-100 space-y-4">
<h4 className="text-[14px] font-bold text-[#1a1a1a] flex items-center gap-2">
<KeyRound className="w-4 h-4 text-primary" /> Device Dispatch & Lock Security
</h4>
{/* Pre-Courier Condition Video Upload */}
<div className="p-4 bg-gray-50 border border-gray-200 rounded-xl space-y-2">
<label className="text-[12px] font-bold text-[#1a1a1a] flex items-center gap-1.5">
<Video className="w-4 h-4 text-primary" /> Pre-Courier Condition Video (Optional)
</label>
<p className="text-[11px] text-gray-500">
Upload a short video (≤ 30 seconds) capturing your device condition before dispatch.
</p>
<input
type="file"
accept="video/mp4,video/webm,video/quicktime"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.size > 50 * 1024 * 1024) {
toast.error('Video file size exceeds 50MB limit.');
return;
}
setPreDispatchVideoFile(file);
toast.success(`Selected video: ${file.name}`);
}
}}
className="text-[12px] text-gray-600 block"
/>
{preDispatchVideoFile && (
<div className="text-[11px] font-semibold text-emerald-600 flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Video attached: {preDispatchVideoFile.name}
</div>
)}
</div>
{/* Device Lock Credential Picker */}
<div className="p-4 bg-gray-50 border border-gray-200 rounded-xl space-y-3">
<label className="text-[12px] font-bold text-[#1a1a1a] block">
Device Passcode / Lock Type (Used strictly by technicians for post-repair testing)
</label>
<div className="flex flex-wrap gap-3">
{(['NONE', 'PIN', 'PASSWORD', 'PATTERN'] as const).map((type) => (
<label key={type} className={`px-3 py-1.5 rounded-lg border text-[12px] font-semibold cursor-pointer transition-all ${
lockType === type ? 'bg-primary text-white border-primary shadow-sm' : 'bg-white text-gray-700 border-gray-200'
}`}>
<input
type="radio"
name="lockType"
value={type}
checked={lockType === type}
onChange={() => {
setLockType(type);
setLockPasscode('');
setPatternNodes([]);
}}
className="sr-only"
/>
{type === 'NONE' ? 'No Passcode' : type}
</label>
))}
</div>
{lockType === 'PIN' && (
<div>
<label className={fieldLabel}>Enter PIN Passcode (4 to 8 digits)</label>
<input
type="password"
maxLength={8}
placeholder="e.g. 1234"
value={lockPasscode}
onChange={(e) => setLockPasscode(e.target.value)}
className="max-w-xs px-3 py-2 text-[13px] border border-gray-200 rounded-lg focus:outline-none focus:border-primary font-mono"
/>
</div>
)}
{lockType === 'PASSWORD' && (
<div>
<label className={fieldLabel}>Enter Device Password</label>
<input
type="password"
placeholder="Enter device unlock password..."
value={lockPasscode}
onChange={(e) => setLockPasscode(e.target.value)}
className="max-w-md px-3 py-2 text-[13px] border border-gray-200 rounded-lg focus:outline-none focus:border-primary"
/>
</div>
)}
{lockType === 'PATTERN' && (
<div className="p-4 bg-white border border-gray-200 rounded-xl space-y-3 max-w-sm">
<div className="flex items-center justify-between">
<span className="text-[12px] font-bold text-[#1a1a1a]">Draw 3x3 Pattern (Min 4 nodes)</span>
<button
type="button"
onClick={resetPattern}
className="text-[11px] text-red-600 flex items-center gap-1 font-semibold hover:underline"
>
<RotateCcw className="w-3 h-3" /> Reset Pattern
</button>
</div>
<div className="grid grid-cols-3 gap-3 w-48 mx-auto py-2">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((node) => {
const idx = patternNodes.indexOf(node);
const isSelected = idx !== -1;
return (
<button
key={node}
type="button"
onClick={() => handlePatternNodeClick(node)}
className={`w-12 h-12 rounded-full font-bold text-[13px] flex items-center justify-center transition-all ${
isSelected
? 'bg-primary text-white ring-4 ring-primary/20 scale-105 shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{isSelected ? idx + 1 : node}
</button>
);
})}
</div>
{patternNodes.length > 0 ? (
<div className="text-center text-[12px] font-mono text-primary bg-primary/5 py-1.5 rounded-lg border border-primary/20">
Pattern Sequence: {patternNodes.join(' → ')}
</div>
) : (
<div className="text-center text-[11px] text-gray-400">
Click nodes in sequence to record pattern
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Order Summary & Deposit Breakdown Card */}
<div className="bg-gray-50 border border-gray-200 rounded-xl p-5 space-y-4 text-[13px]">
<div className="flex flex-col sm:flex-row sm:justify-between gap-3 pb-3 border-b border-gray-200">
<div>
<div className="text-[12px] text-gray-400">Device & Model</div>
<div className="font-semibold text-[#1a1a1a]">{selectedBrand?.name} {selectedModel?.name}</div>
</div>
<div className="sm:text-right">
<div className="text-[12px] text-gray-400">Fulfillment Mode</div>
<div className="font-semibold text-primary">{fulfillmentType}</div>
</div>
</div>
{fulfillmentMode === 'WALK_IN' && selectedSlot && (
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 pb-3 border-b border-gray-200 text-[12px] text-gray-500">
<div>Scheduled time: <span className="font-semibold text-[#1a1a1a]">{new Date(selectedSlot.start_time).toLocaleString()}</span></div>
<div>Warranty: <span className="font-semibold text-primary">{selectedVariant.warranty_days} Days</span></div>
</div>
)}
<div className="space-y-2">
<div className="flex justify-between text-gray-500">
<span>Base repair quote</span>
<span>{formatCurrency(basePrice)}</span>
</div>
{fulfillmentType === 'DOORSTEP_PICKUP' && (
<div className="flex justify-between text-gray-600">
<span>Doorstep pickup fee</span>
<span className="font-semibold text-primary">+₹250</span>
</div>
)}
<div className="flex justify-between font-bold text-[#1a1a1a] pt-1.5 border-t border-gray-200">
<span>Total Quote</span>
<span>{formatCurrency(totalQuote)}</span>
</div>
<div className="flex justify-between font-bold text-primary text-[14px]">
<span>20% Advance Deposit</span>
<span>{formatCurrency(advancePrice)}</span>
</div>
<div className="flex justify-between text-[12px] text-gray-500">
<span>Remaining balance due at return</span>
<span>{formatCurrency(remainingPrice)}</span>
</div>
</div>
</div>
<div className="flex justify-between pt-1">
<button type="button" onClick={() => setActiveStep('schedule')} className={ghostBtn}>
<ArrowLeft className="w-4 h-4" /> Back to schedule
</button>
<button
type="button"
disabled={submitting || uploadingVideo}
onClick={handleCreateBooking}
className={primaryBtn}
>
{submitting || uploadingVideo ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>{uploadingVideo ? 'Uploading Video...' : 'Processing...'}</span>
</>
) : (
<>
<Lock className="w-4 h-4" />
<span>Pay {formatCurrency(advancePrice)} & Confirm</span>
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
{cmsBlocks.map((block) => {
if (block.key === 'how') {
return (
<div
key="how"
className="bg-white border border-gray-200 rounded-xl shadow-sm p-5 md:p-6 space-y-5"
>
<div>
<h2 className="text-[18px] font-bold text-[#1a1a1a]">{howTitle}</h2>
<p className="text-gray-500 text-[13px] mt-1">{howSubtitle}</p>
</div>
<div
className={`grid grid-cols-1 gap-4 ${
howSteps.length >= 3 ? 'md:grid-cols-3' : howSteps.length === 2 ? 'md:grid-cols-2' : ''
}`}
>
{howSteps.map((s: any, idx: number) => (
<div
key={idx}
className="bg-gray-50 rounded-lg p-5 border border-gray-100 space-y-2.5"
>
<span className="w-8 h-8 rounded-full bg-primary text-white font-bold text-[13px] flex items-center justify-center">
{s.number || idx + 1}
</span>
<h3 className="font-semibold text-[#1a1a1a] text-[14px]">{s.title}</h3>
<p className="text-[12px] text-gray-500 leading-relaxed">{s.desc}</p>
</div>
))}
</div>
</div>
);
}
if (block.key === 'why') {
return (
<div
key="why"
className="bg-white border border-gray-200 rounded-xl shadow-sm p-5 md:p-6 space-y-5"
>
<div>
<h2 className="text-[18px] font-bold text-[#1a1a1a]">{whyTitle}</h2>
<p className="text-gray-500 text-[13px] mt-1">{whySubtitle}</p>
</div>
<div
className={`grid grid-cols-1 gap-4 ${
whyCards.length >= 3 ? 'md:grid-cols-3' : whyCards.length === 2 ? 'md:grid-cols-2' : ''
}`}
>
{whyCards.map((c: any, idx: number) => {
const Icon = getWhyUsIcon(c.icon);
return (
<div
key={idx}
className="rounded-lg p-5 border border-gray-100 bg-white space-y-2.5"
>
<div className="w-10 h-10 rounded-lg bg-primary-light text-primary flex items-center justify-center">
<Icon className="w-5 h-5" />
</div>
<h3 className="font-semibold text-[#1a1a1a] text-[14px]">{c.title}</h3>
<p className="text-[12px] text-gray-500 leading-relaxed">{c.desc}</p>
</div>
);
})}
</div>
</div>
);
}
return (
<div
key="faq"
className="bg-white border border-gray-200 rounded-xl shadow-sm p-5 md:p-6 space-y-5"
>
<div>
<div className="flex items-center gap-2 text-primary font-semibold text-[12px]">
<HelpCircle className="w-4 h-4" /> FAQ
</div>
<h2 className="text-[18px] font-bold text-[#1a1a1a] mt-1">{faqTitle}</h2>
<p className="text-gray-500 text-[13px]">{faqSubtitle}</p>
</div>
<div className="space-y-2.5">
{faqList.map((faq: any, idx: number) => {
const isOpen = openFaqIdx === idx;
return (
<div
key={idx}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
type="button"
onClick={() => setOpenFaqIdx(isOpen ? null : idx)}
className="w-full p-4 text-left flex justify-between items-center font-semibold text-[13px] text-[#1a1a1a] bg-gray-50 hover:bg-gray-100 transition cursor-pointer"
>
<span>{faq.question}</span>
<ChevronDown
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform duration-200 ${
isOpen ? 'rotate-180 text-primary' : ''
}`}
/>
</button>
{isOpen && (
<div className="p-4 pt-2 text-[12px] text-gray-500 leading-relaxed bg-white border-t border-gray-100">
{faq.answer}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
<GoogleAuthModal isOpen={authModalOpen} onClose={() => setAuthModalOpen(false)} />
{checkoutOverlay && (
<div className="fixed inset-0 z-[80] bg-white/80 backdrop-blur-[2px] flex flex-col items-center justify-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-[13px] font-semibold text-[#1a1a1a]">
{checkoutOverlay === 'verifying'
? 'Verifying payment'
: 'Opening payment'}
</p>
<p className="text-[12px] text-gray-500">Please wait a moment.</p>
</div>
)}
</PageShell>
);
}