/* global React, PrimeOilShared */ const { useState, useMemo, Icon, SERVICES_DATA, TIME_SLOTS } = PrimeOilShared; const VEHICLES = { Acura: ["ILX","Integra","MDX","RDX","TLX"], Audi: ["A3","A4","A5","A6","Q3","Q5","Q7"], BMW: ["2 Series","3 Series","4 Series","5 Series","X1","X3","X5","X7"], Buick: ["Enclave","Encore","Envision"], Cadillac: ["CT4","CT5","Escalade","XT4","XT5","XT6"], Chevrolet: ["Blazer","Camaro","Colorado","Equinox","Malibu","Silverado 1500","Silverado 2500HD","Suburban","Tahoe","Traverse","Trax"], Chrysler: ["300","Pacifica","Voyager"], Dodge: ["Challenger","Charger","Durango","Hornet"], Ford: ["Bronco","Bronco Sport","Edge","Escape","Expedition","Explorer","F-150","F-250 Super Duty","F-350 Super Duty","Maverick","Mustang","Ranger"], GMC: ["Acadia","Canyon","Sierra 1500","Sierra 2500HD","Terrain","Yukon"], Honda: ["Accord","Civic","CR-V","HR-V","Odyssey","Passport","Pilot","Ridgeline"], Hyundai: ["Elantra","Kona","Palisade","Santa Fe","Sonata","Tucson"], Infiniti: ["Q50","QX50","QX60","QX80"], Jeep: ["Cherokee","Compass","Gladiator","Grand Cherokee","Renegade","Wrangler"], Kia: ["Carnival","Forte","K5","Seltos","Sorento","Soul","Sportage","Telluride"], Lexus: ["ES","GX","IS","NX","RX","TX"], Lincoln: ["Aviator","Corsair","Nautilus","Navigator"], Mazda: ["CX-30","CX-5","CX-50","CX-90","Mazda3","MX-5 Miata"], "Mercedes-Benz": ["C-Class","E-Class","GLA","GLB","GLC","GLE","Sprinter"], Mitsubishi: ["Eclipse Cross","Outlander","Outlander Sport"], Nissan: ["Altima","Frontier","Kicks","Maxima","Murano","Pathfinder","Rogue","Sentra","Titan","Versa"], Ram: ["1500","2500","3500","ProMaster"], Subaru: ["Ascent","Crosstrek","Forester","Impreza","Legacy","Outback","WRX"], Toyota: ["4Runner","Camry","Corolla","Highlander","RAV4","Sequoia","Sienna","Tacoma","Tundra"], Volkswagen: ["Atlas","Golf GTI","Jetta","Taos","Tiguan"], Volvo: ["S60","XC40","XC60","XC90"], }; const MAKES = Object.keys(VEHICLES); // Common trim levels by make. Not exhaustive per model, so "Other" stays available. const TRIMS = { Acura: ["Base","Technology","A-Spec","Advance","Type S"], Audi: ["Premium","Premium Plus","Prestige","S line"], BMW: ["sDrive","xDrive","M Sport","M"], Buick: ["Preferred","Essence","Avenir","ST"], Cadillac: ["Luxury","Premium Luxury","Sport","V-Series"], Chevrolet: ["WT","LS","LT","RST","Z71","LTZ","Premier","High Country","Trail Boss"], Chrysler: ["Touring","Limited","Pinnacle","S"], Dodge: ["SXT","GT","R/T","Scat Pack","Citadel"], Ford: ["XL","XLT","STX","Lariat","King Ranch","Platinum","Limited","Tremor","Raptor","ST","Big Bend","Outer Banks"], GMC: ["Pro","SLE","SLT","Elevation","AT4","Denali"], Honda: ["LX","Sport","EX","EX-L","Touring","Si","Type R","TrailSport"], Hyundai: ["SE","SEL","N Line","Limited","Calligraphy"], Infiniti: ["Pure","Luxe","Sensory","Autograph","Red Sport"], Jeep: ["Sport","Latitude","Altitude","Willys","Rubicon","Limited","Overland","Summit","Trailhawk"], Kia: ["LX","S","EX","GT-Line","SX","SX Prestige","X-Line"], Lexus: ["Base","Premium","Luxury","F Sport","Ultra Luxury"], Lincoln: ["Standard","Premiere","Reserve","Black Label"], Mazda: ["S","Select","Preferred","Carbon Edition","Premium","Turbo"], "Mercedes-Benz": ["Base","4MATIC","AMG Line","AMG"], Mitsubishi: ["ES","SE","SEL","LE"], Nissan: ["S","SV","SR","SL","Platinum","Pro-4X","Midnight Edition"], Ram: ["Tradesman","Big Horn","Laramie","Rebel","Limited","Longhorn"], Subaru: ["Base","Premium","Sport","Limited","Touring","Onyx","Wilderness"], Toyota: ["L","LE","SE","XLE","XSE","Limited","SR5","TRD Off-Road","TRD Pro","Platinum"], Volkswagen: ["S","SE","SE R-Line","SEL","SEL Premium"], Volvo: ["Core","Plus","Ultimate"], }; const ADDON_IDS = ["rotation", "filter", "wipers"]; const BOOKING_STEPS = ["VIN", "Your oil", "When", "Your details"]; function MonthCalendar({ value, onChange, monthDate, setMonthDate, blockedDays }) { const today = new Date(); today.setHours(0,0,0,0); const iso = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; const year = monthDate.getFullYear(); const month = monthDate.getMonth(); const firstOfMonth = new Date(year, month, 1); const startDay = firstOfMonth.getDay(); const daysInMonth = new Date(year, month+1, 0).getDate(); const prevDays = new Date(year, month, 0).getDate(); const cells = []; for (let i = 0; i < startDay; i++) { const d = prevDays - startDay + 1 + i; cells.push({ day: d, muted: true, date: new Date(year, month-1, d) }); } for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, muted: false, date: new Date(year, month, d) }); while (cells.length < 42) { const d = cells.length - (startDay + daysInMonth) + 1; cells.push({ day: d, muted: true, date: new Date(year, month+1, d) }); } const monthName = monthDate.toLocaleString(undefined, { month: "long", year: "numeric" }); const dows = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]; const sameDay = (a,b) => a && b && a.toDateString() === b.toDateString(); return (
{monthName}
{dows.map(d =>
{d}
)} {cells.map((c,i) => { const isPast = c.date < today; const isToday = sameDay(c.date, today); const isSelected = sameDay(c.date, value); const isWeekend = c.date.getDay() === 0 || c.date.getDay() === 6; const isBlocked = isWeekend && !isPast && blockedDays && blockedDays.has(iso(c.date)); const disabled = isPast || !isWeekend || isBlocked; return ( ); })}
); } function BookingModal({ open, onClose, initialServiceId }) { const [step, setStep] = useState(1); const [vehicleMode, setVehicleMode] = useState("vin"); // "vin" | "ymm" const [vin, setVin] = useState(""); const [vinSkipped, setVinSkipped] = useState(false); const [tierIdx, setTierIdx] = useState(0); const [addons, setAddons] = useState({}); const [date, setDate] = useState(null); const [time, setTime] = useState(null); const [monthDate, setMonthDate] = useState(() => { const d = new Date(); d.setDate(1); return d; }); const [form, setForm] = useState({ name: "", phone: "", email: "", address: "", street: "", city: "", zip: "", make: "", model: "", year: "", trim: "", notes: "" }); const [communityDiscount, setCommunityDiscount] = useState(false); const [submitted, setSubmitted] = useState(false); const [avail, setAvail] = useState(null); // null = unknown/no backend; else [bool per slot] const [availLoading, setAvailLoading] = useState(false); const [blockedDays, setBlockedDays] = useState(null); // Set of "YYYY-MM-DD" fully-blocked weekend days const [submitting, setSubmitting] = useState(false); const [bookError, setBookError] = useState(""); const [confirmation, setConfirmation] = useState(""); const [otherMake, setOtherMake] = useState(false); const [otherModel, setOtherModel] = useState(false); const [otherTrim, setOtherTrim] = useState(false); const trimOptions = TRIMS[form.make] || []; const useList = (which) => { if (which === "make") { setOtherMake(false); setOtherModel(false); setOtherTrim(false); setForm({...form, make: "", model: "", trim: ""}); } if (which === "model") { setOtherModel(false); setForm({...form, model: ""}); } if (which === "trim") { setOtherTrim(false); setForm({...form, trim: ""}); } }; const ListLink = ({ which }) => ( ); const modelOptions = VEHICLES[form.make] || []; const oilService = SERVICES_DATA.find(s => s.id === "oil"); const addonServices = SERVICES_DATA.filter(s => ADDON_IDS.includes(s.id)); React.useEffect(() => { if (open) { setStep(1); setVehicleMode("vin"); setVin(""); setVinSkipped(false); setTierIdx(0); const init = {}; if (initialServiceId && ADDON_IDS.includes(initialServiceId)) init[initialServiceId] = true; setAddons(init); setDate(null); setTime(null); setSubmitted(false); setCommunityDiscount(false); setAvail(null); setBookError(""); setConfirmation(""); setSubmitting(false); setBlockedDays(null); const d = new Date(); d.setDate(1); setMonthDate(d); } }, [open, initialServiceId]); // Local YYYY-MM-DD for the selected calendar day (used by the booking API). const isoDate = (d) => d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` : ""; // Ask the backend which weekend days in the visible month are fully blocked, // so the calendar can gray them out. Silent no-op if the API isn't reachable. React.useEffect(() => { if (!open) return undefined; let cancelled = false; fetch(`/api/booking/month?year=${monthDate.getFullYear()}&month=${monthDate.getMonth() + 1}`) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) .then((data) => { if (!cancelled) setBlockedDays(new Set(data.blockedDates || [])); }) .catch(() => { if (!cancelled) setBlockedDays(null); }); return () => { cancelled = true; }; }, [open, monthDate]); // Ask the backend which windows are open for the chosen day. If the API isn't // reachable (not deployed yet / local preview), fall back to no restrictions. React.useEffect(() => { if (!date) { setAvail(null); return undefined; } let cancelled = false; setAvailLoading(true); fetch(`/api/booking/availability?date=${isoDate(date)}`) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) .then((data) => { if (!cancelled) setAvail((data.slots || []).map((s) => !!s.available)); }) .catch(() => { if (!cancelled) setAvail(null); }) .finally(() => { if (!cancelled) setAvailLoading(false); }); return () => { cancelled = true; }; }, [date]); React.useEffect(() => { const onKey = (e) => { if (e.key === "Escape" && open) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); const parsePrice = (s) => parseFloat(String(s).replace(/[^0-9.]/g, "")) || 0; const cleanVin = vin.replace(/[^A-HJ-NPR-Z0-9]/gi, "").toUpperCase(); const vinValid = cleanVin.length === 17; const ymmValid = !!(form.year && form.make && form.model); const vehicleReady = (vehicleMode === "vin" ? vinValid : ymmValid) || vinSkipped; const subtotal = useMemo(() => { let t = oilService.tiers[tierIdx].price; for (const a of addonServices) if (addons[a.id]) t += parsePrice(a.price); return t; }, [tierIdx, addons, addonServices, oilService]); const discountAmount = communityDiscount ? subtotal * 0.10 : 0; const total = subtotal - discountAmount; const canNext = useMemo(() => { if (step === 1) return vehicleReady; if (step === 2) return true; if (step === 3) return !!date && !!time; if (step === 4) return form.name && form.phone && form.address; return true; }, [step, vehicleReady, date, time, form]); if (!open) return null; const fmt = (d) => d ? d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }) : "Not set"; const fmtMoney = (n) => `$${n.toFixed(2)}`; const localCode = () => "PO-" + Math.floor(Math.random() * 9000 + 1000); const submit = async () => { setBookError(""); setSubmitting(true); const payload = { date: isoDate(date), timeIndex: time ? TIME_SLOTS.indexOf(time) : -1, timeLabel: time, name: form.name, phone: form.phone, address: form.address, service: `${tier.name} oil change`, addons: addonServices.filter((a) => addons[a.id]).map((a) => a.name).join(", "), total: fmtMoney(total), notes: form.notes, communityDiscount, }; try { const r = await fetch("/api/booking/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (r.ok) { const data = await r.json().catch(() => ({})); setConfirmation(data.confirmation || localCode()); setSubmitting(false); setSubmitted(true); setStep(5); return; } const data = await r.json().catch(() => ({})); if (r.status === 409) { setSubmitting(false); setBookError("That time was just booked. Please choose another window."); setAvail(null); setTime(null); setStep(3); return; } // No usable API yet: route missing (static preview), or the function is // deployed but its Microsoft env vars aren't set. Don't block the customer. if (r.status === 404 || r.status === 405 || r.status === 501 || data.error === "not_configured") { setConfirmation(localCode()); setSubmitting(false); setSubmitted(true); setStep(5); return; } setSubmitting(false); setBookError("Something went wrong confirming your booking. Please try again."); } catch (e) { // Network error / API not reachable (e.g. local static preview). setConfirmation(localCode()); setSubmitting(false); setSubmitted(true); setStep(5); } }; const addonCount = Object.values(addons).filter(Boolean).length; const tier = oilService.tiers[tierIdx]; const pct = submitted ? 100 : Math.round((step / 4) * 100); const vehicleLabel = vinValid ? `VIN ${cleanVin.slice(0,4)}…${cleanVin.slice(-4)}` : (form.year || form.make || form.model) ? `${form.year} ${form.make} ${form.model}${form.trim ? " " + form.trim : ""}`.trim() : vinSkipped ? "We'll grab it on-site" : "Not set"; const Summary = ({ title = "Your booking", showWhen = false, showVehicle = false }) => (
{title}
{showVehicle && (
Vehicle {vehicleLabel}
)}
{tier.name} oil change {fmtMoney(tier.price)}
{addonServices.filter(a => addons[a.id]).map(a => (
+ {a.name} {a.price}
))} {communityDiscount && (
Community discount (10%) · Show ID −{fmtMoney(discountAmount)}
)} {showWhen && date && time && (
When {fmt(date)} at {time}
)}
Estimated total {fmtMoney(total)}
Estimate only. Final total may vary based on extra quarts or on-site add-ons, and does not include taxes.
); return (
e.stopPropagation()} role="dialog" aria-modal="true">

{submitted ? "You're all set." : "Book your oil change"}

{!submitted && (
Step {step} of 4 · {BOOKING_STEPS[step-1]}
)}
{step === 1 && ( <>
Tell us about your car
Either way works. We just need to know which oil and filter to load on the truck.
{vehicleMode === "vin" && (
{ setVin(e.target.value); setVinSkipped(false); }} placeholder="1HGCM82633A123456" maxLength={20} autoFocus spellCheck={false} autoComplete="off" />
{vinValid ? "✓ Looks good." : "17 characters · letters and numbers (no I, O, or Q)"} {cleanVin.length} / 17
Where to find your VIN
1 Driver's-side dashboard
Look through the windshield at the lower corner on the driver's side. It's a 17-character plate.
2 Driver's door jamb
Open the driver's door and check the sticker on the door frame near the latch.
3 Insurance or registration
Your insurance card, vehicle title, or registration document lists the VIN at the top.
)} {vehicleMode === "ymm" && (
{otherMake ? ( setForm({...form, make: e.target.value})} placeholder="Enter make" autoFocus/> ) : ( )}
{otherModel || (form.make && !modelOptions.length) ? ( setForm({...form, model: e.target.value})} placeholder="Enter model"/> ) : ( )}
{otherTrim || (form.make && !trimOptions.length) ? ( setForm({...form, trim: e.target.value})} placeholder="Enter trim"/> ) : ( )}
i Trim helps us match the exact oil capacity. If you're not sure, leave it blank and your tech will confirm on arrival.
)}
Don't have any of this handy? and we'll grab it on-site.
)} {step === 2 && ( <>
Which oil would you like?
Pick whatever fits your vehicle and budget.
{oilService.tiers.map((t, i) => ( ))}
i Includes up to {oilService.tiers[tierIdx].name === "Diesel" ? "10" : "5"} quarts. If your vehicle needs more, additional quarts are $7.99 each on-site, we'll let you know before adding anything.
Anything else while we're there?
Optional. Bundle a quick add-on and save a trip later.
{addonServices.map(a => { const on = !!addons[a.id]; return ( ); })}
)} {step === 3 && ( <>
When works for you?
Pick a Saturday or Sunday, then choose an available time slot. We book weekends only, 7:00 AM to 7:00 PM.
{date ? ( <>
Available windows
{fmt(date)} · Your tech arrives inside the window you pick. {availLoading ? " Checking the calendar…" : " Crossed-out windows are already booked."}
{availLoading ? (
)} {step === 4 && ( <>
A few quick details
So your tech knows where to go and how to reach you.
setForm({...form, name:e.target.value})} placeholder="Alex Carter"/>
setForm({...form, phone:e.target.value})} placeholder="(615) 636-8704"/>
setForm({...form, address: e.target.value})} placeholder="123 Madison St, Clarksville, TN 37040" autoComplete="off"/>
Community discount
We offer 10% off for those who serve our community. Show a valid ID on arrival.
)} {step === 5 && (

You're all set, {form.name?.split(" ")[0] || "thanks"}.

We sent a confirmation to {form.phone || "your phone"}. Your tech will reach out before arrival on {fmt(date)} at {time}.

Confirmation #{confirmation || localCode()}
Service{tier.name} oil change
{addonCount > 0 &&
Add-ons{addonCount} included
} {(form.year || form.make || form.model) && (
Vehicle{[form.year, form.make, form.model, form.trim].filter(Boolean).join(" ")}
)} {vinValid &&
VIN{cleanVin}
} {communityDiscount && (
Community discount (10%) · Show ID−{fmtMoney(discountAmount)}
)}
Estimated total{fmtMoney(total)}
)}
{step > 1 && step < 5 ? ( ) : } {step < 4 && ( )} {step === 4 && (
{bookError && {bookError}}
)} {step === 5 && ( )}
); } window.BookingModal = BookingModal;