/* 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

{t("ct-overline")}

{c.titlePre} {c.titleEm}

{c.lead}

dzmitry@harupa.pro{t("contact-telegram")}
; } 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 { event.preventDefault(); close(); }} onClick={event => { if (event.target === dialog.current) { const rect = dialog.current.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close(); } }}> {open &&
{mode === "message" ?

{c.titlePre}
{c.titleEm}

{c.lead}

{c.nextTitle}

{c.nextBody}

{status === "saved" ?

{c.successTitle}

{c.successBody}

{c.channels[channel]} · {contact}

{c.readBlog}
:
setName(event.target.value)} disabled={status === "sending"} />
{c.channel}
{V.channels.map(key => )}
{ const nextContact = event.target.value; setContacts(value => ({ ...value, [channel]: nextContact })); }} aria-invalid={Boolean(errors.contact)} aria-describedby={errors.contact ? "contact-contact-error" : undefined} disabled={status === "sending"} />{errorText("contact")}
{social &&
setBackupEmail(event.target.value)} placeholder={c.placeholders.email} aria-invalid={Boolean(errors.backupEmail)} aria-describedby="contact-backup-hint contact-backupEmail-error" disabled={status === "sending"} />

{c.backupHint}

{errorText("backupEmail")}
}