/* TOMEYLO product catalog — Pinterest-style masonry */ const { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } = React; const t = (window.TMOI18N && window.TMOI18N.t) || ((s) => s); const tf = (window.TMOI18N && window.TMOI18N.tf) || ((s) => s); /* ---------- helpers ---------- */ const usePersisted = (key, initial) => { const [v, setV] = useState(() => { try { const s = localStorage.getItem(key); return s ? JSON.parse(s) : initial; } catch { return initial; } }); useEffect(() => { try { localStorage.setItem(key, JSON.stringify(v)); } catch {} }, [key, v]); return [v, setV]; }; /* Product image. - thumb: cards load images/thumb/.jpg (480px). On error, fall back to the full-res original, then to a code placeholder. - no thumb (lightbox): loads the full-res original directly — zoom stays sharp. IMGDIMS (image-dims.js) gives each image's pixel size so the card reserves the correct height before the image loads → no masonry re-layout jump. */ const IMGDIMS = (typeof window !== 'undefined' && window.IMGDIMS) || {}; const ProductImage = ({ code, alt, onLoad, thumb, load = true }) => { // stage 0 = thumb (if thumb requested), 1 = original, 2 = fallback placeholder const [stage, setStage] = useState(thumb ? 0 : 1); useEffect(() => { setStage(thumb ? 0 : 1); }, [code, thumb]); if (stage >= 2) { return (
{code}
); } const dim = IMGDIMS[code]; // width/height attributes give the an intrinsic size, so its height is // reserved from the aspect ratio even before (or without) a src — the masonry // then measures the correct card height without loading any image. const wh = thumb && dim ? { width: dim[0], height: dim[1] } : {}; // `load` gates the network request: only cards scrolled into view fetch their image. const src = load ? (stage === 0 ? `images/thumb/${code}.jpg` : `images/${code}.jpg`) : undefined; return ( {alt} setStage(s => s + 1)} /> ); }; /* Scroll reveal: once a card becomes active (near the viewport, decided by the masonry's deterministic windowing) it stays revealed — so its image loads once and the fade-in plays once, without re-animating or unloading as you scroll past. */ const useSeen = (active) => { const [seen, setSeen] = useState(false); useEffect(() => { if (active && !seen) setSeen(true); }, [active, seen]); return seen; }; /* ---------- Barcode SVG (proper EAN-13) ---------- */ const BarcodeSVG = ({ value, height = 28 }) => { // EAN-13 encoding tables // L-code (left, odd parity), G-code (left, even parity), R-code (right) const L = ['0001101','0011001','0010011','0111101','0100011','0110001','0101111','0111011','0110111','0001011']; const G = ['0100111','0110011','0011011','0100001','0011101','0111001','0000101','0010001','0001001','0010111']; const R = ['1110010','1100110','1101100','1000010','1011100','1001110','1010000','1000100','1001000','1110100']; // Parity pattern based on first digit (decides L/G mix for digits 2-7) const PARITY = ['LLLLLL','LLGLGG','LLGGLG','LLGGGL','LGLLGG','LGGLLG','LGGGLL','LGLGLG','LGLGGL','LGGLGL']; const bits = useMemo(() => { let raw = String(value).replace(/\D/g, ''); // Pad / truncate to 12, then compute checksum for 13th digit. if (raw.length < 12) raw = raw.padStart(12, '0'); if (raw.length > 13) raw = raw.slice(0, 13); if (raw.length === 12) { // calc check digit let sum = 0; for (let i = 0; i < 12; i++) sum += parseInt(raw[i],10) * (i % 2 === 0 ? 1 : 3); const check = (10 - (sum % 10)) % 10; raw = raw + check; } const digits = raw.split('').map(d => parseInt(d, 10)); const first = digits[0]; const parity = PARITY[first]; let out = '101'; // start guard for (let i = 1; i <= 6; i++) { out += parity[i-1] === 'L' ? L[digits[i]] : G[digits[i]]; } out += '01010'; // center guard for (let i = 7; i <= 12; i++) { out += R[digits[i]]; } out += '101'; // end guard return out; }, [value]); const totalModules = bits.length; // 95 return ( {bits.split('').map((b, i) => b === '1' ? : null )} ); }; /* ---------- Banner Card (group of products sharing one image) ---------- */ const BannerCard = ({ p, showBarcode, onOpen, favs, onToggleFav, cols, gap, active }) => { const imgKey = p.imageKey || p.anchorCode || (p.members && p.members[0]?.code); const groupAlt = `${t(p.title || '')} ${t('系列')} ${tf('共 {n} 件',{n:p.members.length})}`; const bannerCols = Math.min(Math.max(cols || 4, 4), 6); const bannerStyle = { '--banner-cols': bannerCols, '--banner-gap': (gap || 14) + 'px' }; const seen = useSeen(active); return (
{t(p.title)}
{tf('共 {n} 件',{n:p.members.length})}
    {p.members.map((m, i) => { const fav = favs && favs.includes(m.code); return (
  • {m.code}
    {showBarcode && }
    {m.barcode}
    {m.name}
  • ); })}
); }; /* ---------- Card ---------- */ const ProductCard = ({ p, showBarcode, onOpen, isFav, onToggleFav, active }) => { const imgKey = p.imageKey || p.code; const seen = useSeen(active); return (
{p.code}
{showBarcode && }
{p.barcode}
{ p.name.split(/(\([^)]*\))/g).map((part, i) => { if (part.includes('紅')) return {part}; if (part.includes('藍')) return {part}; if (part.includes('黑')) return {part}; return part; }) }
); }; /* ---------- Lightbox ---------- */ const Lightbox = ({ p, onClose, onCopy, copiedField, favs, onToggleFav }) => { useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = ''; }; }, [onClose]); if (!p) return null; const isGroup = p.kind === 'group'; const seriesLetter = isGroup ? (p.anchorCode ? p.anchorCode[0] : (p.members && p.members[0]?.code[0])) : p.code[0]; return (
e.stopPropagation()}>
{isGroup ? ( <>

{t(p.title)}

{tf('共 {n} 件',{n:p.members.length})}
    {p.members.map(m => { const fav = favs && favs.includes(m.code); return (
  • {m.code}
    {m.name}
    {m.barcode}
  • ); })}
{t('系列')} {seriesLetter}
) : ( <>
{p.code}

{p.name}

{p.barcode}
{t('系列')} {seriesLetter}
)}
); }; /* ---------- Masonry (row-major, JS-positioned) ---------- */ const Masonry = ({ items, cols, gap, render }) => { const containerRef = useRef(null); const itemRefs = useRef(new Map()); const [layout, setLayout] = useState({ positions: [], height: 0 }); // Determine actual column count based on viewport const computeCols = useCallback(() => { const w = window.innerWidth; if (w <= 540) return 2; if (w <= 760) return 3; if (w <= 980) return 4; if (w <= 1200) return 5; if (w <= 1480) return Math.min(cols, 6); return cols; }, [cols]); const doLayout = useCallback(() => { const container = containerRef.current; if (!container) return; const W = container.clientWidth; const numCols = computeCols(); const colWidth = (W - gap * (numCols - 1)) / numCols; const colTops = new Array(numCols).fill(0); const positions = []; // Compute each item's span (clamped to numCols) // Banner cards: full width across all columns. const spanOf = (p) => { if (p.kind === 'group') return numCols; if (p.size === 'medium') return Math.min(2, numCols); // medium = 2 column units return 1; }; items.forEach((p, i) => { const span = spanOf(p); const k = `${p.code}-${p.seq}`; const el = itemRefs.current.get(k); const h = el ? el.offsetHeight : 0; // Find leftmost starting column where this span fits, with min(max) of top among those columns let bestCol = 0; let bestY = Infinity; for (let c = 0; c + span <= numCols; c++) { let maxTop = 0; for (let s = 0; s < span; s++) maxTop = Math.max(maxTop, colTops[c + s]); if (maxTop < bestY) { bestY = maxTop; bestCol = c; } } const x = bestCol * (colWidth + gap); const w = colWidth * span + gap * (span - 1); const y = bestY; positions.push({ key: k, x, y, w, h }); for (let s = 0; s < span; s++) colTops[bestCol + s] = y + h + gap; }); const height = Math.max(0, ...colTops) - gap; setLayout({ positions, height }); }, [items, gap, computeCols]); // Position synchronously before paint so cards never flash stacked at (0,0). // That stacked frame would otherwise make every lazy image + reveal observer // think all cards are in-viewport (defeating windowed loading and scroll reveal). useLayoutEffect(() => { doLayout(); }, [doLayout]); useEffect(() => { const onResize = () => doLayout(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [doLayout]); // Watch each card for size changes (image loads etc.) useEffect(() => { const ro = new ResizeObserver(() => doLayout()); itemRefs.current.forEach(el => el && ro.observe(el)); return () => ro.disconnect(); }, [items, doLayout]); // Deterministic windowing: track the scroll viewport (in masonry-local coords) and // only activate cards whose vertical band is near it. Active cards fetch their image // and fade in; far-off cards stay as reserved-height placeholders (no image request). const [win, setWin] = useState({ top: -1, bottom: -1 }); useEffect(() => { let raf = 0; const update = () => { raf = 0; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const top = -rect.top; // scroll offset into the masonry const bottom = top + window.innerHeight; setWin(prev => (Math.abs(prev.top - top) < 40 && Math.abs(prev.bottom - bottom) < 40) ? prev : { top, bottom }); }; const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); }; update(); window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); cancelAnimationFrame(raf); }; }, [layout.height]); const posByCode = new Map(layout.positions.map(p => [p.key, p])); const MARGIN = 700; // px above/below the viewport that count as "near" return (
{(() => { const _renderCols = computeCols(); return items.map(p => { const k = `${p.code}-${p.seq}`; const pos = posByCode.get(k); const style = pos ? { transform: `translate(${pos.x}px, ${pos.y}px)`, width: pos.w + 'px' } : { visibility: 'hidden', width: '100%' }; const active = !!pos && win.bottom >= 0 && pos.y < win.bottom + MARGIN && pos.y + pos.h > win.top - MARGIN; return (
{ if (el) itemRefs.current.set(k, el); else itemRefs.current.delete(k); }} style={style} > {render(p, { numCols: _renderCols, gap, active })}
); }); })()}
); }; /* ---------- Main App ---------- */ const App = () => { const TWEAKS_DEFAULTS = /*EDITMODE-BEGIN*/{ "columnsDesktop": 6, "showBarcodeOnCard": true, "darkMode": false, "density": "comfortable", "wireframe": false }/*EDITMODE-END*/; const [tweaks, setTweaks] = (window.useTweaks || ((d)=>useState(d)))(TWEAKS_DEFAULTS); const setTweak = (k, v) => { if (typeof k === 'object') setTweaks(prev => ({...prev, ...k})); else setTweaks(prev => ({...prev, [k]: v})); }; const seriesKeys = Object.keys(window.PRODUCTS || {}).sort(); const initialSeries = (() => { const q = new URLSearchParams(window.location.search).get('series'); if (q && seriesKeys.includes(q.toUpperCase())) return q.toUpperCase(); return seriesKeys[0] || 'A'; })(); const [series, setSeries] = useState(initialSeries); const initialQuery = (() => { try { return new URLSearchParams(window.location.search).get('q') || ''; } catch(e){ return ''; } })(); const [query, setQuery] = useState(initialQuery); const [sortMode, setSortMode] = useState('type'); // default to curated 同類型 order const [favs, setFavs] = usePersisted('tomeylo:favs', []); const [showFavsOnly, setShowFavsOnly] = useState(false); const [open, setOpen] = useState(null); const [copied, setCopied] = useState(null); const [scrolled, setScrolled] = useState(false); const [collapsed, setCollapsed] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [bbSearchOpen, setBbSearchOpen] = useState(false); const [openRow, setOpenRow] = useState(null); const SERIES_TITLES = {A:'混合龍頭',B:'自由栓龍頭',C:'水塞 / 龍頭配件',D:'起泡頭',E:'進排水配件',F:'管用配件',G:'洗衣機配件',H:'瓦斯配件',I:'淨水濾心',J:'淨水器配件',K:'衛浴間配件',L:'淋浴配件',M:'水箱水塔另件',N:'面盆 / 水槽配件',O:'浴室鏡',P:'管束夾 / 園藝配件'}; const MENU = window.TMOMENU || {INSTALL:[],ABOUT:[],CONTACT:[],SOCIAL:[]}; const INSTALL_ITEMS = (MENU.INSTALL||[]); const toggleRow = (k) => setOpenRow(r => r===k ? null : k); // Apply dark mode useEffect(() => { document.documentElement.dataset.theme = tweaks.darkMode ? 'dark' : 'light'; }, [tweaks.darkMode]); // Apply wireframe (線稿) mode — strips color & images so layout is the focus useEffect(() => { document.documentElement.dataset.wireframe = tweaks.wireframe ? 'on' : 'off'; }, [tweaks.wireframe]); // Apply series accent color useEffect(() => { document.documentElement.dataset.series = series; }, [series]); useEffect(() => { let lastY = window.scrollY; let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const y = window.scrollY; setScrolled(y > 4); const delta = y - lastY; if (y < 60) { setCollapsed(false); } else if (delta > 4) { setCollapsed(true); } else if (delta < -4) { setCollapsed(false); } lastY = y; ticking = false; }); }; window.addEventListener('scroll', onScroll, {passive: true}); return () => window.removeEventListener('scroll', onScroll); }, []); // Keep the bottom bar's search input above the on-screen keyboard. // Mobile browsers don't move a fixed;bottom:0 bar above the software keyboard, // so while the search is open we lift the bar by the keyboard height (measured // via visualViewport) — otherwise the user can't see what they type. useEffect(() => { const vv = window.visualViewport; if (!vv) return; const bar = document.querySelector('.botbar'); if (!bar) return; const update = () => { const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); if (bbSearchOpen && inset > 40) { bar.style.bottom = inset + 'px'; } else { bar.style.bottom = ''; } }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); update(); return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); bar.style.bottom = ''; }; }, [bbSearchOpen]); // Footer accordion (mobile) — matches homepage [data-acc] behavior useEffect(() => { const handler = (e) => { const btn = e.target.closest('button'); if (btn && btn.parentElement && btn.parentElement.classList.contains('ft-row')) { btn.parentElement.classList.toggle('open'); } }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, []); const data = window.PRODUCTS; // Per-series type keywords. Each series only uses its OWN keyword list, // so a D-series product can never be mis-grouped under an A/B keyword (e.g. "浴缸", "長栓"). // Order within each list defines display order — first match wins. const TYPE_PATTERNS_BY_SERIES = { A: [ ['淋浴龍頭', '淋浴龍頭'], ['沐浴龍頭', '沐浴龍頭'], ['蓮蓬', '蓮蓬龍頭'], ['壁式', '壁式龍頭'], ['立式', '立式龍頭'], ['台盆', '台盆龍頭'], ['浴缸', '浴缸龍頭'], ['廚房', '廚房龍頭'], ['長栓', '長栓'], ['短栓', '短栓'], ['過濾', '過濾龍頭'], ['伸縮', '伸縮龍頭'], ['冷熱|混合', '混合龍頭'], ['水塞', '水塞配件'], ['軟管|蛇管', '軟管配件'], ['花灑|蓮蓬頭', '花灑配件'], ['配件|螺絲|零件', '配件零件'], ], D: [ ['濾泡頭|起泡器|起泡頭|起波器|起波頭|省水閥|省水|分流器|節水器|外牙接頭', '起泡頭'], ['過濾', '過濾器'], ['珠鏈|珠鍊|鏈條|鍊條', '珠鏈條'], ['墊圈', '墊圈'], ['延伸器', '延伸器'], ['飲水機', '飲水機'], ], F: [ ['內外牙', '內外牙'], ['外牙', '外牙'], ['OS內牙', 'OS內牙'], ['內牙', '內牙'], ['凡而', '凡而'], ['四角中間', '四角中間'], ['ST銅珠閥', 'ST銅珠閥'], ['球塞ST', '球塞ST'], ['銅球塞', '銅球塞'], ['接頭', '接頭'], ['OS', 'OS'], ['OL', 'OL'], ['OP', 'OP'], ['OT', 'OT'], ['WS', 'WS'], ['逆止閥', '逆止閥'], ['逆福特', '逆福特'], ['南亞B管|南亞|B管', '南亞B管'], ['由任', '由任'], ['立布', '立布'], ['管帽', '管帽'], ['三通', '三通'], ['牙塞', '牙塞'], ['彎頭', '彎頭'], ], G: [ ['快速珠鏈|快速珠鍊', '快速珠鏈'], ['快速螺絲', '快速螺絲'], ['專利洗衣機進水管', '專利洗衣機進水管'], ['珠鏈洗衣機|珠鍊洗衣機', '珠鏈洗衣機'], ['螺絲洗衣機', '螺絲洗衣機'], ['濾網', '濾網'], ], }; // B and C reuse A's keyword set by default. TYPE_PATTERNS_BY_SERIES.B = TYPE_PATTERNS_BY_SERIES.A; TYPE_PATTERNS_BY_SERIES.C = TYPE_PATTERNS_BY_SERIES.A; const getPatternsFor = (s) => TYPE_PATTERNS_BY_SERIES[s] || TYPE_PATTERNS_BY_SERIES.A; const getTypeIndex = (name, s) => { const pats = getPatternsFor(s); if (!name) return pats.length; for (let i = 0; i < pats.length; i++) { if (new RegExp(pats[i][0]).test(name)) return i; } return pats.length; }; const getType = (name, s) => { const pats = getPatternsFor(s); const idx = getTypeIndex(name, s); return idx === pats.length ? '其他' : pats[idx][1]; }; const items = useMemo(() => { const searching = !!query.trim(); let list = searching ? Object.keys(data).flatMap(k => (data[k].items || [])) : (data[series]?.items || []).slice(); if (!searching && sortMode === 'code') { list.sort((a,b) => { const ca = a.anchorCode || a.code; const cb = b.anchorCode || b.code; return ca.localeCompare(cb); }); } else if (!searching && sortMode === 'type') { // Series with an explicit, user-defined 「依同類型產品」 order. // When present, use it verbatim (by anchorCode/code) and skip the keyword algorithm. const MANUAL_TYPE_ORDER = { A: ['A001','A064','A004','A011','A020','A021','A060','A079','A098','A099','A094','A012','A013','A037','A065','A014','A018','A015','A019','A016','A068','A087','A066','A078','A097'], }; if (MANUAL_TYPE_ORDER[series]) { const ord = MANUAL_TYPE_ORDER[series]; const rank = new Map(ord.map((c,i)=>[c,i])); const keyOf = (p) => p.anchorCode || p.code; list.sort((a,b) => { const ra = rank.has(keyOf(a)) ? rank.get(keyOf(a)) : 9999; const rb = rank.has(keyOf(b)) ? rank.get(keyOf(b)) : 9999; if (ra !== rb) return ra - rb; return keyOf(a).localeCompare(keyOf(b)); }); } else { // codes that should always sink to the very end of the type-grouped list const TAIL_CODES = new Set(['E009','E008','E003','E322','E336','E337']); // codes that must appear consecutively, in the order listed const CLUSTERS = [ ['E281','E285','E286'], ]; const clusterOrder = new Map(); // code -> [clusterIdx, posInCluster] CLUSTERS.forEach((arr, ci) => arr.forEach((c, pi) => clusterOrder.set(c, [ci, pi]))); // anchor each cluster to the type-index of its FIRST item (so the cluster lives in that group) const clusterAnchorTypeIdx = CLUSTERS.map(arr => { const first = (data[series]?.items || []).find(x => x.code === arr[0]); return first ? getTypeIndex(first.name, series) : 999; }); // group by type using TYPE_PATTERNS list order, then by code within each group list.sort((a,b) => { // Card-size rank: big card (group) → medium card → individual card. const sizeRank = (p) => p.kind === 'group' ? 0 : (p.size === 'medium' ? 1 : 2); const sa = sizeRank(a), sb = sizeRank(b); if (sa !== sb) return sa - sb; // L series: names containing 「組」 float to the very front of the type list. if (series === 'L') { const za = (a.name||'').includes('組') ? 0 : 1; const zb = (b.name||'').includes('組') ? 0 : 1; if (za !== zb) return za - zb; } const ta = TAIL_CODES.has(a.code) ? 1 : 0; const tb = TAIL_CODES.has(b.code) ? 1 : 0; if (ta !== tb) return ta - tb; const ca = clusterOrder.get(a.code); const cb = clusterOrder.get(b.code); const ia = ca ? clusterAnchorTypeIdx[ca[0]] : getTypeIndex(a.name, series); const ib = cb ? clusterAnchorTypeIdx[cb[0]] : getTypeIndex(b.name, series); if (ia !== ib) return ia - ib; // within same type group: cluster members sort by clusterIdx then posInCluster, before non-cluster if (ca && cb) { if (ca[0] !== cb[0]) return ca[0] - cb[0]; return ca[1] - cb[1]; } if (ca && !cb) return -1; if (!ca && cb) return 1; return a.code.localeCompare(b.code); }); } } if (showFavsOnly) list = list.filter(i => favs.includes(i.code)); if (query.trim()) { const q = query.trim().toLowerCase(); list = list.filter(i => { if (i.kind === 'group') { if ((i.title||'').toLowerCase().includes(q)) return true; return (i.members||[]).some(m => m.code.toLowerCase().includes(q) || (m.name||'').toLowerCase().includes(q) || (m.barcode||'').includes(q) ); } return ( i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) || i.barcode.includes(q) ); }); } return list; }, [data, series, query, sortMode, showFavsOnly, favs]); const toggleFav = useCallback((code) => { setFavs(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]); }, [setFavs]); const onCopy = (field, val) => { navigator.clipboard?.writeText(val); setCopied(field); setTimeout(() => setCopied(null), 1400); }; const cols = Math.max(2, Math.min(8, tweaks.columnsDesktop || 6)); const dense = tweaks.density === 'compact'; return (
{/* HEADER */}
TMO 水材所
setQuery(e.target.value)} placeholder={t('搜尋全部產品代號、品名或條碼')} aria-label={t('搜尋全部產品')} />
{/* Series Tabs — auto-derived from PRODUCTS keys, supports A→Z */} {/* Sub-bar: sort + favs count + result count */}
setQuery(e.target.value)} placeholder={t('搜尋代號、品名或條碼')} aria-label={t('搜尋')} /> {query && }
{/* SLIDE-IN MENU (matches homepage) */}
{ if(e.target.classList.contains('sheet')) setMenuOpen(false); }}>
{INSTALL_ITEMS.map((x,i) => ({t(x)}))}
{(MENU.SOCIAL||[]).map((it,i)=>({it[0]}))}
{/* MAIN GRID */}
{items.length === 0 ? (
{showFavsOnly ? t('尚未收藏任何商品') : t('找不到符合條件的商品')}
{(query || showFavsOnly) && ( )}
) : ( p.kind === 'group' ? ( ) : ( )} /> )}
{/* MOBILE BOTTOM NAV — matches homepage */} {/* LIGHTBOX */} {open && setOpen(null)} onCopy={onCopy} copiedField={copied} favs={favs} onToggleFav={toggleFav} />} {/* TWEAKS PANEL */} {window.TweaksPanel && ( setTweak('columnsDesktop', v)} /> setTweak('density', v)} /> setTweak('showBarcodeOnCard', v)} /> setTweak('darkMode', v)} /> setTweak('wireframe', v)} /> )}
); }; ReactDOM.createRoot(document.getElementById('root')).render();