1612 lines
68 KiB
TypeScript
1612 lines
68 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|