// 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

Ms.Brow
{!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 (
LINE 預約 instagram {LINE_ID} {note || "By Appointment Only"}
); } // ═══════════════════════════════════════════════════════════════ // 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 ( ); } // ═══════════════════════════════════════════════════════════════ // 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 => (
    {g.items.map((it) => (
  • {it.zh} {it.en && {it.en}} {it.desc && {it.desc}}
  • ))}
))} {/* 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 */}
    {DATA.lower.items.map(it => (
  • {it.zh} {it.note && {it.note}}
  • ))}
{/* REMOVAL */}
    {DATA.removal.items.map((it, i) => (
  • {it.zh} {it.note && {it.note}}
  • ))}
{/* ── SEMI-PERMANENT chapter ──────────────────────────────── */}
{ds.items.map((it, i) => (

{it.en}

{it.tc.split("").join(" ")}

{it.sub}

{it.body}

{it.ann}
))} {/* ── 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 (

入會費

NT$ {d.fee}

會員權益

    {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();