595 lines
19 KiB
JavaScript
595 lines
19 KiB
JavaScript
import {
|
||
hydrateCart,
|
||
subscribe,
|
||
getSnapshot,
|
||
updateQuantity,
|
||
removeItem,
|
||
clearCart,
|
||
formatPrice,
|
||
} from "/js/cart.js";
|
||
import { IRAN_PROVINCES } from "/js/iran-locations.js?v=loc1";
|
||
import {
|
||
generateInvoicePdf,
|
||
downloadFile,
|
||
openWhatsAppWithInvoice,
|
||
whatsappChatUrl,
|
||
} from "/js/invoice-pdf.js?v=eurInv1";
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function generateOrderId() {
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
const n = String(Math.floor(1000 + Math.random() * 9000));
|
||
return `KIKO-${y}${m}${day}-${n}`;
|
||
}
|
||
|
||
function ordersEndpoint() {
|
||
return (
|
||
document.body.dataset.ordersEndpoint ||
|
||
window.__KIKOMIKO_ORDERS_URL__ ||
|
||
""
|
||
).trim();
|
||
}
|
||
|
||
function whatsappNumber() {
|
||
return (
|
||
document.body.dataset.whatsapp ||
|
||
window.__KIKOMIKO_WHATSAPP__ ||
|
||
"393520566607"
|
||
);
|
||
}
|
||
|
||
function eurToTomanRate() {
|
||
const raw = Number(document.body.dataset.eurRate || "");
|
||
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||
}
|
||
|
||
function googlePlacesKey() {
|
||
return (
|
||
document.body.dataset.googlePlacesKey ||
|
||
window.__GOOGLE_PLACES_API_KEY__ ||
|
||
""
|
||
).trim();
|
||
}
|
||
|
||
function normalizeFa(s) {
|
||
return String(s || "")
|
||
.trim()
|
||
.replace(/\u200c/g, "")
|
||
.replace(/ي/g, "ی")
|
||
.replace(/ك/g, "ک")
|
||
.replace(/\s+/g, " ");
|
||
}
|
||
|
||
function findProvince(name) {
|
||
const n = normalizeFa(name);
|
||
if (!n) return null;
|
||
return (
|
||
IRAN_PROVINCES.find((p) => normalizeFa(p.name) === n) ||
|
||
IRAN_PROVINCES.find((p) => normalizeFa(p.name).includes(n) || n.includes(normalizeFa(p.name))) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function findCityInProvince(province, cityName) {
|
||
if (!province) return null;
|
||
const n = normalizeFa(cityName);
|
||
if (!n) return null;
|
||
return (
|
||
province.cities.find((c) => normalizeFa(c) === n) ||
|
||
province.cities.find((c) => normalizeFa(c).includes(n) || n.includes(normalizeFa(c))) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function receiptItemsHtml(items) {
|
||
const rows = items
|
||
.map((i) => {
|
||
const lineTotal = formatPrice(i.price * i.quantity);
|
||
const productType = i.category || "—";
|
||
const img = i.image
|
||
? `<img class="receipt-thumb" src="${escapeHtml(i.image)}" alt="" width="64" height="85" />`
|
||
: `<span class="receipt-thumb receipt-thumb--empty" aria-hidden="true"></span>`;
|
||
return `<tr>
|
||
<td class="receipt-model">${escapeHtml(productType)}<div class="receipt-sub">${escapeHtml(i.name)}</div></td>
|
||
<td class="receipt-qty">${escapeHtml(String(i.quantity))}</td>
|
||
<td class="receipt-price">${escapeHtml(lineTotal)}</td>
|
||
<td class="receipt-img">${img}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
return `<div class="receipt-table-wrap">
|
||
<table class="receipt-table">
|
||
<thead>
|
||
<tr>
|
||
<th>نوع محصول</th>
|
||
<th>عدد</th>
|
||
<th>قیمت</th>
|
||
<th>عکس</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function fillReceipt(el, data) {
|
||
if (!el) return;
|
||
const set = (sel, text) => {
|
||
const node = el.querySelector(sel);
|
||
if (node) node.textContent = text;
|
||
};
|
||
set("[data-receipt-id]", data.orderId);
|
||
set("[data-receipt-date]", data.dateLabel);
|
||
set("[data-receipt-name]", data.customerName);
|
||
set("[data-receipt-total]", data.totalLabel);
|
||
const itemsHost = el.querySelector("[data-receipt-items]");
|
||
if (itemsHost) {
|
||
if (data.itemsHtml) itemsHost.innerHTML = data.itemsHtml;
|
||
else itemsHost.textContent = data.itemsText || "";
|
||
}
|
||
el.hidden = false;
|
||
}
|
||
|
||
/**
|
||
* Province → city cascading selects (Iran). Customers choose; no free text.
|
||
*/
|
||
function initLocationSelects(form) {
|
||
const provinceSelect = form.querySelector("[data-province-select]");
|
||
const citySelect = form.querySelector("[data-city-select]");
|
||
if (!(provinceSelect instanceof HTMLSelectElement) || !(citySelect instanceof HTMLSelectElement)) {
|
||
return { setProvinceCity: () => {} };
|
||
}
|
||
|
||
for (const p of IRAN_PROVINCES) {
|
||
const opt = document.createElement("option");
|
||
opt.value = p.name;
|
||
opt.textContent = p.name;
|
||
provinceSelect.appendChild(opt);
|
||
}
|
||
|
||
function fillCities(provinceName, preferredCity) {
|
||
const province = findProvince(provinceName);
|
||
citySelect.innerHTML = "";
|
||
if (!province) {
|
||
citySelect.disabled = true;
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "ابتدا استان را انتخاب کنید";
|
||
citySelect.appendChild(opt);
|
||
return;
|
||
}
|
||
citySelect.disabled = false;
|
||
const placeholder = document.createElement("option");
|
||
placeholder.value = "";
|
||
placeholder.textContent = "انتخاب شهر";
|
||
citySelect.appendChild(placeholder);
|
||
for (const city of province.cities) {
|
||
const opt = document.createElement("option");
|
||
opt.value = city;
|
||
opt.textContent = city;
|
||
citySelect.appendChild(opt);
|
||
}
|
||
if (preferredCity) {
|
||
const matched = findCityInProvince(province, preferredCity);
|
||
if (matched) {
|
||
citySelect.value = matched;
|
||
} else {
|
||
const extra = document.createElement("option");
|
||
extra.value = preferredCity;
|
||
extra.textContent = preferredCity;
|
||
citySelect.appendChild(extra);
|
||
citySelect.value = preferredCity;
|
||
}
|
||
}
|
||
}
|
||
|
||
provinceSelect.addEventListener("change", () => {
|
||
fillCities(provinceSelect.value, "");
|
||
});
|
||
|
||
function setProvinceCity(provinceName, cityName) {
|
||
const province = findProvince(provinceName);
|
||
if (!province) {
|
||
provinceSelect.value = "";
|
||
fillCities("", "");
|
||
return;
|
||
}
|
||
provinceSelect.value = province.name;
|
||
fillCities(province.name, cityName || "");
|
||
}
|
||
|
||
return { setProvinceCity, provinceSelect, citySelect };
|
||
}
|
||
|
||
function loadGoogleMapsPlaces(apiKey) {
|
||
if (window.google?.maps?.places) return Promise.resolve();
|
||
if (window.__kikoGoogleMapsPromise) return window.__kikoGoogleMapsPromise;
|
||
window.__kikoGoogleMapsPromise = new Promise((resolve, reject) => {
|
||
const cb = `__kikoPlacesReady_${Date.now()}`;
|
||
window[cb] = () => {
|
||
delete window[cb];
|
||
resolve();
|
||
};
|
||
const script = document.createElement("script");
|
||
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&libraries=places&language=fa®ion=IR&callback=${cb}`;
|
||
script.async = true;
|
||
script.onerror = () => reject(new Error("Google Maps failed to load"));
|
||
document.head.appendChild(script);
|
||
});
|
||
return window.__kikoGoogleMapsPromise;
|
||
}
|
||
|
||
/**
|
||
* Google Places Autocomplete on address — fills استان/شهر when a place is chosen.
|
||
*/
|
||
async function initGoogleAddressAutocomplete(form, setProvinceCity) {
|
||
const key = googlePlacesKey();
|
||
if (!key) return;
|
||
const addressEl = form.querySelector('textarea[name="address"], input[name="address"]');
|
||
if (!(addressEl instanceof HTMLTextAreaElement) && !(addressEl instanceof HTMLInputElement)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await loadGoogleMapsPlaces(key);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
// Places Autocomplete needs an <input>; mirror textarea value via a search input above it.
|
||
let input = addressEl;
|
||
if (addressEl instanceof HTMLTextAreaElement) {
|
||
const wrap = document.createElement("label");
|
||
wrap.className = "field";
|
||
wrap.innerHTML = `<span>جستجوی آدرس (گوگل)</span>`;
|
||
input = document.createElement("input");
|
||
input.type = "text";
|
||
input.setAttribute("autocomplete", "off");
|
||
input.setAttribute("placeholder", "شهر، محله یا خیابان را جستجو کنید…");
|
||
input.setAttribute("aria-label", "جستجوی آدرس با گوگل");
|
||
wrap.appendChild(input);
|
||
addressEl.parentElement?.insertBefore(wrap, addressEl);
|
||
}
|
||
|
||
const autocomplete = new google.maps.places.Autocomplete(input, {
|
||
componentRestrictions: { country: "ir" },
|
||
fields: ["address_components", "formatted_address", "name"],
|
||
types: ["geocode"],
|
||
});
|
||
|
||
autocomplete.addListener("place_changed", () => {
|
||
const place = autocomplete.getPlace();
|
||
if (!place) return;
|
||
|
||
let province = "";
|
||
let city = "";
|
||
for (const c of place.address_components || []) {
|
||
const types = c.types || [];
|
||
if (types.includes("administrative_area_level_1")) {
|
||
province = c.long_name;
|
||
}
|
||
if (
|
||
types.includes("locality") ||
|
||
types.includes("administrative_area_level_2")
|
||
) {
|
||
if (!city) city = c.long_name;
|
||
}
|
||
}
|
||
|
||
if (province || city) setProvinceCity(province, city);
|
||
|
||
const formatted = place.formatted_address || place.name || "";
|
||
if (formatted && addressEl instanceof HTMLTextAreaElement) {
|
||
if (!addressEl.value.trim()) addressEl.value = formatted;
|
||
} else if (formatted && addressEl instanceof HTMLInputElement && addressEl === input) {
|
||
addressEl.value = formatted;
|
||
}
|
||
});
|
||
}
|
||
|
||
function validateCheckoutFields(data) {
|
||
const firstName = String(data.get("firstName") || "").trim();
|
||
const lastName = String(data.get("lastName") || "").trim();
|
||
const phone = String(data.get("phone") || "").trim();
|
||
const address = String(data.get("address") || "").trim();
|
||
const city = String(data.get("city") || "").trim();
|
||
const province = String(data.get("province") || "").trim();
|
||
|
||
if (!firstName) return "نام الزامی است.";
|
||
if (!lastName) return "نام خانوادگی الزامی است.";
|
||
if (!phone) return "شماره تلفن الزامی است.";
|
||
if (phone.replace(/\D/g, "").length < 10) return "شماره تلفن معتبر وارد کنید.";
|
||
if (!address) return "آدرس الزامی است.";
|
||
if (!province) return "استان را از لیست انتخاب کنید.";
|
||
if (!city) return "شهر را از لیست انتخاب کنید.";
|
||
return "";
|
||
}
|
||
|
||
/** Static checkout: Sheets storage + WhatsApp (no site backend). */
|
||
export function initCheckout() {
|
||
const root = document.querySelector("[data-checkout]");
|
||
if (!root) return;
|
||
|
||
hydrateCart();
|
||
const list = root.querySelector("[data-cart-lines]");
|
||
const totalEl = root.querySelector("[data-cart-total]");
|
||
const form = root.querySelector("form");
|
||
const errorEl = root.querySelector("[data-checkout-error]");
|
||
const successEl = root.querySelector("[data-checkout-success]");
|
||
const submitBtn = root.querySelector("[type=submit]");
|
||
const receiptEl = root.querySelector("[data-order-receipt]");
|
||
const downloadPdfBtn = root.querySelector("[data-receipt-pdf]");
|
||
const openWaBtn = root.querySelector("[data-receipt-whatsapp]");
|
||
const phone = whatsappNumber();
|
||
|
||
/** @type {{ file: File|null; waText: string; waUrl: string; pdfUrl?: string } | null} */
|
||
let lastInvoice = null;
|
||
|
||
downloadPdfBtn?.addEventListener("click", () => {
|
||
if (lastInvoice?.file) downloadFile(lastInvoice.file);
|
||
else if (lastInvoice?.pdfUrl) window.open(lastInvoice.pdfUrl, "_blank", "noopener");
|
||
});
|
||
openWaBtn?.addEventListener("click", async () => {
|
||
if (lastInvoice?.file) {
|
||
try {
|
||
const result = await openWhatsAppWithInvoice(
|
||
lastInvoice.file,
|
||
lastInvoice.waText,
|
||
phone
|
||
);
|
||
lastInvoice.pdfUrl = result.pdfUrl;
|
||
lastInvoice.waUrl = result.waUrl;
|
||
return;
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
}
|
||
if (lastInvoice?.waUrl) {
|
||
window.location.href = lastInvoice.waUrl;
|
||
return;
|
||
}
|
||
window.location.href = whatsappChatUrl(phone, "");
|
||
});
|
||
|
||
let setProvinceCity = () => {};
|
||
if (form instanceof HTMLFormElement) {
|
||
const loc = initLocationSelects(form);
|
||
setProvinceCity = loc.setProvinceCity;
|
||
initGoogleAddressAutocomplete(form, setProvinceCity);
|
||
}
|
||
|
||
function render(snap) {
|
||
if (!list || !totalEl || !submitBtn) return;
|
||
if (!snap.items.length) {
|
||
list.innerHTML = `<p class="empty-cart">سبد خرید خالی است.</p>`;
|
||
totalEl.textContent = formatPrice(0);
|
||
submitBtn.setAttribute("disabled", "");
|
||
return;
|
||
}
|
||
|
||
submitBtn.removeAttribute("disabled");
|
||
list.innerHTML = snap.items
|
||
.map(
|
||
(item) => `
|
||
<div class="cart-line" data-id="${item.productId}">
|
||
${
|
||
item.image
|
||
? `<img src="${item.image}" alt="" width="72" height="96" />`
|
||
: `<div style="width:72px;height:96px;background:#f7f7f7"></div>`
|
||
}
|
||
<div style="flex:1;min-width:0">
|
||
<a href="/products/${item.slug}/">${escapeHtml(item.name)}</a>
|
||
<div class="text-muted" style="font-size:0.85rem;margin-top:0.25rem">${formatPrice(item.price)}</div>
|
||
<div class="qty-controls">
|
||
<button type="button" data-qty-dec aria-label="کاهش تعداد">−</button>
|
||
<span>${item.quantity}</span>
|
||
<button type="button" data-qty-inc aria-label="افزایش تعداد">+</button>
|
||
<button type="button" data-qty-remove aria-label="حذف از سبد" style="margin-inline-start:0.5rem;font-size:0.75rem;color:#8a8a8a">حذف</button>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
|
||
totalEl.textContent = formatPrice(snap.subtotal);
|
||
|
||
list.querySelectorAll(".cart-line").forEach((row) => {
|
||
const id = row.getAttribute("data-id");
|
||
const item = snap.items.find((i) => i.productId === id);
|
||
if (!item || !id) return;
|
||
row.querySelector("[data-qty-dec]")?.addEventListener("click", () =>
|
||
updateQuantity(id, item.quantity - 1)
|
||
);
|
||
row.querySelector("[data-qty-inc]")?.addEventListener("click", () =>
|
||
updateQuantity(id, item.quantity + 1)
|
||
);
|
||
row.querySelector("[data-qty-remove]")?.addEventListener("click", () =>
|
||
removeItem(id)
|
||
);
|
||
});
|
||
}
|
||
|
||
subscribe(render);
|
||
render(getSnapshot());
|
||
|
||
form?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (!(form instanceof HTMLFormElement)) return;
|
||
const snap = getSnapshot();
|
||
if (!snap.items.length) return;
|
||
|
||
errorEl && (errorEl.hidden = true);
|
||
successEl && (successEl.hidden = true);
|
||
|
||
const data = new FormData(form);
|
||
const validationError = validateCheckoutFields(data);
|
||
if (validationError) {
|
||
if (errorEl) {
|
||
errorEl.hidden = false;
|
||
errorEl.textContent = validationError;
|
||
}
|
||
return;
|
||
}
|
||
|
||
submitBtn?.setAttribute("disabled", "");
|
||
if (submitBtn) submitBtn.textContent = "در حال ثبت…";
|
||
|
||
const customerName = `${String(data.get("firstName") || "").trim()} ${String(data.get("lastName") || "").trim()}`.trim();
|
||
const customerPhone = String(data.get("phone") || "").trim();
|
||
const email = String(data.get("email") || "").trim();
|
||
const address = String(data.get("address") || "").trim();
|
||
const city = String(data.get("city") || "").trim();
|
||
const province = String(data.get("province") || "").trim();
|
||
const notes = String(data.get("notes") || "").trim();
|
||
|
||
const orderId = generateOrderId();
|
||
const dateIso = new Date().toISOString();
|
||
const dateLabel = new Date().toLocaleString("fa-IR");
|
||
const productsLines = snap.items.map(
|
||
(i) =>
|
||
`- ${i.name} | ${i.category || "—"} | ×${i.quantity} = ${formatPrice(i.price * i.quantity)}`
|
||
);
|
||
const itemsText = productsLines.join("\n");
|
||
const itemsHtml = receiptItemsHtml(snap.items);
|
||
const totalLabel = formatPrice(snap.subtotal);
|
||
|
||
const endpoint = ordersEndpoint();
|
||
if (endpoint) {
|
||
try {
|
||
const res = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "text/plain;charset=utf-8" },
|
||
body: JSON.stringify({
|
||
orderId,
|
||
date: dateIso,
|
||
customerName,
|
||
phone: customerPhone,
|
||
items: itemsText,
|
||
total: totalLabel,
|
||
status: "Pending",
|
||
}),
|
||
redirect: "follow",
|
||
});
|
||
const json = await res.json().catch(() => ({}));
|
||
if (!res.ok || json.ok === false) {
|
||
throw new Error(json.error || "ثبت سفارش در سیستم ناموفق بود");
|
||
}
|
||
} catch (err) {
|
||
if (errorEl) {
|
||
errorEl.hidden = false;
|
||
errorEl.textContent =
|
||
err instanceof Error ? err.message : "خطا در ثبت سفارش";
|
||
}
|
||
if (submitBtn) {
|
||
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
|
||
submitBtn.removeAttribute("disabled");
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
fillReceipt(receiptEl, {
|
||
orderId,
|
||
dateLabel,
|
||
customerName,
|
||
itemsHtml,
|
||
totalLabel,
|
||
});
|
||
|
||
if (successEl) {
|
||
successEl.hidden = false;
|
||
successEl.textContent = `سفارش ${orderId} ثبت شد. در حال ساخت فاکتور و باز کردن واتساپ…`;
|
||
}
|
||
|
||
const waLines = [
|
||
"Order Number:",
|
||
orderId,
|
||
"",
|
||
"Customer Name:",
|
||
customerName,
|
||
"",
|
||
"Products:",
|
||
...productsLines,
|
||
"",
|
||
"Total:",
|
||
totalLabel,
|
||
"",
|
||
`Email: ${email}`,
|
||
`Phone: ${customerPhone}`,
|
||
`Address: ${address}`,
|
||
`City: ${city}`,
|
||
`Province: ${province}`,
|
||
`Notes: ${notes}`,
|
||
];
|
||
const waText = waLines.join("\n");
|
||
const itemsForPdf = snap.items.map((i) => ({ ...i }));
|
||
|
||
try {
|
||
const pdfFile = await generateInvoicePdf({
|
||
orderId,
|
||
dateLabel,
|
||
customerName,
|
||
customerPhone,
|
||
address,
|
||
city,
|
||
province,
|
||
items: itemsForPdf,
|
||
totalLabel,
|
||
formatPrice,
|
||
eurToTomanRate: eurToTomanRate(),
|
||
});
|
||
|
||
clearCart();
|
||
form.reset();
|
||
setProvinceCity("", "");
|
||
|
||
if (submitBtn) {
|
||
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
|
||
submitBtn.setAttribute("disabled", "");
|
||
}
|
||
|
||
if (successEl) {
|
||
successEl.textContent =
|
||
"فاکتور آماده شد — واتساپ باز میشود. فقط دکمه ارسال را بزنید.";
|
||
}
|
||
|
||
const result = await openWhatsAppWithInvoice(pdfFile, waText, phone);
|
||
lastInvoice = {
|
||
file: pdfFile,
|
||
waText,
|
||
waUrl: result.waUrl,
|
||
pdfUrl: result.pdfUrl,
|
||
};
|
||
} catch (err) {
|
||
clearCart();
|
||
form.reset();
|
||
setProvinceCity("", "");
|
||
if (submitBtn) {
|
||
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
|
||
submitBtn.setAttribute("disabled", "");
|
||
}
|
||
const url = whatsappChatUrl(phone, waText);
|
||
lastInvoice = { file: null, waText, waUrl: url };
|
||
if (successEl) {
|
||
successEl.textContent =
|
||
err instanceof Error
|
||
? `ثبت شد، اما فاکتور آماده نشد: ${err.message}. واتساپ با متن سفارش باز میشود.`
|
||
: "ثبت شد. واتساپ با متن سفارش باز میشود.";
|
||
}
|
||
window.setTimeout(() => {
|
||
window.location.href = url;
|
||
}, 500);
|
||
}
|
||
});
|
||
}
|