// Mr. Hair Club — Mobile React App
// Mobile-first. Designed for 360-430px viewport widths.
// Renders inside a phone shell on desktop; full-screen on real phones.
const { useState, useEffect, useRef, useMemo } = 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;
const REVIEWS = window.REVIEWS;
// ─────────────────────────────────────────────────────────────
// PRIMITIVES
function BrandLogo({ size = 1 }) {
return (
MR.
HAIR
club
);
}
function BookLink({ children, secondary, small, block, className, onClick }) {
const cls = ["btn", secondary ? "btn-ghost" : "btn-primary", small ? "btn-sm" : "", block ? "btn-block" : "", className || ""].filter(Boolean).join(" ");
return (
{children}
→
);
}
function SectionHead({ kicker, title, sub }) {
return (
{kicker}
{title}
{sub &&
{sub}
}
);
}
// ─────────────────────────────────────────────────────────────
// HEADER + DRAWER
function Header({ scrolled, onOpenMenu, lang, setLang }) {
return (
);
}
function LangPill({ lang, setLang }) {
return (
{["pt", "en", "es"].map((l) => (
setLang(l)}
aria-pressed={lang === l}
>
{l.toUpperCase()}
))}
);
}
function Drawer({ open, onClose, t, scrollTo }) {
if (!open) return null;
const links = [
["services", t.nav.services],
["team", t.nav.team],
["work", t.nav.work],
["club", t.nav.club],
["visit", t.nav.visit],
];
return (
);
}
// ─────────────────────────────────────────────────────────────
// HERO — text-forward, photo strip below
function Hero({ t, lang }) {
return (
Copacabana · RJ · desde 2011
{t.hero.title1}
{t.hero.title2}
{t.hero.title3}
{t.hero.sub}
★
4.9
·
{fmtNum(1512, lang)} {t.authority.reviews}
·
Google
);
}
function HeroPhotoStrip({ lang }) {
const photos = [
{
src: "assets/work-real-01.webp",
caption: lang === "pt" ? "Allan, cliente desde 2022" : lang === "es" ? "Allan, cliente desde 2022" : "Allan, regular since 2022",
},
{
src: "assets/work-real-02.webp",
caption: lang === "pt" ? "Equipe · joia da casa" : lang === "es" ? "Equipo · orgullo de la casa" : "The team, in their element",
},
{
src: "assets/storefront.jpg",
caption: lang === "pt" ? "Rua Djalma Ulrich, 163" : lang === "es" ? "Calle Djalma Ulrich, 163" : "Djalma Ulrich, 163",
},
];
return (
{photos.map((p, i) => (
{p.caption}
))}
);
}
function HeroStats({ t, lang }) {
const totalCuts = TEAM.reduce((s, b) => s + b.cuts, 0);
const items = [
{ big: "4.9★", small: "Google" },
{ big: fmtNum(1512, lang), small: t.authority.reviews },
{ big: fmtNum(Math.round(totalCuts / 1000)) + "k+", small: t.authority.cuts.split(" ")[0] },
{ big: "15", small: t.authority.years.split(" ")[0] },
];
return (
{items.map((it, i) => (
))}
);
}
// ─────────────────────────────────────────────────────────────
// STATUS CARD — replaces redundant quick actions; shows live open/closed
function computeStatus(lang) {
// Hours: Mon-Sat 9-20, Sun closed (Rio time approx)
const now = new Date();
const day = now.getDay(); // 0 Sun, 6 Sat
const mins = now.getHours() * 60 + now.getMinutes();
const openMin = 9 * 60;
const closeMin = 20 * 60;
const isSun = day === 0;
const isOpen = !isSun && mins >= openMin && mins < closeMin;
const closesSoon = isOpen && mins >= closeMin - 60;
const T = {
pt: {
open: "Aberto agora",
closed: "Fechado",
closesAt: "fecha às 20h",
closesSoon: "fecha em breve",
reopens: "abre amanhã às 9h",
reopensMon: "abre segunda às 9h",
hint: "Walk-ins aceitos quando há cadeira livre",
},
en: {
open: "Open now",
closed: "Closed",
closesAt: "closes at 8pm",
closesSoon: "closing soon",
reopens: "opens tomorrow at 9am",
reopensMon: "opens Monday at 9am",
hint: "Walk-ins welcome when chairs are free",
},
es: {
open: "Abierto ahora",
closed: "Cerrado",
closesAt: "cierra a las 20h",
closesSoon: "cierra pronto",
reopens: "abre mañana a las 9h",
reopensMon: "abre el lunes a las 9h",
hint: "Sin cita según disponibilidad",
},
}[lang];
let subtitle = "";
if (isOpen) subtitle = closesSoon ? T.closesSoon : T.closesAt;
else if (isSun) subtitle = T.reopensMon;
else subtitle = mins < openMin ? (lang === "pt" ? "abre às 9h" : lang === "es" ? "abre a las 9h" : "opens at 9am") : T.reopens;
return { isOpen, label: isOpen ? T.open : T.closed, subtitle, hint: T.hint };
}
function StatusCard({ lang }) {
const [s, setS] = useState(() => computeStatus(lang));
useEffect(() => {
setS(computeStatus(lang));
const id = setInterval(() => setS(computeStatus(lang)), 60000);
return () => clearInterval(id);
}, [lang]);
const callTxt = lang === "pt" ? "Ligar" : lang === "es" ? "Llamar" : "Call";
return (
{s.label}
{s.subtitle} · {s.hint}
);
}
// ─────────────────────────────────────────────────────────────
// WHY
function Why({ t }) {
return (
{t.why.items.map((it, i) => (
))}
);
}
// ─────────────────────────────────────────────────────────────
// SERVICES — collapsible accordion with sticky group nav
function Services({ t, lang, showUsd = true }) {
const groups = t.services.groups;
// Track which groups are open. First open by default.
const [open, setOpen] = useState(() => groups.map((_, i) => i === 0));
const refs = useRef([]);
const toggle = (i) => {
setOpen((prev) => prev.map((v, j) => (j === i ? !v : v)));
};
const scrollToGroup = (i) => {
setOpen((prev) => prev.map((v, j) => (j === i ? true : v)));
requestAnimationFrame(() => {
const el = refs.current[i];
if (el) {
const scroller = document.getElementById("phone-scroll");
if (scroller) {
scroller.scrollTo({ top: el.offsetTop - 110, behavior: "smooth" });
}
}
});
};
return (
{groups.map((g, i) => (
scrollToGroup(i)}
>
{g.name}
))}
{groups.map((g, gi) => (
(refs.current[gi] = el)}>
toggle(gi)}
aria-expanded={open[gi]}
>
0{gi + 1}
{g.name}
{g.items.length}
{open[gi] && (
)}
))}
);
}
// ─────────────────────────────────────────────────────────────
// TEAM — horizontal carousel
function Team({ t, lang }) {
const totalCuts = TEAM.reduce((s, b) => s + b.cuts, 0);
const scrollRef = useRef(null);
const [idx, setIdx] = useState(0);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
const cardWidth = el.firstChild?.offsetWidth || 1;
const gap = 14;
const i = Math.round(el.scrollLeft / (cardWidth + gap));
setIdx(Math.min(i, TEAM.length - 1));
};
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) => (
))}
{TEAM.map((_, i) => (
))}
);
}
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
function Work({ t }) {
const tags = ["all", "cuts", "beards", "interior", "before"];
const [filter, setFilter] = useState("all");
// Mobile bento: 2 cols, first cell spans 2 cols (hero), then alternating spans
const spans = [
"span-2-col", // big hero
"",
"span-2-row", // tall
"",
"",
"span-2-col", // wide
"",
"",
];
return (
{tags.map((tag) => (
setFilter(tag)}
>
{t.work.tags[tag]}
))}
{t.work.slots.slice(0, 8).map((s, i) => {
const hide = filter !== "all" && s.tag !== filter;
const realSrc = window.WORK_PHOTOS && window.WORK_PHOTOS[s.id];
return (
);
})}
);
}
// ─────────────────────────────────────────────────────────────
// CLUB
function Club({ t, lang }) {
const [tab, setTab] = useState("flex");
const plans = tab === "flex" ? t.club.flex : t.club.essencial;
return (
setTab("flex")}
>
FLEX
R$ 109+
setTab("essencial")}
>
ESSENCIAL
R$ 129+
{plans.map((p, i) => (
{p.hot &&
{t.club.most}
}
{p.name}
R$
{p.price}
{t.club.monthly}
{t.club.includes}
{p.inc.map((it, j) => (
✓ {it}
))}
{p.save &&
{p.save}
}
{t.club.subscribe}
→
))}
{t.club.compare}
{t.club.note}
);
}
// ─────────────────────────────────────────────────────────────
// REVIEWS
function Reviews({ t, lang }) {
const items = (REVIEWS[lang] || []).filter((r) => !r.placeholder);
// Limit to 6 cards on mobile
const limit = items.slice(0, 6);
return (
{limit.map((r, i) => (
))}
);
}
function ReviewCard({ r, lang }) {
const [expanded, setExpanded] = useState(false);
const moreLabel = lang === "pt" ? "ler mais" : lang === "es" ? "leer más" : "read more";
const lessLabel = lang === "pt" ? "recolher" : lang === "es" ? "contraer" : "less";
const long = r.text.length > 180;
const beforeLabel = lang === "en" ? "Before" : lang === "es" ? "Antes" : "Antes";
const afterLabel = lang === "en" ? "After" : lang === "es" ? "Después" : "Depois";
let photoEl = null;
if (r.combinedBeforeAfter && r.photo) {
photoEl = (
{beforeLabel}
{afterLabel}
);
} else if (r.photos && r.photos.length > 0) {
photoEl = (
{r.beforeAfter &&
{beforeLabel} }
);
} else if (r.photo) {
photoEl = (
);
}
return (
{photoEl}
★★★★★
"{r.text}"
{long && (
setExpanded((v) => !v)}>
{expanded ? lessLabel : moreLabel}
)}
{r.name}
·
{r.date}
);
}
// ─────────────────────────────────────────────────────────────
// VISIT
function Visit({ t, lang }) {
const openLabel = lang === "pt" ? "Abrir no Google Maps" : lang === "es" ? "Abrir en Google Maps" : "Open in Google Maps";
return (
);
}
// ─────────────────────────────────────────────────────────────
// FINAL CTA + FOOTER
function FinalCta({ t }) {
return (
{t.finalCta.title}
{t.finalCta.sub}
{t.finalCta.book}
);
}
function Footer({ t, lang, setLang }) {
return (
© 2026 Mr. Hair Club
{t.footer.rights}
);
}
// ─────────────────────────────────────────────────────────────
// BOTTOM TAB BAR (sticky)
function TabBar({ t, lang }) {
return (
{lang === "pt" ? "Ligar" : lang === "es" ? "Llamar" : "Call"}
WhatsApp
{t.nav.book}
{lang === "pt" ? "Rota" : lang === "es" ? "Ruta" : "Route"}
);
}
// ─────────────────────────────────────────────────────────────
// APP
function App() {
const [lang, setLang] = useState("pt");
const [scrolled, setScrolled] = useState(false);
const [progress, setProgress] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
const scrollerRef = useRef(null);
const t = C[lang];
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onScroll = () => {
setScrolled(el.scrollTop > 32);
const max = el.scrollHeight - el.clientHeight;
setProgress(max > 0 ? Math.min(1, el.scrollTop / max) : 0);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
// Lock body scroll when drawer is open (real mobile)
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
if (drawerOpen) el.style.overflow = "hidden";
else el.style.overflow = "";
}, [drawerOpen]);
const scrollTo = (id) => {
const el = document.getElementById(id);
const scroller = scrollerRef.current;
if (el && scroller) {
scroller.scrollTo({ top: el.offsetTop - 50, behavior: "smooth" });
}
};
return (
<>
setDrawerOpen(false)} t={t} scrollTo={scrollTo} />
>
);
}
ReactDOM.createRoot(document.getElementById("phone-mount")).render( );