zip-van/components/sections/ZipvanQuote.js
2025-09-10 11:15:18 +05:30

422 lines
19 KiB
JavaScript

// src/components/ZipvanQuote.js
import React, { useEffect, useRef, useState } from "react";
export default function ZipvanQuote() {
const CONFIG = {
GOOGLE_MAPS_API_KEY:
process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY ||
"AIzaSyAxWCXjM3_iYRKdWPUceRo79DlvIU9xnZQ",
CALENDLY_EVENT_URL: "https://calendly.com/zipvan/new-meeting",
PUBLIC_RATES: { booking_flat: 25, km_rate_under_equal_15: 3, km_rate_over_15: 2 },
TAX_RATE: 0.13,
BOUNDS: { SW: { lat: 42.8, lng: -81.0 }, NE: { lat: 44.3, lng: -78.5 } },
};
const pickupRef = useRef(null);
const dropoffRef = useRef(null);
const mapRef = useRef(null);
const schedRef = useRef(null);
const mapObj = useRef(null);
const dirSvc = useRef(null);
const dirRenderer = useRef(null);
const geocoder = useRef(null);
const markerP = useRef(null);
const markerD = useRef(null);
const routeToken = useRef(0);
const [quote, setQuote] = useState({
km: 0,
min: 0,
subtotal: 0,
tax: 0,
grand: 0,
});
const [mapsReady, setMapsReady] = useState(false);
const [mapsError, setMapsError] = useState(null);
const money = (n) => "$" + Number(n).toFixed(2);
const q = (p) =>
Object.entries(p)
.filter(([, v]) => v !== "" && v != null)
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
.join("&");
const showMapStatus = (m) => {
const el = document.getElementById("mapStatus");
if (el) {
el.style.display = "block";
el.textContent = m;
}
};
const debounce = (fn, wait = 700) => {
let t;
return (...a) => {
clearTimeout(t);
t = setTimeout(() => fn(...a), wait);
};
};
function loadGoogleMaps() {
return new Promise((resolve, reject) => {
if (window.google && window.google.maps && window.google.maps.places) {
resolve(window.google);
return;
}
if (document.getElementById("zipvan-google-maps")) {
const existing = document.getElementById("zipvan-google-maps");
existing.addEventListener("load", () => {
if (window.google) resolve(window.google);
else reject(new Error("Google loaded but window.google missing"));
});
existing.addEventListener("error", () => reject(new Error("Google Maps script error")));
return;
}
const s = document.createElement("script");
s.id = "zipvan-google-maps";
s.src =
"https://maps.googleapis.com/maps/api/js?key=" +
encodeURIComponent(CONFIG.GOOGLE_MAPS_API_KEY) +
"&libraries=places&v=weekly";
s.async = true;
s.defer = true;
s.onload = () => {
if (window.google) resolve(window.google);
else reject(new Error("Google Maps loaded but window.google missing"));
};
s.onerror = () => reject(new Error("Google Maps failed to load (network or invalid key)"));
document.head.appendChild(s);
});
}
function calcAndSetTotals({ km, min }) {
const rate = km <= 15 ? CONFIG.PUBLIC_RATES.km_rate_under_equal_15 : CONFIG.PUBLIC_RATES.km_rate_over_15;
const distanceCost = km * rate;
const subtotal = CONFIG.PUBLIC_RATES.booking_flat + distanceCost;
const tax = +(subtotal * CONFIG.TAX_RATE).toFixed(2);
const grand = +(subtotal + tax).toFixed(2);
setQuote({
km: +km.toFixed(2),
min: Math.round(min),
subtotal: +subtotal.toFixed(2),
tax,
grand,
});
}
function openCalendly() {
const url =
CONFIG.CALENDLY_EVENT_URL +
"?" +
q({
hide_event_type_details: 1,
background_color: "ffffff",
text_color: "0a0a0a",
a1: pickupRef.current ? pickupRef.current.value : "",
a2: dropoffRef.current ? dropoffRef.current.value : "",
a3: quote.km ? quote.km.toFixed(1) + " km" : "",
a4: money(quote.subtotal),
a5: money(quote.grand),
_cb: Date.now(),
});
const holder = schedRef.current ?? document.getElementById("sched");
if (!holder) return;
const existing = document.getElementById("calendly-embed-iframe");
if (existing) existing.src = url;
else {
const ifr = document.createElement("iframe");
ifr.id = "calendly-embed-iframe";
ifr.src = url;
ifr.allow = "payment *; clipboard-write *";
ifr.style.width = "100%";
ifr.style.minHeight = "980px";
ifr.style.border = "0";
holder.appendChild(ifr);
}
holder.scrollIntoView({ behavior: "smooth" });
}
function initMap() {
if (!window.google) {
setMapsError("Google Maps not available.");
return;
}
if (!mapRef.current) {
setMapsError("Map container not found.");
return;
}
mapObj.current = new window.google.maps.Map(mapRef.current, {
center: { lat: 43.65, lng: -79.38 },
zoom: 10,
gestureHandling: "greedy",
});
dirSvc.current = new window.google.maps.DirectionsService();
dirRenderer.current = new window.google.maps.DirectionsRenderer({ suppressMarkers: true, preserveViewport: false });
geocoder.current = new window.google.maps.Geocoder();
dirRenderer.current.setMap(mapObj.current);
const biasBounds = new window.google.maps.LatLngBounds(CONFIG.BOUNDS.SW, CONFIG.BOUNDS.NE);
const acOpts = {
types: ["address"],
componentRestrictions: { country: "ca" },
bounds: biasBounds,
strictBounds: false,
fields: ["formatted_address", "geometry"],
};
if (pickupRef.current) {
const acP = new window.google.maps.places.Autocomplete(pickupRef.current, acOpts);
acP.addListener("place_changed", () => handlePlace(acP, "pickup"));
}
if (dropoffRef.current) {
const acD = new window.google.maps.places.Autocomplete(dropoffRef.current, acOpts);
acD.addListener("place_changed", () => handlePlace(acD, "dropoff"));
}
const onBlurPick = () => geocodeManual("pickup");
const onBlurDrop = () => geocodeManual("dropoff");
pickupRef.current && pickupRef.current.addEventListener("blur", onBlurPick);
dropoffRef.current && dropoffRef.current.addEventListener("blur", onBlurDrop);
const geocodeDebounced = debounce((which) => {
const el = which === "pickup" ? pickupRef.current : dropoffRef.current;
const val = el ? el.value.trim() : "";
if (!val) return;
geocoder.current.geocode({ address: val, componentRestrictions: { country: "CA" }, bounds: biasBounds }, (res, st) => {
if (st === "OK" && res && res[0]) {
dropMarker(which, res[0].geometry.location, res[0].formatted_address);
requestRoute();
}
});
}, 400);
function geocodeManual(which) {
geocodeDebounced(which);
}
function handlePlace(ac, which) {
const p = ac.getPlace();
if (!p || !p.geometry) {
geocodeManual(which);
return;
}
dropMarker(which, p.geometry.location, p.formatted_address || (which === "pickup" ? pickupRef.current.value : dropoffRef.current.value));
requestRoute();
}
function dropMarker(which, latlng, label) {
const L = which === "pickup" ? "P" : "D";
const opts = {
position: latlng,
map: mapObj.current,
label: { text: L, color: "#fff", fontWeight: "700" },
title: (which === "pickup" ? "Pick up" : "Drop off") + ": " + label,
};
if (which === "pickup") {
if (markerP.current) markerP.current.setMap(null);
markerP.current = new window.google.maps.Marker(opts);
} else {
if (markerD.current) markerD.current.setMap(null);
markerD.current = new window.google.maps.Marker(opts);
}
}
function requestRoute() {
if (!markerP.current || !markerD.current) return;
const my = ++routeToken.current;
dirSvc.current.route(
{
origin: markerP.current.getPosition(),
destination: markerD.current.getPosition(),
travelMode: window.google.maps.TravelMode.DRIVING,
},
(res, st) => {
if (my !== routeToken.current) return;
if (st === "OK" && res?.routes?.[0]?.legs?.[0]) {
dirRenderer.current.setDirections(res);
const leg = res.routes[0].legs[0];
const km = +(leg.distance.value / 1000).toFixed(2);
const mins = Math.round(leg.duration.value / 60);
const mapStatusEl = document.getElementById("mapStatus");
if (mapStatusEl) mapStatusEl.style.display = "none";
const b = new window.google.maps.LatLngBounds();
b.extend(markerP.current.getPosition());
b.extend(markerD.current.getPosition());
mapObj.current.fitBounds(b);
calcAndSetTotals({ km, min: mins });
} else {
showMapStatus("Couldn't compute the driving route. Check Ontario addresses.");
}
}
);
}
if (!document.getElementById("zipvan-calendly-script")) {
const cs = document.createElement("script");
cs.id = "zipvan-calendly-script";
cs.src = "https://assets.calendly.com/assets/external/widget.js";
cs.async = true;
document.head.appendChild(cs);
}
setMapsReady(true);
initMap._cleanup = () => {
pickupRef.current && pickupRef.current.removeEventListener("blur", onBlurPick);
dropoffRef.current && dropoffRef.current.removeEventListener("blur", onBlurDrop);
};
}
useEffect(() => {
let mounted = true;
if (!CONFIG.GOOGLE_MAPS_API_KEY) {
setMapsError("Google Maps API key missing. Put it into CONFIG or NEXT_PUBLIC_GOOGLE_MAPS_KEY.");
return;
}
loadGoogleMaps()
.then(() => {
if (!mounted) return;
initMap();
})
.catch((err) => {
console.error("Google Maps load failed:", err);
setMapsError("Google Maps failed to load. Check API key, billing and allowed referrers.");
showMapStatus("Google Maps failed to load. See console for details.");
});
return () => {
mounted = false;
try {
if (initMap._cleanup) initMap._cleanup();
if (markerP.current) markerP.current.setMap(null);
if (markerD.current) markerD.current.setMap(null);
if (dirRenderer.current) dirRenderer.current.setMap(null);
} catch (e) { }
};
}, []);
const canBook = Boolean(quote.km && quote.min);
return (
<div className="zip-wrap" style={{ maxWidth: 1100, margin: "0 auto" }}>
<div className="pd_top_90" />
<div className="section_title type_four text-center">
<h4 className="sm_title">Fast Checkout</h4>
<div className="title_whole">
<h2 className="title">See Your All-In Price in 10 Seconds</h2>
<p>Enter pickup & drop-off. Your price is lockedno fuel or weekend fees.</p>
</div>
<div className="pd_bottom_40" />
</div>
<style>{`
:root{
--zip-orange:#ff6500; --zip-black:#0a0a0a; --ink:#0f1720; --muted:#6b7280;
--surface:#ffffff; --border:#e6e8eb; --radius:16px;
}
.zip-wrap{max-width:1100px;margin:0 auto;padding:28px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
box-shadow:0 10px 28px rgba(15,23,32,.07);overflow:hidden}
.card-head{padding:20px 22px;background:var(--zip-black);color:#fff;display:flex;justify-content:space-between;align-items:center}
.card-head h2{margin:0;font-size:22px}
.card-body{padding:22px}
.addr-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
@media(max-width:760px){ .addr-row{grid-template-columns:1fr} }
.input{width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:12px;
font-size:16px;background:#fff;color:var(--ink);min-height:46px;box-sizing:border-box}
.input:focus{outline:0;border-color:#cfd3d8;box-shadow:0 0 0 3px rgba(255,101,0,.12)}
.map{height:390px;border-radius:12px;overflow:hidden;border:1px solid var(--border);
background:#f6f7f8;display:flex;align-items:center;justify-content:center;margin-top:14px}
.map .err{color:var(--muted);font-size:14px}
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:14px}
.stat{border:1px solid var(--border);border-radius:12px;background:#fff;padding:12px 14px}
.stat label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
.stat .value{font-size:16px;font-weight:750;color:var(--ink)}
.totalbar{display:flex;align-items:center;gap:18px;margin-top:16px}
.grand{margin-left:auto;font-size:22px;font-weight:850}
.breakdown{margin-top:12px;border:1px solid var(--border);border-radius:12px;overflow:hidden}
.brow{display:flex;justify-content:space-between;padding:10px 12px;font-size:14px;background:#fff}
.brow:nth-child(odd){background:#fbfbfc}
.brow .lbl{color:#6b7280}
.brow.total{font-weight:800;border-top:1px solid var(--border)}
.hint{font-size:12px;color:#6b7280;margin-top:8px}
.cta{display:flex;justify-content:flex-end;margin-top:16px}
.btn{appearance:none;border:0;background:var(--zip-orange);color:#fff;font-weight:850;letter-spacing:.2px;
padding:12px 18px;border-radius:999px;font-size:16px;cursor:pointer;transition:.15s transform,.2s opacity}
.btn[disabled]{background:#c9ccd1;cursor:not-allowed;opacity:.85}
.btn:active{transform:translateY(1px)}
#sched{margin-top:26px}
#calendly-embed-iframe{width:100%;min-height:980px;border:0}
`}</style>
<div className="card">
<div className="card-head">
<h2 className="text-white">Book a Pickup</h2>
<div style={{ opacity: ".9", fontSize: 13 }}>Transparent pricing distance + booking</div>
</div>
<div className="card-body">
<div className="addr-row">
<input id="pickup" ref={pickupRef} className="input" type="text" placeholder="Pick up address (Canada)" aria-label="Pick up" autoComplete="off" />
<input id="dropoff" ref={dropoffRef} className="input" type="text" placeholder="Drop off address (Canada)" aria-label="Drop off" autoComplete="off" />
</div>
<div id="map" ref={mapRef} className="map">
<div className="err" id="mapStatus">Enter Pick up and Drop off to see your route, distance, and price.</div>
</div>
<div className="stats">
<div className="stat">
<label>Service</label>
<div className="value">Curbside</div>
</div>
<div className="stat">
<label>Crew</label>
<div className="value">1 mover</div>
</div>
<div className="stat">
<label>Distance & Time</label>
<div className="value">
<span id="distanceKm">{quote.km ? quote.km.toFixed(1) : "—"}</span> km <span id="driveMin">{quote.min ? Math.round(quote.min) : ""}</span> min
</div>
</div>
</div>
<div className="totalbar">
<div className="grand">Grand total: <span id="grandTotal">{quote.km ? money(quote.grand) : "—"}</span></div>
</div>
<div className="breakdown" id="breakdownBox" style={{ display: quote.km ? "block" : "none" }}>
<div className="brow"><div className="lbl">Booking fee</div><div id="bkFee">{money(CONFIG.PUBLIC_RATES.booking_flat)}</div></div>
<div className="brow"><div className="lbl">Distance (<span id="rateLabel">${quote.km && quote.km <= 15 ? CONFIG.PUBLIC_RATES.km_rate_under_equal_15 : CONFIG.PUBLIC_RATES.km_rate_over_15}</span>/km)</div><div id="distAmt">{quote.km ? money((quote.km) * (quote.km <= 15 ? CONFIG.PUBLIC_RATES.km_rate_under_equal_15 : CONFIG.PUBLIC_RATES.km_rate_over_15)) : money(0)}</div></div>
<div className="brow"><div className="lbl">Subtotal</div><div id="subAmt">{quote.km ? money(quote.subtotal) : money(0)}</div></div>
<div className="brow"><div className="lbl">HST (13%)</div><div id="taxAmt">{quote.km ? money(quote.tax) : money(0)}</div></div>
<div className="brow total"><div>Grand total</div><div id="grandAmt">{quote.km ? money(quote.grand) : money(0)}</div></div>
</div>
<div className="hint">Final price shown before you pay. Name, phone, and email are collected in the next step.</div>
<div className="cta">
<button id="bookNow" className="btn" disabled={!canBook} onClick={() => { if (canBook) openCalendly(); }}>
Continue to Scheduling
</button>
</div>
</div>
</div>
<div id="sched" ref={schedRef}></div>
{mapsError && (
<div style={{ marginTop: 12, padding: 12, background: "#fff6f6", color: "#b91c1c", borderRadius: 8 }}>
<strong>Map error:</strong> {mapsError}
</div>
)}
</div>
);
}