/* Shared primitives used by all three direction skins. Exported to window so the per-direction script files can use them. */ const { useState, useEffect, useRef, useMemo, useCallback } = React; // ============================================================ // haptic — tiny wrapper around navigator.vibrate // ============================================================ function haptic(ms = 8) { try { if (navigator.vibrate) navigator.vibrate(ms); } catch (e) {} } // ============================================================ // txField — resolve a field that may be either a bilingual { en, no } // object (placeholder data) or a plain string (WooCommerce, already localized // by Polylang/WPML or the store base language). Always returns a string. // ============================================================ function txField(v, lang = "en") { if (v == null) return ""; if (typeof v === "object" && !Array.isArray(v)) { return v[lang] ?? v.en ?? Object.values(v)[0] ?? ""; } return String(v); } // ============================================================ // useTick — re-renders every `ms` for live displays (countdown, cursor coords) // ============================================================ function useTick(ms = 1000) { const [, setN] = useState(0); useEffect(() => { const t = setInterval(() => setN(n => n + 1), ms); return () => clearInterval(t); }, [ms]); } // ============================================================ // Countdown — formatted DD : HH : MM : SS to a fixed ISO target. // Renders only the parts (no chrome) — each skin styles them. // ============================================================ function useCountdown(iso) { useTick(1000); const target = useMemo(() => new Date(iso).getTime(), [iso]); const now = Date.now(); let diff = Math.max(0, target - now); const d = Math.floor(diff / 86_400_000); diff -= d * 86_400_000; const h = Math.floor(diff / 3_600_000); diff -= h * 3_600_000; const m = Math.floor(diff / 60_000); diff -= m * 60_000; const s = Math.floor(diff / 1000); const pad = (n, w = 2) => String(n).padStart(w, "0"); return { d: pad(d, 2), h: pad(h), m: pad(m), s: pad(s), done: target - Date.now() <= 0 }; } // ============================================================ // Redacted — censored span. Reveal-on-hover by default; if revealAll is true, // shows the underlying word with a subtle highlight (for "show all decrypted" tweak). // ============================================================ function Redacted({ children, revealAll, intensity = 0.8, accent = "#c8493a" }) { const [hover, setHover] = useState(false); const revealed = revealAll || hover; return ( { setHover(true); haptic(6); }} onMouseLeave={() => setHover(false)} style={{ position: "relative", display: "inline-block", cursor: "help", padding: "0 0.18em", margin: "0 0.04em", transition: "background 160ms ease", // Outer keeps inherited color so currentColor resolves to the page's // foreground (dark on paper, cream on dark) — that's the bar fill. background: revealed ? `color-mix(in oklch, ${accent} ${intensity * 22}%, transparent)` : "currentColor", userSelect: "none", whiteSpace: "nowrap", verticalAlign: "baseline", }} > {/* Inner span flips text visibility independently of the bar fill. */} {children} ); } // Render an array of tokens (strings | { r: "word" }) into a flow of text. function RedactedRich({ tokens, revealAll, accent }) { return ( {tokens.map((t, i) => { if (typeof t === "string") return {t}; return {t.r}; })} ); } // ============================================================ // CustomCursor — modes: "off" | "minimal" | "tracking" // minimal: thin ring + center dot, that's it // tracking: ring + dot + faint hairlines + tiny coord chip // Hidden on touch/coarse pointers automatically. // ============================================================ function CustomCursor({ mode = "minimal", accent = "#c8493a" }) { const [pos, setPos] = useState({ x: -100, y: -100, vis: false }); const [overInteractive, setOver] = useState(false); const enabled = mode !== "off"; useEffect(() => { if (!enabled) return; const move = (e) => { setPos({ x: e.clientX, y: e.clientY, vis: true }); const el = e.target; const tag = (el?.tagName || "").toLowerCase(); setOver(["a", "button"].includes(tag) || el?.dataset?.interactive === "true" || !!el?.closest?.("[data-interactive='true']")); }; const leave = () => setPos(p => ({ ...p, vis: false })); window.addEventListener("mousemove", move); window.addEventListener("mouseleave", leave); document.body.style.cursor = "none"; return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseleave", leave); document.body.style.cursor = ""; }; }, [enabled]); const coarse = useMemo(() => window.matchMedia?.("(pointer: coarse)")?.matches, []); if (!enabled || coarse) return null; const ringR = overInteractive ? 14 : 7; const stroke = overInteractive ? accent : "rgba(236,229,216,0.7)"; return (
{mode === "tracking" && ( <>
)} {mode === "tracking" && overInteractive && (
● LOCK
)}
); } // ============================================================ // Lang toggle pill (EN / NO) // ============================================================ function LangToggle({ lang, setLang, accent }) { const opts = ["en", "no", "es"]; const labelFor = c => (c === "no" ? "NB" : c.toUpperCase()); // Highlight the truly-selected language from the cookie. Content may be // coerced to English for languages without translations (es), but the // pill should still reflect what the user picked. const active = (typeof document !== 'undefined' && (document.cookie.match(/(?:^|;)\s*awo_lang=(\w+)/) || [])[1]) || lang; const choose = o => { try { document.cookie = 'awo_lang=' + o + ';path=/;max-age=31536000;samesite=lax'; } catch (e) {} setLang(o); haptic(10); }; return (
{opts.map((o, i) => ( ))}
); } // ============================================================ // Placeholder product image — striped SVG, no real photo yet. // Per-direction wrappers add their own frame. // ============================================================ // `image` (a real WooCommerce product photo URL) takes over the frame when // present; without it we fall back to the striped swatch placeholder. The lot // stamp + category caption stay in both modes so the editorial framing holds. function GarmentPlaceholder({ swatch = "#1a1a1a", lot = "LOT-000", label = "garment", ratio = 1.25, image = null }) { const hasImage = !!image; const stampColor = hasImage ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,0.55)"; const capColor = hasImage ? "rgba(255,255,255,0.72)" : "rgba(255,255,255,0.4)"; const shadow = hasImage ? "0 1px 6px rgba(0,0,0,0.55)" : "none"; return (
{!hasImage && (
)}
{lot}
[ {label} ] {hasImage ? "" : "placeholder"}
); } // ============================================================ // Logo — inline AWOTOWA wordmark. Loads the SVG file once and // injects its inner content into a host so color is // controllable via CSS `color` / currentColor (mask-image was // flaky because the SVG's currentColor inside a mask context // resolves to black, killing luminance masks). // ============================================================ // Logo source URLs — tries local first (works in preview), falls back to the // WordPress Media Library URL (works once deployed to awotowa.com). // If you upload the SVG in a different month, update the WP URL below. const LOGO_SOURCES = [ "assets/logo.svg", "https://awotowa.com/wp-content/uploads/2026/05/logo.svg", ]; async function fetchLogoSvg() { // Production: PHP template injects the SVG inner content as a global. if (typeof window !== 'undefined' && window.AWOTOWA_LOGO_SVG_INNER) { return '' + window.AWOTOWA_LOGO_SVG_INNER + ''; } for (const url of LOGO_SOURCES) { try { const r = await fetch(url); if (r.ok) return await r.text(); } catch (e) { /* try next */ } } return null; } function Logo({ color = "currentColor", height, width, style, ariaLabel = "AWOTOWA" }) { const [inner, setInner] = useState(null); useEffect(() => { let cancelled = false; fetchLogoSvg().then(t => { if (cancelled || !t) return; // Extract inner content between the outer .... const m = t.match(/]*>([\s\S]*)<\/svg>\s*$/i); setInner(m ? m[1] : t); }); return () => { cancelled = true; }; }, []); // height/width may be a number (px) or any CSS value (clamp(), %, etc.) const sizing = height != null ? { height, width: "auto" } : width != null ? { width, height: "auto" } : { height: 24, width: "auto" }; return ( ); } function formatPrice(n, lang = "en") { return new Intl.NumberFormat(lang === "no" ? "nb-NO" : "en-US", { style: "currency", currency: "NOK", maximumFractionDigits: 0 }).format(n); } // ============================================================ // useCart — toy state // ============================================================ function useCart() { const [items, setItems] = useState({}); const add = (id) => { setItems(s => ({ ...s, [id]: (s[id] || 0) + 1 })); haptic(14); }; const remove = (id) => { setItems(s => { const c = { ...s }; delete c[id]; return c; }); haptic(8); }; const total = Object.values(items).reduce((a, b) => a + b, 0); return { items, add, remove, total }; } // ============================================================ // Smooth section scroller helper (for nav anchors) // ============================================================ function scrollToId(id) { const el = document.getElementById(id); if (!el) return; const top = el.getBoundingClientRect().top + window.scrollY - 80; window.scrollTo({ top, behavior: "smooth" }); } // ============================================================ // Export to window for other Babel script files to consume. // ============================================================ Object.assign(window, { haptic, txField, useTick, useCountdown, Redacted, RedactedRich, CustomCursor, LangToggle, Logo, GarmentPlaceholder, formatPrice, useCart, scrollToId, });