/* Contact UI. No credentials or private phone number belong in this module. */
(function () {
const { useState, useEffect, useRef } = React;
const I = window.HPIcons;
const V = window.HPContactValidation;
let turnstileLoad;
function loadTurnstile() {
if (window.turnstile) return Promise.resolve(window.turnstile);
if (!turnstileLoad) turnstileLoad = new Promise((resolve, reject) => {
const script = document.createElement("script");
const fail = () => { script.remove(); turnstileLoad = null; reject(new Error("verification_unavailable")); };
const timer = setTimeout(fail, 15000);
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => { clearTimeout(timer); window.turnstile ? resolve(window.turnstile) : fail(); };
script.onerror = () => { clearTimeout(timer); fail(); };
document.head.appendChild(script);
});
return turnstileLoad;
}
async function request(path, payload) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(path, { method: payload ? "POST" : "GET", credentials: "same-origin", cache: "no-store", signal: controller.signal,
headers: payload ? { "Content-Type": "application/json" } : {}, body: payload ? JSON.stringify(payload) : undefined });
const body = await response.json();
if (!response.ok) throw Object.assign(new Error(body.error || "unavailable"), { status: response.status, field: body.field });
return body;
} finally { clearTimeout(timer); }
}
function openContact(mode = "message", source = "contact") {
window.dispatchEvent(new CustomEvent("hp:contact", { detail: { mode, source } }));
}
function ContactSection({ t }) {
const c = t("contact-form");
return ;
}
function Verification({ sitekey, action, lang, revision, onToken, onError, label }) {
const container = useRef(null);
const callbacks = useRef({ onToken, onError });
callbacks.current = { onToken, onError };
useEffect(() => {
let active = true, widget, observer, size;
callbacks.current.onToken("");
loadTurnstile().then((api) => {
if (!active) return;
const renderWidget = () => {
const nextSize = container.current.getBoundingClientRect().width < 300 ? "compact" : "flexible";
if (size === nextSize) return;
size = nextSize; callbacks.current.onToken("");
if (widget !== undefined) api.remove(widget);
widget = api.render(container.current, { sitekey, action, theme: "light", size, language: ({ ua: "uk", by: "auto" })[lang] || lang,
callback: token => { if (active) callbacks.current.onToken(token); },
"expired-callback": () => { if (active) callbacks.current.onToken(""); },
"error-callback": () => { if (active) { callbacks.current.onToken(""); callbacks.current.onError(); } return true; },
"timeout-callback": () => { if (active) { callbacks.current.onToken(""); callbacks.current.onError(); } }
});
};
renderWidget(); observer = new ResizeObserver(renderWidget); observer.observe(container.current);
}).catch(() => { if (active) callbacks.current.onError(); });
return () => { active = false; observer?.disconnect(); if (widget !== undefined && window.turnstile) window.turnstile.remove(widget); };
}, [sitekey, action, lang, revision]);
return
;
}
function ContactDialog({ t, lang }) {
const c = t("contact-form"), p = t("contact-phone-ui");
const dialog = useRef(null), focusReturn = useRef(null), pushed = useRef(false), source = useRef(location.pathname + " · contact");
const submission = useRef(null), busy = useRef(false), phoneBusy = useRef(false);
const [open, setOpen] = useState(false), [mode, setMode] = useState("message");
const [config, setConfig] = useState(null), [configError, setConfigError] = useState(false), [configRevision, setConfigRevision] = useState(0);
const [name, setName] = useState(""), [channel, setChannel] = useState("email"), [contacts, setContacts] = useState({});
const [message, setMessage] = useState(""), [backupEmail, setBackupEmail] = useState(""), [website, setWebsite] = useState("");
const [errors, setErrors] = useState({}), [status, setStatus] = useState("idle"), [sendError, setSendError] = useState("");
const [token, setToken] = useState(""), [verificationError, setVerificationError] = useState(false), [revision, setRevision] = useState(0);
const [phone, setPhone] = useState(""), [phoneStatus, setPhoneStatus] = useState("idle"), [copied, setCopied] = useState(false);
const social = channel === "linkedin" || channel === "x";
const contact = contacts[channel] || "";
const blogHref = "/articles/" + (lang === "en" ? "" : "index." + lang + ".html") + "?lang=" + lang;
useEffect(() => {
const fromHash = () => {
const requested = location.hash === "#phone" ? "phone" : location.hash === "#contact" ? "message" : null;
setOpen(Boolean(requested)); if (requested) setMode(requested);
};
const listener = event => {
const detail = event.detail || {};
if (!dialog.current?.open) focusReturn.current = document.activeElement;
source.current = (location.pathname + " · " + (detail.source || "contact")).slice(0, 240);
const next = detail.mode === "phone" ? "phone" : "message";
if (!["#contact", "#phone"].includes(location.hash)) {
history.pushState(history.state, "", next === "phone" ? "#phone" : "#contact"); pushed.current = true;
} else history.replaceState(history.state, "", next === "phone" ? "#phone" : "#contact");
setMode(next); setOpen(true); setToken(""); setVerificationError(false);
};
window.addEventListener("hp:contact", listener); window.addEventListener("hashchange", fromHash); window.addEventListener("popstate", fromHash);
fromHash();
return () => { window.removeEventListener("hp:contact", listener); window.removeEventListener("hashchange", fromHash); window.removeEventListener("popstate", fromHash); };
}, []);
useEffect(() => {
const node = dialog.current;
if (!open) { if (node.open) node.close(); return; }
if (!node.open) node.showModal();
const previous = document.body.style.overflow; document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = previous; if (node.open) node.close(); };
}, [open]);
useEffect(() => {
if (!open) return;
let active = true;
setConfigError(false); setConfig(null);
request("/api/contact/config").then(value => {
if (!value || typeof value.inquiriesEnabled !== "boolean" || typeof value.phoneEnabled !== "boolean") throw new Error("unavailable");
if (active) setConfig(value);
}).catch(() => { if (active) setConfigError(true); });
return () => { active = false; };
}, [open, configRevision]);
useEffect(() => { setToken(""); setVerificationError(false); setPhone(""); setPhoneStatus("idle"); setCopied(false); }, [open, mode]);
function close() {
setOpen(false);
if (pushed.current) { pushed.current = false; history.back(); }
else if (["#contact", "#phone"].includes(location.hash)) history.replaceState(history.state, "", location.pathname + location.search);
requestAnimationFrame(() => { if (focusReturn.current?.isConnected) focusReturn.current.focus(); });
}
function acceptVerification(value) {
setToken(value);
if (value) { setVerificationError(false); setPhoneStatus(current => current === "error" ? "idle" : current); }
}
function verificationRejected(error) { return error.status === 403 && error.message === "verification_failed"; }
function resetVerification() { setToken(""); setVerificationError(false); setRevision(value => value + 1); }
async function send(event) {
event.preventDefault();
if (busy.current || !config?.inquiriesEnabled) return;
const payload = { name: name.trim(), channel, contact: V.normalizeContact(channel, contact) || contact.trim(), message: message.trim(), backupEmail: social ? backupEmail.trim() : "", locale: lang, source: source.current, website };
const invalid = V.validate(payload); setErrors(invalid); setSendError("");
const first = Object.keys(invalid)[0];
if (first) { document.getElementById("contact-" + first)?.focus(); return; }
if (config.inquiryTurnstileRequired && !token) { setVerificationError(true); return; }
const fingerprint = JSON.stringify(payload);
if (!submission.current || submission.current.fingerprint !== fingerprint) submission.current = { fingerprint, id: crypto.randomUUID() };
busy.current = true; setStatus("sending");
try {
const result = await request("/api/inquiries", { ...payload, id: submission.current.id, turnstileToken: token });
if (result.status !== "saved" || result.id !== submission.current.id) throw new Error("unavailable");
setStatus("saved");
} catch (error) {
setStatus("idle");
const fieldError = error.status === 400 && ({ contact: channel, backupEmail: "backup", message: message.trim() ? "send" : "requiredMessage" })[error.field];
if (fieldError) {
setErrors({ [error.field]: fieldError });
requestAnimationFrame(() => document.getElementById("contact-" + error.field)?.focus());
} else if (verificationRejected(error)) {
setToken(""); setVerificationError(true);
} else setSendError(error.status === 429 ? "rate" : error.status === 503 ? "unavailable" : "send");
} finally { busy.current = false; }
}
async function revealPhone() {
if (phoneBusy.current || !config?.phoneEnabled || !token) return;
phoneBusy.current = true; setPhoneStatus("loading");
try {
const result = await request("/api/contact/phone", { turnstileToken: token });
if (!/^\+[1-9][\d\s()-]{6,24}$/.test(result.phone || "")) throw new Error("invalid_phone");
setPhone(result.phone); setPhoneStatus("ready");
} catch (error) {
setPhoneStatus("error");
if (verificationRejected(error)) { setToken(""); setVerificationError(true); }
}
finally { phoneBusy.current = false; }
}
const errorText = field => errors[field] ? c.errors[errors[field]] : "";
const verificationNeeded = config && (mode === "phone" ? config.phoneEnabled : config.inquiriesEnabled && config.inquiryTurnstileRequired);
return ;
}
Object.assign(window, { HPContactDialog: ContactDialog, HPContactSection: ContactSection, hpOpenContact: openContact });
})();