// Mr. Hair Club — main React app
// Single-page site, trilingual, scroll-based with anchored sections.
const { useState, useEffect, useMemo, useRef } = React;
const C = window.COPY;
const TEAM = window.TEAM;
const BOOKING_URL = window.BOOKING_URL;
const WHATSAPP_URL = window.WHATSAPP_URL;
const MAPS_URL = window.MAPS_URL;
const IG_URL = window.IG_URL;
const USD_RATE = window.USD_RATE;
const fmtNum = window.fmtNum;
// ───────────────────────────────────────────────────────────────────────────
// TWEAK DEFAULTS
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentHue": "cyan",
"density": "comfortable",
"showUsd": true,
"heroBg": "glow",
"heroTreatment": "duotone",
"heroComposition": "fullbleed",
"heroLayout": "dense",
"language": "pt"
}/*EDITMODE-END*/;
const HUE_OPTIONS = {
cyan: { name: "Electric cyan", c1: "#2BA5C7", c2: "#56C7E3", c3: "#0F4F60" },
azure: { name: "Brand azure", c1: "#3A8FE0", c2: "#6BB5F5", c3: "#1A4A82" },
teal: { name: "Deep teal", c1: "#19B5A2", c2: "#5FD9C8", c3: "#0E5249" },
amber: { name: "Hot amber", c1: "#E89B3C", c2: "#F5C275", c3: "#7A4E15" },
};
// ───────────────────────────────────────────────────────────────────────────
// PRIMITIVES
function BookLink({ children, className, style, onClick, secondary, small }) {
const cls = ["btn", secondary ? "btn-ghost" : "btn-primary", small ? "btn-sm" : "", className || ""].join(" ");
return (
{children}
→
);
}
function SectionLabel({ kicker, title, sub, align }) {
return (
{kicker}
{title}
{sub &&
{sub}
}
);
}
// Brand logo built from type
function BrandLogo({ size = 1 }) {
return (
MR.
HAIR
club
);
}
// Striped placeholder for missing imagery
function PhotoPlaceholder({ label, aspect = "4/5" }) {
return (
{label}
);
}
// ───────────────────────────────────────────────────────────────────────────
// HEADER
function Header({ lang, setLang, t, scrolled }) {
const [open, setOpen] = useState(false);
const links = [
["services", t.nav.services],
["team", t.nav.team],
["casa", t.nav.casa],
["club", t.nav.club],
["visit", t.nav.visit],
];
return (
);
}
function LangSwitcher({ lang, setLang }) {
return (
{["pt", "en", "es"].map((l) => (
setLang(l)}
aria-pressed={lang === l}
>
{l.toUpperCase()}
))}
);
}
// ───────────────────────────────────────────────────────────────────────────
// HERO
function Hero({ t, lang, heroBg, heroLayout, heroTreatment, heroComposition }) {
const minimal = heroLayout === "minimal";
const split = heroComposition === "split";
const HP = window.__heroPhotos || {};
const photoSrc = heroBg === "storefront" ? (HP.storefront || "assets/storefront.jpg")
: heroBg === "interior" ? (HP.interior || "assets/hero-interior.jpg")
: null; // glow
return (
{photoSrc ? (
) : (
)}
{heroTreatment === "duotone" &&
}
{minimal ? (
{t.hero.title1}
{t.hero.title2}
{" "}{t.hero.title3}
) : (
{t.hero.title1}
{t.hero.title2}
{t.hero.title3}
)}
{!minimal &&
{t.hero.sub}
}
{!minimal &&
}
{lang === "pt" && "desde"}
{lang === "en" && "since"}
{lang === "es" && "desde"}
2011
RJ · BR
);
}
// Thin authority strip used in minimal hero mode (sits between Hero and Why)
function AuthorityStrip({ t, lang }) {
return (
);
}
function AuthorityBar({ t, lang }) {
const totalCuts = TEAM.reduce((s, b) => s + b.cuts, 0);
const items = [
{ big: "4.9★", small: t.authority.rating },
{ big: fmtNum(1512, lang), small: t.authority.reviews },
{ big: fmtNum(totalCuts, lang) + "+", small: t.authority.cuts },
{ big: "15", small: t.authority.years },
];
return (
{items.map((it, i) => (
))}
);
}
// ───────────────────────────────────────────────────────────────────────────
// WHY
function Why({ t }) {
return (
{t.why.items.map((it, i) => (
))}
);
}
// ───────────────────────────────────────────────────────────────────────────
// SERVICES
function Services({ t, lang, showUsd }) {
return (
{t.services.groups.map((g, gi) => (
0{gi + 1}
{g.name}
{g.items.map(([name, dur, price], i) => (
{name}
{dur}
R$ {price}
{showUsd && (
~USD {Math.round(price / USD_RATE)}
)}
{t.services.book}
))}
))}
);
}
// ───────────────────────────────────────────────────────────────────────────
// TEAM
function Team({ t, lang }) {
const totalCuts = TEAM.reduce((s, b) => s + b.cuts, 0);
return (
{fmtNum(totalCuts, lang)}+
{lang === "pt" && "cortes feitos. Seis barbeiros. Todos com nota 5,00."}
{lang === "en" && "cuts performed. Six barbers. All rated 5.00."}
{lang === "es" && "cortes realizados. Seis barberos. Todos con 5,00."}
{TEAM.map((b) => (
))}
);
}
function BarberCard({ barber, t, lang }) {
const hasPhoto = barber.id !== "sebastian";
return (
{hasPhoto ? (
<>
>
) : (
)}
★
{barber.rating.toFixed(2)}
{barber.name}
{t.team.role}
{fmtNum(barber.cuts, lang)}
{t.team.cuts}
{t.team.bios[barber.name]}
{barber.tags.map((tag) => (
{t.team.tags[tag]}
))}
{t.team.bookWith} {barber.name}
);
}
// ───────────────────────────────────────────────────────────────────────────
// WORK / GALLERY (user-fillable image slots)
function Work({ t }) {
const tags = ["all", "cuts", "beards", "interior", "before"];
const [filter, setFilter] = useState("all");
// Bento grid spans — paired with slot order. Keeps composition visually rhythmic.
const spans = [
{ c: "span 2", r: "span 2" }, // big
{ c: "span 1", r: "span 1" },
{ c: "span 1", r: "span 2" }, // tall
{ c: "span 2", r: "span 1" }, // wide
{ c: "span 1", r: "span 1" },
{ c: "span 1", r: "span 1" },
{ c: "span 1", r: "span 1" },
{ c: "span 2", r: "span 1" }, // wide
{ c: "span 1", r: "span 1" },
{ c: "span 1", r: "span 1" },
];
return (
{tags.map((tag) => (
setFilter(tag)}
>
{t.work.tags[tag]}
))}
{t.work.slots.map((s, i) => {
const hide = filter !== "all" && s.tag !== filter;
const sp = spans[i] || { c: "span 1", r: "span 1" };
const realSrc = window.WORK_PHOTOS && window.WORK_PHOTOS[s.id];
return (
);
})}
);
}
// ───────────────────────────────────────────────────────────────────────────
// CLUB (Subscription)
function Club({ t, lang }) {
const [tab, setTab] = useState("flex");
const plans = tab === "flex" ? t.club.flex : t.club.essencial;
const compareText = tab === "flex"
? (t.club.compareFlex || t.club.compare)
: (t.club.compareEssencial || t.club.compare);
return (
setTab("flex")}
>
FLEX
{t.club.tab1.split("·")[1]?.trim()}
R$ 109+
setTab("essencial")}
>
ESSENCIAL
{t.club.tab2.split("·")[1]?.trim()}
R$ 129+
{plans.map((p, i) => (
{p.hot &&
{t.club.most}
}
{p.name}
{p.forWho &&
{p.forWho}
}
R$
{p.price}
{t.club.monthly}
{t.club.includes}
{p.inc.map((it, j) => (
✓ {it}
))}
{p.save &&
{p.save}
}
{t.club.subscribe}
→
))}
{compareText}
{t.club.note}
);
}
// ───────────────────────────────────────────────────────────────────────────
// REVIEWS
const REVIEWS = window.REVIEWS;
function Reviews({ t, lang }) {
const items = REVIEWS[lang] || [];
const hasPlaceholders = items.some((r) => r.placeholder);
const withPhoto = items.filter((r) => !r.placeholder && (r.photo || (r.photos && r.photos.length)));
const textOnly = items.filter((r) => r.placeholder || !(r.photo || (r.photos && r.photos.length)));
const renderCard = (r, i) => (
{r.combinedBeforeAfter && r.photo ? (
{lang === "en" ? "Before" : lang === "es" ? "Antes" : "Antes"}
{lang === "en" ? "After" : lang === "es" ? "Después" : "Depois"}
) : r.photos && r.photos.length > 1 ? (
{r.photos.map((p, j) => (
{r.beforeAfter && (
{j === 0
? (lang === "en" ? "Before" : lang === "es" ? "Antes" : "Antes")
: (lang === "en" ? "After" : lang === "es" ? "Después" : "Depois")}
)}
))}
) : r.photo ? (
) : null}
★★★★★
{r.placeholder ? r.text : `“${r.text}”`}
{r.name}
·
{r.date}
);
return (
{hasPlaceholders && (
{lang === "pt" && "Reviews reais em português ainda não importados. Os cards abaixo são placeholders — substituir antes do launch."}
{lang === "es" && "Reseñas reales en español aún no importadas. Los cards abajo son placeholders — sustituir antes del lanzamiento."}
)}
{withPhoto.length > 0 && (
{withPhoto.map(renderCard)}
)}
{textOnly.length > 0 && (
{textOnly.map(renderCard)}
)}
);
}
// ───────────────────────────────────────────────────────────────────────────
// VISIT / LOCATION
function Visit({ t }) {
return (
{t.location.hours}
{t.location.hoursWeek}
{t.location.hoursSun}
{t.location.walk}
{t.location.walkFrom.map(([place, time], i) => (
{place}
{time}
))}
);
}
// ───────────────────────────────────────────────────────────────────────────
// FINAL CTA
function FinalCta({ t }) {
return (
{t.finalCta.title}
{t.finalCta.sub}
{t.finalCta.book}
);
}
// ───────────────────────────────────────────────────────────────────────────
// FOOTER
function Footer({ t, lang, setLang }) {
return (
);
}
// ───────────────────────────────────────────────────────────────────────────
// STICKY MOBILE BOOK BUTTON
function StickyBook({ t }) {
return (
{t.nav.book}
→
);
}
// ───────────────────────────────────────────────────────────────────────────
// TWEAKS PANEL
function Tweaks({ tweaks, setTweak }) {
const { TweaksPanel, TweakSection, TweakColor, TweakRadio, TweakToggle, TweakSelect } = window;
if (!TweaksPanel) return null;
const accentPalettes = Object.entries(HUE_OPTIONS).map(([k, v]) => [v.c1, v.c2, v.c3]);
const accentKeys = Object.keys(HUE_OPTIONS);
return (
{
const i = accentPalettes.findIndex((p) => p[0] === palette[0]);
if (i >= 0) setTweak("accentHue", accentKeys[i]);
}}
/>
setTweak("density", v)}
/>
setTweak("heroLayout", v)}
/>
setTweak("heroBg", v)}
/>
setTweak("heroTreatment", v)}
/>
setTweak("heroComposition", v)}
/>
setTweak("showUsd", v)}
/>
setTweak("language", v)}
/>
);
}
// ───────────────────────────────────────────────────────────────────────────
// STATUS + POSTER (desktop) — paired editorial section under hero
function computeStatusDesktop(t) {
const now = new Date();
const day = now.getDay();
const mins = now.getHours() * 60 + now.getMinutes();
const isSun = day === 0;
const isOpen = !isSun && mins >= 540 && mins < 1200;
const closesSoon = isOpen && mins >= 1140;
const T = t.status;
let subtitle = "";
if (isOpen) subtitle = closesSoon ? T.closesSoon : T.closesAt;
else if (isSun) subtitle = T.opensMonday;
else subtitle = mins < 540 ? T.opensToday : T.opensTomorrow;
return { isOpen, label: isOpen ? T.open : T.closed, subtitle, hint: T.hint };
}
function StatusAndPoster({ t, lang }) {
const [s, setS] = useState(() => computeStatusDesktop(t));
useEffect(() => {
setS(computeStatusDesktop(t));
const id = setInterval(() => setS(computeStatusDesktop(t)), 60000);
return () => clearInterval(id);
}, [t]);
const p = t.poster;
return (
{p.since}
{p.year}
{p.headline}
);
}
// ───────────────────────────────────────────────────────────────────────────
// CASA — origin story prose (two-column on desktop)
function Casa({ t }) {
return (
{t.casa.p1}
{t.casa.p2}
{t.casa.p3}
);
}
// ───────────────────────────────────────────────────────────────────────────
// APP
function App() {
const [tweaks, setTweaksState] = useState(TWEAK_DEFAULTS);
const [lang, setLang] = useState(TWEAK_DEFAULTS.language);
const [scrolled, setScrolled] = useState(false);
const t = C[lang];
const setTweak = (key, val) => {
const next = typeof key === "object" ? { ...tweaks, ...key } : { ...tweaks, [key]: val };
setTweaksState(next);
try {
window.parent.postMessage({ type: "__edit_mode_set_keys", edits: typeof key === "object" ? key : { [key]: val } }, "*");
} catch (e) {}
};
useEffect(() => {
if (tweaks.language && tweaks.language !== lang) setLang(tweaks.language);
}, [tweaks.language]);
useEffect(() => {
const accent = HUE_OPTIONS[tweaks.accentHue] || HUE_OPTIONS.cyan;
document.documentElement.style.setProperty("--accent", accent.c1);
document.documentElement.style.setProperty("--accent-bright", accent.c2);
document.documentElement.style.setProperty("--accent-deep", accent.c3);
document.documentElement.setAttribute("data-density", tweaks.density);
}, [tweaks.accentHue, tweaks.density]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 32);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<>
{tweaks.heroLayout === "minimal" && }
>
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );