/* 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) => (
choose(o)}
style={{
background: active === o ? accent : "transparent",
color: active === o ? "#fff" : "rgba(255,255,255,0.75)",
border: "none", padding: "5px 9px", cursor: "pointer",
fontFamily: "inherit", fontSize: "inherit", letterSpacing: "inherit",
borderLeft: i ? "1px solid rgba(255,255,255,0.18)" : "none",
}}
>{labelFor(o)}
))}
);
}
// ============================================================
// 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,
});