// app.jsx — Ms.Brow Salon · single-page editorial premium
// Default palette: neutral mist · type: mixed serif/sans (set in CSS :root)
const { useState, useEffect, useRef } = React;
const LOGO_SRC = "assets/logo.svg";
const LINE_ID = "@ms.brow";
const LINE_URL = "https://lin.ee/TKw4f1Z";
const IG_URL = "https://www.instagram.com/ms.brow_tw/";
// ─── DATA ────────────────────────────────────────────────────────
const DATA = {
lash: {
groups: [
{
id: "popular",
title: "人氣款式",
en: "Popular · Best Loved",
meta: "客人最常選擇的款式",
items: [
{ zh: "嬰兒彎系列", en: "Baby Curl", desc: "柔和弧度,眼神更溫柔", natural: 1599, dense: 1899 },
{ zh: "媽生感", en: "Born Natural", desc: "極細像天生自然的睫毛", natural: 1599, dense: 1899 },
{ zh: "LC 野生漫畫風", en: "LC Wild Manga", desc: "自然放大,同時帶一點個性感", natural: 1899, dense: 2099 },
{ zh: "超美狐系感", en: "Fox Style", desc: "上揚眼神,氣質拉長", natural: 1899, dense: 2099 },
],
},
{
id: "basic",
title: "日常基礎",
en: "Basic · Daily Essentials",
meta: "自然乾淨 · 日常首選",
items: [
{ zh: "日式單根系列", en: "Japanese · Single", desc: "自然微妝感,乾淨", natural: 999, dense: 1299 },
{ zh: "輕透少女風", en: "Sheer Girl", desc: "乾淨中帶一點精緻感", natural: 1599, dense: 1899 },
],
},
{
id: "style",
title: "風格設計",
en: "Style · By Mood",
meta: "依喜好挑選",
items: [
{ zh: "元氣少女風", en: "Vivid Girl", desc: "長短層次分明,自然放大的效果", natural: 1899, dense: 2199 },
{ zh: "泰式淡顏風", en: "Thai Soft Look", desc: "低存在感,但眼神很有神", natural: 1899, dense: 2199 },
{ zh: "小清新漫畫款", en: "Fresh Manga", desc: "清爽乾淨,持久度超好", natural: 1899, dense: 2199 },
{ zh: "網美睫毛膏款", en: "Mascara Look", desc: "像刷過睫毛膏一樣精緻", natural: 1599, dense: 1899 },
],
},
],
},
custom: {
eyebrow: "Custom Design",
title: "客製化系列",
bodyZh: "依眼型・氣質・日常妝感\n為您設計最適合的睫毛",
quote: "不是選款式\n而是找到適合妳的風格",
keywords: ["清冷風", "富家千金感", "狐系", "媽生感", "歐美風"],
closing: "最精緻的呈現",
},
lower: {
title: "下睫毛",
en: "Lower Lash · Add-on",
meta: "細節延伸 · 完整眼神比例",
items: [
{ zh: "30 根", note: "自然效果", price: 300 },
{ zh: "40 根", note: "妝感層次", price: 400 },
{ zh: "50 根", note: "濃妝感設計", price: 500 },
],
},
removal: {
title: "卸睫",
en: "Removal · In-Salon & External",
items: [
{ zh: "卸睫服務", note: "依狀況評估", price: 200, suffix: "起" },
],
},
semi: {
eyebrow: "Semi-Permanent",
title: "半永久設計",
sub: "自然修飾|長效精緻",
items: [
{
en: "Powder Brows",
tc: "霧眉",
sub: "柔霧邊界|自然立體",
body: "以柔霧邊界打造自然立體的眉型線條,於恰到好處的層次中保留呼吸感。",
price: 6999,
ann: "含一次補色",
},
{
en: "Lip Blush",
tc: "霧唇",
sub: "提升氣色|日常不素顏",
body: "依膚色調整專屬色,讓唇色從根本提升氣色\n呈現乾淨|柔和|自然紅潤",
price: 12800,
ann: "含一次補色",
},
],
},
policy: {
eyebrow: "Service Notes",
title: "消費介紹",
intro: "預約與服務說明",
chapters: [
{ eyebrow: "Booking", title: "預約流程", body: "01|加入官方 LINE 預約\n02|選擇款式或由客服協助評估\n03|確認時間,完成預約" },
{ eyebrow: "Service", title: "施作流程", body: "01|現場溝通設計(依眼型|氣質調整)\n02|進行嫁接(約 1.5 ~ 2 小時)\n03|完成後確認整體效果" },
{ eyebrow: "Aftercare", title: "施作後保養", body: "| 6 小時內避免碰水與蒸氣\n| 避免油性產品接觸睫毛\n| 勿自行拉扯或卸除\n| 建議 2 ~ 3 週回來補接" },
{ eyebrow: "Notice", title: "注意事項", body: "| 如有遲到,可能影響施作內容\n| 眼睛較敏感或特殊狀況,請提前告知\n| 若有任何不適,請立即與我們聯繫" },
],
closing: "每個人的眼型與條件不同\n我們會依當下狀況\n為妳調整最適合的設計",
},
member: {
eyebrow: "Inner Circle",
title: "會員規則",
sub: "以細膩之約,回饋每一次相見",
fee: 600,
benefits: [
{ title: "會員價優惠", body: "入會後,所有美睫服務皆享專屬會員價。" },
{ title: "集點兌換", body: "每次美睫款式消費可集一點,集滿六點即可兌換 NT$500 抵用券。" },
{ title: "專屬會員禮", body: "會員享預約時段優先排程,假日與熱門時段更從容。" },
{ title: "入會禮・生日禮", body: "入會當下即奉上一份小小心意;每年生辰之月,另有一份生日禮。" },
],
notes: [
"入會費 NT$600,會籍一年有效。",
"抵用券限本人使用,不得兌換現金。",
],
},
};
// ─── Brand mark (top of page; no easter egg here — moved to footer) ──
function Brand({ hideTagline }) {
return (
Ms.Brow Studio · Taipei
{!hideTagline && 以靜為禮 · 以細為意
}
);
}
// ─── Faint logo backdrop — fades in after brand scrolls off, ─────────
// fades out as footer approaches viewport
function LogoBackdrop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const update = () => {
const brand = document.querySelector(".brand");
const foot = document.querySelector(".foot");
const vh = window.innerHeight;
const brandBottom = brand ? brand.getBoundingClientRect().bottom : 0;
const footTop = foot ? foot.getBoundingClientRect().top : Infinity;
// Show once brand mostly scrolled off, hide as soon as footer is about to enter view
setVisible(brandBottom < 40 && footTop > vh * 0.9);
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, []);
return (
);
}
// ─── Reusable pieces ─────────────────────────────────────────────
function PageHead({ eyebrow, title, sub }) {
// For short titles (≤ 4 chars) spread with ideographic spaces for editorial feel.
// Longer titles (5+) skip the spread so they fit on a single line at 375px width.
const display = title.length <= 4 ? title.split("").join(" ") : title;
return (
{eyebrow &&
{eyebrow}
}
{display}
{sub &&
{sub}
}
);
}
function SectionHead({ title, en, meta }) {
return (
<>
{title}
{en && {en}
}
{meta && {meta}
}
>
);
}
function PriceAmount({ value, prefix = "NT$", suffix }) {
return (
{prefix}
{value.toLocaleString()}
{suffix && {suffix} }
);
}
function PriceTiers({ natural, dense }) {
return (
);
}
function Footer({ onUnlock }) {
const clicksRef = useRef([]);
const handle = () => {
const now = Date.now();
clicksRef.current = [...clicksRef.current, now].filter(t => now - t < 900);
if (clicksRef.current.length >= 3) {
clicksRef.current = [];
onUnlock();
}
};
return (
);
}
function IgIcon({ size = 28 }) {
return (
);
}
function LineCta({ note }) {
return (
);
}
// ═══════════════════════════════════════════════════════════════
// QUICK-JUMP (頂部章節索引列)
// ═══════════════════════════════════════════════════════════════
const JUMPS = [
{ id: "popular", label: "人氣款式" },
{ id: "basic", label: "日常基礎" },
{ id: "style", label: "風格設計" },
{ id: "custom", label: "客製化系列" },
{ id: "lower", label: "下睫毛" },
{ id: "removal", label: "卸睫" },
{ id: "semi", label: "半永久" },
{ id: "policy", label: "消費介紹" },
];
function QuickJump({ active, setActive }) {
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
if (visible.length > 0) {
const id = visible[0].target.id.replace("sec-", "");
setActive(id);
}
},
{ rootMargin: "-30% 0px -55% 0px", threshold: 0 }
);
JUMPS.forEach(it => {
const el = document.getElementById("sec-" + it.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
const goto = (id) => {
const el = document.getElementById("sec-" + id);
if (el) {
const rect = el.getBoundingClientRect();
const offset = 110;
window.scrollTo({ top: window.scrollY + rect.top - offset, behavior: "smooth" });
}
setActive(id);
};
return (
{JUMPS.map((it) => (
goto(it.id)}
>
{it.label}
))}
);
}
// ═══════════════════════════════════════════════════════════════
// PAGE · 服務總覽 (single-page)
// ═══════════════════════════════════════════════════════════════
function ServicePage({ onUnlock }) {
const dl = DATA.lash;
const ds = DATA.semi;
const dp = DATA.policy;
const [active, setActive] = useState("popular");
return (
價格均為新客體驗價
{dl.groups.map(g => (
))}
{/* CUSTOM block */}
{DATA.custom.eyebrow}
{DATA.custom.title}
{DATA.custom.bodyZh}
{DATA.custom.quote}
{DATA.custom.keywords.map(k => {k} )}
{DATA.custom.closing && {DATA.custom.closing}
}
{/* LOWER LASH */}
{/* REMOVAL */}
{/* ── SEMI-PERMANENT chapter ──────────────────────────────── */}
{ds.items.map((it, i) => (
{it.en}
{it.tc.split("").join(" ")}
{it.sub}
{it.body}
))}
{/* ── POLICY chapter ──────────────────────────────────────── */}
{dp.intro}
{dp.chapters.map((ch, i) => (
{String(i + 1).padStart(2, "0")}
{ch.eyebrow}
{ch.title}
{ch.body}
))}
);
}
// ═══════════════════════════════════════════════════════════════
// PAGE · 會員規則 (hidden — unlocked via triple-click)
// ═══════════════════════════════════════════════════════════════
function MemberPage({ onBack, onUnlock }) {
const d = DATA.member;
return (
會員權益
{d.benefits.map((b, i) => (
{String(i + 1).padStart(2, "0")}
{b.title}
{b.body}
))}
{d.notes.map((n, i) => (
{String(i + 1).padStart(2, "0")}
{n}
))}
返回首頁
);
}
// ═══════════════════════════════════════════════════════════════
// APP
// ═══════════════════════════════════════════════════════════════
// URL hash routing — share /#member to land directly on the member page
const HASH_MEMBER = "member";
const readPageFromHash = () =>
window.location.hash.replace(/^#\/?/, "") === HASH_MEMBER ? "member" : "service";
// Apply ?theme= to . Default (no param) = A version.
// ?theme=snow → B version (cool snow palette)
// ?theme=bubbel → C version (warm cream/champagne palette)
const THEMES = new Set(["snow", "bubbel"]);
const applyThemeFromUrl = () => {
const theme = new URLSearchParams(window.location.search).get("theme");
const root = document.documentElement;
if (theme && THEMES.has(theme)) root.setAttribute("data-theme", theme);
else root.removeAttribute("data-theme");
};
applyThemeFromUrl();
function App() {
const [page, setPage] = useState(readPageFromHash);
const [toastOn, setToastOn] = useState(false);
const [toastText, setToastText] = useState("");
// Snap to top whenever the page swaps (member ↔ service).
// Runs after React commits the new DOM, so document height has already
// shrunk to the new page's size — clamping issues are avoided.
useEffect(() => {
window.scrollTo({ top: 0, behavior: "instant" });
}, [page]);
// Keep URL hash in sync with page state so the member page is shareable.
useEffect(() => {
const want = page === "member" ? "#" + HASH_MEMBER : "";
if (window.location.hash !== want) {
const base = window.location.pathname + window.location.search;
window.history.replaceState(null, "", want ? base + want : base);
}
}, [page]);
// React to browser back/forward (or manual hash edits).
useEffect(() => {
const onHashChange = () => setPage(readPageFromHash());
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const showToast = (txt) => {
setToastText(txt);
setToastOn(true);
setTimeout(() => setToastOn(false), 2600);
};
const onUnlock = () => {
if (page === "member") return;
showToast("Inner Circle · Unlocked");
setTimeout(() => {
setPage("member");
}, 700);
};
return (
{page === "member"
? setPage("service")} />
: }
{toastText}
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render( );