/* 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}
setMonthDate(new Date(year, month-1, 1))} aria-label="Previous month">‹
setMonthDate(new Date(year, month+1, 1))} aria-label="Next month">›
{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 (
onChange(c.date)}
>{c.day}
);
})}
);
}
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 }) => (
useList(which)}>Choose from list
);
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.
{ setVehicleMode("vin"); setVinSkipped(false); }}
>
Enter VIN
Fastest · most accurate
{ setVehicleMode("ymm"); setVinSkipped(false); }}
>
Year / Make / Model
No VIN handy? Use this
{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" && (
Year
setForm({...form, year: e.target.value})}>
Select year
{Array.from({length: 30}, (_, i) => 2026 - i).map(y => (
{y}
))}
Make{otherMake && }
{otherMake ? (
setForm({...form, make: e.target.value})} placeholder="Enter make" autoFocus/>
) : (
{
const v = e.target.value;
if (v === "__other") { setOtherMake(true); setOtherModel(true); setOtherTrim(true); setForm({...form, make: "", model: "", trim: ""}); }
else setForm({...form, make: v, model: "", trim: ""});
}}>
Select make
{MAKES.map(m => {m} )}
Other
)}
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? { setVinSkipped(true); setStep(2); }}>Skip for now 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) => (
setTierIdx(i)}
>
{t.name}
${t.price}
{t.sub && {t.sub}
}
))}
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 (
setAddons({...addons, [a.id]: !on})}
style={{flexDirection:"row", alignItems:"center", justifyContent:"space-between", gap:14, padding:"14px 16px"}}
>
{on ? "✓" : ""}
{a.icon}
{a.name}
{a.chips.join(" · ")}
+ {a.price}
);
})}
>
)}
{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 ? (
{TIME_SLOTS.map((t) =>
)}
) : (
{TIME_SLOTS.map((t, i) => {
// avail comes from the booking API; when it's null the
// backend isn't reachable, so leave every window open.
const taken = avail ? !avail[i] : false;
return (
setTime(t)}>
{t}
);
})}
)}
>
) : (
🗓
Select a day to see available times
)}
>
)}
{step === 4 && (
<>
A few quick details
So your tech knows where to go and how to reach you.
Community discount
We offer 10% off for those who serve our community. Show a valid ID on arrival.
setCommunityDiscount(!communityDiscount)}
style={{flexDirection:"row", alignItems:"center", justifyContent:"space-between", gap:14, padding:"14px 16px", width:"100%"}}
>
{communityDiscount ? "✓" : ""}
I'm Military, First Responder, Law Enforcement, Teacher, or Student
Apply 10% off my total. Show ID.
>
)}
{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 ? (
setStep(step-1)}>← Back
) :
}
{step < 4 && (
setStep(step+1)}>
Continue{step >= 2 ? ` · ${fmtMoney(total)}` : ""}
)}
{step === 4 && (
{bookError && {bookError} }
{submitting ? "Confirming…" : `Confirm booking · ${fmtMoney(total)}`}
)}
{step === 5 && (
Done
)}
);
}
window.BookingModal = BookingModal;