Files

595 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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&region=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);
}
});
}