PageSourceSearch

https://kaitorix.app/js/kaitorix.js?v=1056

js kaitorix.app collected 2026-10-01 13:07:18 UTC 362,346 bytes, 5,733 lines download raw bytes

1    (function() {
2        'use strict';
3        // ==================== 共通コアを分割代入(kaitorix-core.js が単一真理源) ====================
4        // AMAZON_TAG/CONFIG/STORE_COLORS/KX_STORE_MAP/deobfuscate/前日比 snapshot は core に集約。
5        // apiCall は下でページ固有の onHttpError(セッション期限切れ/硬封禁)を束ねた薄ラッパにする。
6        const {
7            AMAZON_TAG, CONFIG, STORE_COLORS,
8            KX_STORE_NAME_BY_KEY, KX_STORE_KEY_BY_NAME,
9            setXorKey, deobfuscatePrice, rawPrice, rawMaxPrice,
10            _isoMinusOne, _findYesterdaySnapshot
11        } = window.KX_CORE;
12
13        // ==================== DOM 元素 ====================
14        const elements = {
15            searchSection: document.getElementById('searchSection'),
16            productSection: document.getElementById('productSection'),
17            loading: document.getElementById('loading'),
18            searchInput: document.getElementById('searchInput'),
19            searchDropdown: document.getElementById('searchDropdown'),
20            searchResults: document.getElementById('searchResults'),
21            searchError: document.getElementById('searchError'),
22            backBtn: document.getElementById('backBtn'),
23            productName: document.getElementById('productName'),
24            productJan: document.getElementById('productJan'),
25            pricesGrid: document.getElementById('pricesGrid'),
26            statMaxPrice: document.getElementById('statMaxPrice'),
27            statStoreCount: document.getElementById('statStoreCount'),
28            statDiff: document.getElementById('statDiff'),
29            noHistory: document.getElementById('noHistory')
30        };
31
32        let priceChart = null;
33        let currentJan = null;
34        let currentFrom = null;
35        let currentFromQuery = '';  // 戻り先の query params を保持
36        let currentFromUrl = null;  // 戻り先の具体的 URL(listing ページ等)
37        let _isInitialLoad = false;
38        let profitableData = null;
39        let carouselPage = 1;
40        let searchTimeout = null;
41        const CARDS_PER_PAGE = 5;
42        const SEARCH_DEBOUNCE_MS = 300;
43        const HISTORY_KEY = 'kaitorix_search_history';
44        const MAX_HISTORY_ITEMS = 100;   // 保存上限(表示は初期10件+「もっと見る」で+10ずつ最大100)
45        const MATOME_KEY = 'kaitorix_matome_list';
46        const MAX_MATOME_ITEMS = 50;
47        const FAVORITES_KEY = 'kaitorix_favorites';
48        const MAX_FAVORITES = 50;
49
50        let matomeBarExpanded = false;
51        let previousViewState = null;
52        let currentProductData = null;
53        let currentHistoryData = null;
54        // 定価履歴 [{from:'YYYY-MM-DD', msrp:int}, ...](effective_from 昇順、末尾=現行)
55        let currentMsrpHistory = null;
56        // 統計欄の再計算に使う「現在のレンジ」と「表示中店舗 key」
57        let _currentRecentHistory = null;
58        let _visibleStoreKeys = null; // null = 全店舗(初期化前フォールバック)
59
60        // ==================== API 调用(core.apiCall にページ固有フックを束ねる) ====================
61        // challenge 誘導・共通エラーは kaitorix-core.js が担う。主版だけが持つ
62        // 「DP session 期限切れモーダル」「硬封禁 → /blocked 遷移」を onHttpError で注入する。
63        // 判定順は元実装と同一: session → (challenge は core へ委譲) → blocked。
64        function apiCall(endpoint) {
65            return window.KX_CORE.apiCall(endpoint, function(status, data, response) {
66                // DP session 期限切れ (/history が 403 を返した場合) → 再読込誘導モーダル
67                if (status === 403 && data.detail === 'dp_session_required') {
68                    _showSessionExpiredModal();
69                    throw new Error('session_expired');
70                }
71                // challenge は core 側に処理させる(false を返して委譲)。
72                if (status === 403 &&
73                    (data.challenge || response.headers.get('X-Challenge-Required'))) {
74                    return false;
75                }
76                // それ以外の 403(硬封禁 / rate ban)→ 封禁説明ページ(連絡先付き)へ遷移
77                if (status === 403) {
78                    window.location.href = '/blocked';
79                    throw new Error('blocked');
80                }
81                return false;
82            });
83        }
84
85        // まとめ(一括比較)用の一括取得は kaitorix-core.js に集約(2026-09-21)。
86        // ここに写しを置いていたせいで、TG 版(kaitorix-tg.js)が個別呼び出しのまま
87        // 取り残され、同じ誤 BAN が 2026-09-21 に再発した。実装は core 側の
88        // fetchProductsBatch 冒頭コメントを参照。apiCall は主版固有フック付きを渡す。
89        function fetchProductsBatch(jans) {
90            return window.KX_CORE.fetchProductsBatch(jans, apiCall);
91        }
92
93        // セッション期限切れモーダル: ユーザーが手動で再読込
94        let _sessionModalShown = false;
95        function _showSessionExpiredModal() {
96            if (_sessionModalShown) return;
97            _sessionModalShown = true;
98            const ov = document.createElement('div');
99            ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:99999;display:flex;align-items:center;justify-content:center;padding:20px';
100            ov.innerHTML = '<div style="background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:24px;max-width:400px;text-align:center;color:#eee"><div style="font-size:16px;font-weight:600;margin-bottom:12px">セッション期限切れ</div><div style="font-size:14px;line-height:1.6;margin-bottom:20px;color:#bbb">価格チャートのセッションが期限切れになりました。<br>ページを再読み込みしてください。</div><button id="_sessReloadBtn" style="background:#3b82f6;color:#fff;border:0;padding:10px 24px;border-radius:6px;font-size:14px;cursor:pointer">再読み込み</button></div>';
101            document.body.appendChild(ov);
102            document.getElementById('_sessReloadBtn').onclick = () => location.reload();
103        }
104        // setXorKey / deobfuscatePrice / _isoMinusOne / _findYesterdaySnapshot は
105        // kaitorix-core.js に集約(冒頭で分割代入済み)。
106
107        // ==================== 搜索功能 ====================
108        async function searchDropdown(query) {
109            query = query.trim();
110            if (!query || query.length < 2) {
111                hideDropdown();
112                return;
113            }
114            showDropdownLoading();
115
116            // GA4: search
117            if (window.gtag) gtag('event', 'search', { search_term: query });
118
119            try {
120                _dropdownQuery = query;
121                const data = await apiCall(`/api/search?q=${encodeURIComponent(query)}&limit=30&offset=0`);
122                if (!data.results || data.results.length === 0) {
123                    showDropdownEmpty(query);
124                    return;
125                }
126                renderDropdown(data.results);
127            } catch (err) {
128                hideDropdown();
129            }
130        }
131
132        function showDropdownLoading() {
133            elements.searchDropdown.innerHTML = '<div class="dropdown-loading">検索中...</div>';
134            elements.searchDropdown.classList.add('active');
135        }
136
137        function showDropdownEmpty(query) {
138            var q = query ? escapeHtml(query) : '';
139            elements.searchDropdown.innerHTML = (
140                '<div style="padding:16px;text-align:center;color:var(--text-muted);">' +
141                '<div style="margin-bottom:8px;">「' + q + '」の検索結果はありません</div>' +
142                '<div style="font-size:0.85rem;">' +
143                '<p>商品名の一部・JAN・ASINで検索してみてください</p>' +
144                '<div style="margin-top:8px;">' +
145                '<a href="/guide" style="color:var(--primary);text-decoration:none;font-weight:600;">使い方ガイド</a>' +
146                '<span style="margin:0 8px;color:var(--border);">|</span>' +
147                '<a href="/ranking/profit" style="color:var(--primary);text-decoration:none;font-weight:600;">利益ランキングを見る</a>' +
148                '</div></div></div>' +
149                _searchAllRow(query)
150            );
151            elements.searchDropdown.classList.add('active');
152        }
153
154        function hideDropdown() {
155            elements.searchDropdown.classList.remove('active');
156            elements.searchDropdown.innerHTML = '';
157        }
158
159        var _dropdownResults = [];
160        var _dropdownQuery = '';
161        var _dropdownHasMore = true;
162        var _dropdownLoading = false;
163        var _DROPDOWN_PAGE = 30;
164
165        // 検索ドロップダウン共通の「『q』で全件検索」行(クリックで /search?q= へ遷移)。
166        // 全 SPA 検索ボックス(hero / product inline / sticky)で同じ導線を提供する。
167        // isEnterAction: Enter がこの行と同じ挙動になる場合のみ keycap を出す。
168        // 候補がちょうど 1 件のときだけ Enter はその商品へ直行するので、その場合は false を渡す(表示と実挙動の齟齬を防ぐ)。
169        function _searchAllRow(q, isEnterAction) {
170            const qq = escapeHtml(q || '');
171            const href = '/search?q=' + encodeURIComponent(q || '');
172            const kbd = (isEnterAction === false) ? '' : '<kbd class="search-all-kbd">Enter&nbsp;↵</kbd>';
173            return `<a href="${href}" class="search-all-row" aria-label="「${qq}」で全件検索">`
174                + `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.3" y2="16.3"/></svg>`
175                + `<span class="sar-label">「<b>${qq}</b>」で全件検索</span>${kbd}</a>`;
176        }
177
178        function _renderDropdownBody() {
179            let html = _dropdownResults.map(item => {
180                const img = item.image_url
181                    ? `<img src="${escapeHtml(item.image_url)}" loading="lazy" alt="" style="width:40px;height:40px;object-fit:contain;border-radius:4px;background:#fff;flex-shrink:0;">`
182                    : `<div style="width:40px;height:40px;border-radius:4px;background:var(--border);flex-shrink:0;"></div>`;
183                return `
184                <a href="/dp/${escapeHtml(item.jan)}" class="dropdown-item" data-jan="${escapeHtml(item.jan)}" style="display:flex;align-items:center;gap:10px;">
185                    ${img}
186                    <div style="flex:1;min-width:0;">
187                        <div class="dropdown-item-name">${escapeHtml(item.name)}</div>
188                        <div class="dropdown-item-code">JAN: ${escapeHtml(item.jan)}</div>
189                    </div>
190                </a>`;
191            }).join('');
192            if (_dropdownHasMore) {
193                const label = _dropdownLoading ? '読み込み中...' : `さらに読み込む (+${_DROPDOWN_PAGE})`;
194                html += `<div id="dropdownLoadMore" style="padding:12px;text-align:center;cursor:pointer;color:var(--primary);font-weight:600;font-size:0.88rem;border-top:1px solid var(--border);">${label}</div>`;
195            }
196            html += _searchAllRow(_dropdownQuery, _dropdownResults.length !== 1);
197            elements.searchDropdown.innerHTML = html;
198            elements.searchDropdown.classList.add('active');
199
200            elements.searchDropdown.querySelectorAll('.dropdown-item').forEach(el => {
201                el.addEventListener('click', (e) => {
202                    e.preventDefault();
203                    hideDropdown();
204                    currentFrom = 'search';
205                    _updateBackBtn('search');
206                    showProduct(el.dataset.jan);
207                });
208            });
209            const moreBtn = document.getElementById('dropdownLoadMore');
210            if (moreBtn) {
211                moreBtn.addEventListener('click', async (e) => {
212                    e.stopPropagation();
213                    if (_dropdownLoading || !_dropdownHasMore) return;
214                    _dropdownLoading = true;
215                    _renderDropdownBody();
216                    try {
217                        const offset = _dropdownResults.length;
218                        const data = await apiCall(`/api/search?q=${encodeURIComponent(_dropdownQuery)}&limit=${_DROPDOWN_PAGE}&offset=${offset}`);
219                        const more = (data && data.results) || [];
220                        _dropdownResults = _dropdownResults.concat(more);
221                        _dropdownHasMore = more.length >= _DROPDOWN_PAGE;
222                    } catch (err) {
223                        _dropdownHasMore = false;
224                    }
225                    _dropdownLoading = false;
226                    _renderDropdownBody();
227                });
228            }
229        }
230
231        function renderDropdown(results) {
232            _dropdownResults = results || [];
233            _dropdownHasMore = _dropdownResults.length >= _DROPDOWN_PAGE;
234            _dropdownLoading = false;
235            _renderDropdownBody();
236        }
237
238        function debouncedSearch() {
239            if (searchTimeout) clearTimeout(searchTimeout);
240            searchTimeout = setTimeout(() => {
241                searchDropdown(elements.searchInput.value);
242            }, SEARCH_DEBOUNCE_MS);
243        }
244
245        // 入力欄内の「Enter ↵」ヒントの出し入れ。Enter が実際に効く閾値(2文字)に合わせる。
246        // 表示自体は PC 限定(CSS の pointer:fine メディアクエリ側で制御)。
247        function syncEnterHint() {
248            const box = elements.searchInput && elements.searchInput.closest('.search-box');
249            if (!box) return;
250            box.classList.toggle('has-query', (elements.searchInput.value || '').trim().length >= 2);
251        }
252
253        // ==================== Page View Tracking ====================
254        let _lastTrackedPath = null;
255        function trackPageView(path) {
256            path = path || location.pathname;
257            if (path === _lastTrackedPath) return;
258            // 初回ヒットのみ document.referrer を送る(SEO/GEO 流入元計測。SPA 内遷移は ref なし)
259            const _ref = _lastTrackedPath === null ? (document.referrer || '') : '';
260            _lastTrackedPath = path;
261            fetch('/api/pageview?token=' + CONFIG.API_TOKEN, {
262                method: 'POST',
263                headers: {'Content-Type': 'application/json', 'X-API-Token': CONFIG.API_TOKEN},
264                body: JSON.stringify({path: path, ref: _ref})
265            }).catch(() => {});
266        }
267
268        // ==================== 商品详情 ====================
269        // 商品間遷移時(関連商品クリック等): 古い価格表を残さず skeleton を即表示
270        function renderPriceSkeleton(rows) {
271            if (!elements.pricesGrid) return;
272            var html = '<div class="prices-list" aria-busy="true" aria-label="価格を読み込み中">';
273            for (var i = 0; i < (rows || 6); i++) {
274                html += '<div class="price-skeleton-row">' +
275                    '<div style="display:flex;align-items:center;flex:1;min-width:0;">
275' +
276                    '<span class="sk-line sk-rank"></span><span class="sk-line sk-store"></span></div>' +
277                    '<span class="sk-line sk-price"></span></div>';
278            }
279            elements.pricesGrid.innerHTML = html + '</div>';
280        }
281
282        // トップ等から商品へ移る時は、まず「商品を読み込み中」の形(price-checker.html の kx-dp-boot と
283        // 同じ)にして、その位置へ即座に送る。以前は読み込み中もトップの画面のまま(読み込み表示は
284        // 検索欄の下で画面外のことも)、届いた瞬間に中身が入れ替わってから画面が流れていた
285        // (2026-10-01 May「先に崩れた形が出て、一瞬で正式に変わる」)。
286        // 送り先は商品が描かれる位置=showProduct の最後のスクロールと同じ式。
287        function _enterProductLoadingView() {
288            document.documentElement.classList.add('kx-dp-boot');
289            const tb = document.querySelector('.sticky-toolbar');
290            const off = tb ? tb.offsetHeight + 8 : 0;
291            const top = elements.loading.getBoundingClientRect().top + window.pageYOffset - off;
292            window.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
293        }
294
295        async function showProduct(jan, scrollToTop) {
296            showLoading(true);
297            hideError();
298            currentJan = jan;
299            if (elements.productSection.classList.contains('active')) renderPriceSkeleton(6);
300            else if (scrollToTop || currentFrom) _enterProductLoadingView();   // 最後に商品へスクロールする経路だけ
301
302            try {
303                // /api/product/{jan} を先に await して dp_seen marker を確実に Redis に
304                // 書き込ませる。その後 /history を呼ぶことで marker チェックを通す。
305                // 並列にすると race condition で /history が先に到着し 403 になる。
306                const product = await apiCall(`/api/product/${jan}?campaign=1`);
307                _productReadyJan = jan;   // ここで初めて dp_seen marker が立っている
308                const historyData = await apiCall(`/api/product/${jan}/history?days=180`);
309                let peakData = null;
310                try { peakData = await apiCall(`/api/product/${jan}/peak`); } catch (e) { peakData = null; }
311
312                showLoading(false);
313                currentHistoryData = historyData.history;
314                currentMsrpHistory = (historyData.msrp_history && historyData.msrp_history.length) ? historyData.msrp_history : null;
315                setXorKey(product._k);
316                renderProduct(product);
317                renderPeakPrice(peakData && peakData.peak);
318                // 表示期間: 記憶された設定 or PC=30日/モバイル=14日
319                const _initDays = _chartPeriodPref();
320                const _rangeSel = document.getElementById('chartRangeSel');
321                if (_rangeSel) _rangeSel.value = String(_initDays);
322                // 🔴🔴 リセットは **renderChart より前**。順番を逆にすると 2 つ壊れる:
323                //  ・`_plogHidden` は `renderChart` の最後で凡例の状態から作り直される。
324                //    後から空にすると、グラフが隠している店が表では出たままになる。
325                //    先に空にしておけば「正常系=renderChart が正しく入れ直す」
326                //    「異常系=renderChart が早期 return(履歴 0 件 / Chart 未ロード)
327                //    しても空のまま=勝手に絞り込まれない」の両方が成り立つ。
328                //  ・`plogPrev`(履歴ビューで隠した時の元の display)も同じ。先に消せば
329                //    `renderChart` がこの商品用に出し直した #chartMsrpToggle /
330                //    #chartBrush / #chartPeriodStats を、後段の `setPriceLogView(false)`
331                //    が**前の商品の値**で上書きしない(定価の無い商品→有る商品へ移ると
332                //    定価トグルが出ないまま、という壊れ方をしていた)。
333                _plogLoadedJan = null;
334                _plogAll = [];
335                _plogPage = 1;
336                _plogHidden = new Set();
337                // 🔴🔴 印を消す前に **必ず戻す**。`renderChart` が display を入れ直すのは
338                //    noHistory / chartBrush / chartPeriodStats / chartMsrpToggle の 4 つだけで、
339                //    **chartSaleBtn / chartRangeSel / chartSaleNote は誰も触らない**。
340                //    印だけ消すと履歴ビューで付けた inline の display:none が残り、
341                //    セールと期間セレクタが**以後どの商品でも出なくなる**(=グラフの
342                //    期間を変える手段が丸ごと消え、ページを読み直すまで戻らない)。
343                //    前回「順序を前に出す」だけで直したつもりになっていた穴。
344                ['chartSaleBtn', 'chartRangeSel', 'chartSaleNote', 'chartBrush',
345                 'chartPeriodStats', 'chartMsrpToggle', 'noHistory'].forEach(function (id) {
346                    var el = document.getElementById(id);
347                    if (!el || el.dataset.plogPrev === undefined) return;
348                    el.style.display = el.dataset.plogPrev;
349                    delete el.dataset.plogPrev;
350                });
351                renderChart(currentHistoryData, _initDays);
352                setupPriceLogTabs();
353                setupPriceLogPager();
354                setPriceLogView(false);
355                renderPriceIndicator(historyData.price_indicator);
356
357                if (product.name && product.jan) {
358                    saveToHistory(product.jan, product.name, {
359                        // 🔴 素の最高値。キャンペーン価を入れると localStorage と
360                        // /api/me/recent-views に条件抜きの額が**期間終了後も残る**
361                        // (履歴カードは条件を描けないし、期限で消える仕組みも無い)。
362                        price: rawMaxPrice(product.prices) || deobfuscatePrice(product.max_price) || 0,
363                        img: product.image_url || '',
364                        cat: product.category || ''
365                    });
366                }
367                loadSokuho(jan);
368
369                // GA4: view_item
370                if (window.gtag) gtag('event', 'view_item', {
371                    item_id: jan,
372                    item_name: product.name,
373                    price: rawMaxPrice(product.prices) || deobfuscatePrice(product.max_price)
374                });
375
376                // Dynamic meta update for product page
377                // 🔴 meta/og はリンクプレビューとして条件から切り離されて出回るので素の額で書く。
378                const _prices = product.prices || [];
379                const _rawMax = rawMaxPrice(_prices);
380                const _maxPrice = _rawMax.toLocaleString();
381                const _maxStore = (_prices.find(function (x) { return !x.stale && rawPrice(x) === _rawMax; })
382                                   || _prices[0] || {}).store || '';
383                const _name = product.name || '';
384                // 🔴 2026-09-23〜 買取価が 1 円も無い商品もカタログに載る(JAN↔ASIN の
385                //    対応が有れば掲載する May 裁決)。素のままだと【0円】/ 最高値は の0円
386                //    という嘘が title・og・twitter に同時に出る。SSR 側(rendering.py の
387                //    has_price)と同じ分岐をこちらにも置く。
388                const _hasPrice = _rawMax > 0;
389                const _metaTitle = _hasPrice
390                    ? `「${_name}」の買取価格比較【${_maxPrice}円】- 買取X`
391                    : `「${_name}」の買取価格 - 買取X | 現在、買取価格の掲載はありません`;
392                const _metaDesc = _hasPrice
393                    ? `「${_name}」の買取価格を複数社で比較。最高値は${_maxStore}の${_maxPrice}円。最新の買取価格を毎日更新中。`
394                    : `「${_name}」は現在、買取価格の掲載がありません。買取価格が付き次第このページに掲載します。`;
395                const _canonical = `https://kaitorix.app/dp/${product.jan}
395`;
396                document.title = _metaTitle;
397                const _descEl = document.querySelector('meta[name="description"]');
398                if (_descEl) _descEl.setAttribute('content', _metaDesc);
399                const _ogTitle = document.querySelector('meta[property="og:title"]');
400                if (_ogTitle) _ogTitle.setAttribute('content', _metaTitle);
401                const _ogDesc = document.querySelector('meta[property="og:description"]');
402                if (_ogDesc) _ogDesc.setAttribute('content', _metaDesc);
403                let _canonEl = document.querySelector('link[rel="canonical"]');
404                if (!_canonEl) { _canonEl = document.createElement('link'); _canonEl.rel = 'canonical'; document.head.appendChild(_canonEl); }
405                _canonEl.setAttribute('href', _canonical);
406
407                elements.searchSection.style.display = 'none';
408                elements.productSection.classList.add('active');
409                // 直開きの「読み込み中の形」(price-checker.html の kx-dp-boot)はここで終わり
410                document.documentElement.classList.remove('kx-dp-boot');
411                // 🔴 ここで初めて #productSection が display:none を脱ぐ。renderChart は
412                //    その**前**に走るので、`_drawBrushMini` は親幅 0 を見て(0 幅ガードで)
413                //    描かずに帰っている= URL 直開き・検索→商品の初回でミニマップが
414                //    空のまま残る。旧実装は `|| 300` でぼやけつつも描けていたので、
415                //    ガードだけ足したのは**片手落ち**だった。見えた直後に描き直す。
416                if (currentHistoryData && currentHistoryData.length) _drawBrushMini(currentHistoryData);
417                updateMatomeUI();
418                // from_url も URL に残す(リロード/popstate 後も「◯◯に戻る」の戻り先を維持)
419                var _navParams = new URLSearchParams();
420                if (currentFrom) {
421                    _navParams.set('from', currentFrom);
422                    if (currentFromUrl) _navParams.set('from_url', currentFromUrl);
423                }
424                var _navQs = _navParams.toString();
425                var productUrl = '/dp/' + jan + (_navQs ? '?' + _navQs : '');
426                const _firstView = _isInitialLoad;
427                if (_isInitialLoad) {
428                    history.replaceState({ jan: jan }, '', productUrl);
429                    _isInitialLoad = false;
430                } else {
431                    history.pushState({ jan: jan }, '', productUrl);
432                }
433                trackPageView('/dp/' + jan);
434
435                // Scroll after rendering - when navigating from any section with from parameter
436                if (scrollToTop || currentFrom) {
437                    requestAnimationFrame(function() {
438                        const el = document.getElementById('productSection');
439                        const headerH = document.querySelector('.sticky-toolbar');
440                        const offset = headerH ? headerH.offsetHeight + 8 : 0;
441                        const top = el.getBoundingClientRect().top + window.pageYOffset - offset;
442                        // 直開きは読み込み中の時点でもう同じ位置へ送ってある(price-checker.html)。
443                        // ここで smooth にすると、ずれが数 px でも画面が流れて見えるので即時に合わせる。
444                        window.scrollTo({top: top, behavior: _firstView ? 'auto' : 'smooth'});
445                    });
446                }
447
448            } catch (err) {
449                // 404 = DB に存在しない JAN(Open API 等から任意 JAN で着地し得る)。
450                // 生の「HTTP 404」ではなく案内文を出す
451                if (String(err.message).indexOf('404') >= 0) {
452                    showError('この商品は現在お取り扱いがありません(買取店での取扱が確認できない商品です)。商品名・型番・JANコードで検索をお試しください。');
453                } else {
454                    showError(err.message);
455                }
456                showLoading(false);
457                // 案内文は検索欄の中に出る=読み込み中の形を解いて検索欄を見せる
458                document.documentElement.classList.remove('kx-dp-boot');
459            }
460        }
461
462        function renderProduct(product) {
463            currentProductData = {
464                jan: product.jan,
465                name: product.name,
466                image_url: product.image_url || null,
467                prices: product.prices || [],
468                asin: product.asin || null
469            };
470
471            updateFavoriteStarUI(product.jan);
472
473            document.getElementById('qtyInput').value = 1;
474            document.getElementById('matomeAddFeedback').textContent = '';
475            document.getElementById('matomeAddFeedback').className = '';
476            // Reset profit calculator & PC qty
477            const profitCalcInput = document.getElementById('profitCalcInput');
478            if (profitCalcInput) { profitCalcInput.value = ''; }
479            const profitCalcResult = document.getElementById('profitCalcResult');
480            if (profitCalcResult) { profitCalcResult.textContent = '利益 ¥---'; profitCalcResult.className = 'profit-calc-result zero'; }
481            const qtyInputPcEl = document.querySelector('.qty-input-pc');
482            if (qtyInputPcEl) { qtyInputPcEl.value = 1; }
483
484            elements.productName.textContent = product.name || '商品名不明';
485            elements.productJan.textContent = product.jan;
486            elements.productJan.dataset.jan = product.jan;
487
488            const imageContainer = document.getElementById('productImage');
489            if (product.image_url) {
490                imageContainer.innerHTML = `<img src="${escapeHtml(product.image_url)}" alt="${escapeHtml(product.name || '')}" loading="lazy" width="200" height="200">`;
491            } else {
492                imageContainer.innerHTML = '<div class="placeholder-icon" style="width:100%;height:100%;border-radius:12px;">'+placeholderSVG(64)+'</div>';
493            }
494            imageContainer.style.display = 'flex';
495
496            const prices = product.prices || [];
497            const maxPrice = prices.length > 0 ? deobfuscatePrice(prices[0].price) : 0;
498            const minPrice = prices.length > 0 ? deobfuscatePrice(prices[prices.length - 1].price) : 0;
499
500            // 🔴 値が無い時に ¥0 と出すと「0 円でしか買い取られない」と読める。SSR は "---"。
501            elements.statMaxPrice.textContent = maxPrice > 0 ? `Â¥${maxPrice.toLocaleString()}` : '---';
502            // 定価対比ストリップ(ある商品のみ: Apple/主要ゲーム機): 定価 ─ 比較バー ─ 買取率
503            const _msrpEl = document.getElementById('statMsrp');
504            if (_msrpEl) {
505                const _msrpNow = product.msrp || 0;
506                if (_msrpNow > 0 && maxPrice > 0) {
507                    const _rate = Math.round(maxPrice / _msrpNow * 1000) / 10;
508                    _msrpEl.innerHTML =
509                        `<span>定価 ¥${_msrpNow.toLocaleString()}</span>`
510                        + `<span class="stat-msrp-bar"><i style="width:${Math.min(_rate, 100)}%"></i></span>`
511                        + `<span>買取率 <b>${_rate}%</b></span>`;
512                    _msrpEl.title = `最高買取価格は定価(税込)の ${_rate}%`;
513                    _msrpEl.style.display = '';
514                } else {
515                    _msrpEl.style.display = 'none';
516                }
517            }
518            syncStatsCtx();
519            elements.statStoreCount.textContent = `${prices.length}店舗`;
520            // 前日比(画面に表示中の最高値 vs 昨日 snapshot)
521            // ※ history API の「今日行」ではなく表示価格そのものを使う —
522            //   product と history のキャッシュ期限ズレで「価格は旧・バッジは新」の
523            //   不整合表示が起きるため(2026-06-12 ホムラ強化価格事案)
524            const _yesterdayRow = _findYesterdaySnapshot(currentHistoryData);
525            if (_yesterdayRow && maxPrice > 0) {
526                const yPrices = Object.values(_yesterdayRow.prices || {}).filter(v => v > 0);
527                if (yPrices.length) {
528                    const yesterdayMax = Math.max(...yPrices);
529                    const diff = maxPrice - yesterdayMax;
530                    if (diff > 0) {
531                        elements.statDiff.innerHTML = `<span style="color:var(--success)">+Â¥${diff.toLocaleString()}</span>`;
532                    } else if (diff < 0) {
533                        elements.statDiff.innerHTML = `<span style="color:var(--danger)">
533-Â¥${Math.abs(diff).toLocaleString()}</span>`;
534                    } else {
535                        elements.statDiff.textContent = '±¥0';
536                    }
537                } else {
538                    elements.statDiff.textContent = '---';
539                }
540            } else {
541                elements.statDiff.textContent = '---';
542            }
543
544            const ecContainer = document.getElementById('amazonBtnContainer');
545            const productName = encodeURIComponent(product.name);
546
547            renderCheckerBanner(product.jan);
548
549            const amazonUrl = product.asin
550                ? `https://www.amazon.co.jp/dp/${escapeHtml(product.asin)}?tag=${AMAZON_TAG}&linkCode=ogi&th=1&psc=1`
551                : `https://www.amazon.co.jp/s?k=${productName}`;
552            // jan も渡す: 中転ページが自社 DB から商品名・写真を引いて商品ページ体裁で出し、
553            // 検索自体も JAN 完全一致になる(商品名検索は容量・色違いを拾う)
554            const _janQ = encodeURIComponent(product.jan || '');
555            const rakutenUrl = window.kxRakutenUrl({ jan: product.jan || '', query: product.name || '' });
556            const yahooUrl = `https://mottoku.app/y/search?jan=${_janQ}&q=${productName}&s=ktrx`;
557
558            const fujiyaUrl = `https://www.fujiya-camera.co.jp/shop/goods/search.aspx?keyword=${product.jan || productName}`;
559            const yodobashiUrl = `https://www.yodobashi.com/?word=${productName}`;
560            const sofmapUrl = product.jan
561                ? `https://click.linksynergy.com/link?id=IQNT7UmwfIQ&offerid=260508.37641101644937&type=2&murl=${encodeURIComponent('https://www.sofmap.com/search_result.aspx?keyword=' + product.jan)}`
562                : `https://www.sofmap.com/`;
563
564            const _jan = product.jan || '';
565            ecContainer.innerHTML = `
566                <div class="ec-buttons" style="display:flex;gap:8px;align-items:center;">
567                    <a href="${amazonUrl}" target="_blank" rel="sponsored noopener" class="ec-btn amazon" style="flex:1;">Amazon</a>
568                    <a href="${rakutenUrl}" target="_blank" rel="sponsored noopener" class="ec-btn rakuten" style="flex:1;" onclick="return showShopsPopover(this,'${_jan}','rakuten','${escapeHtml(rakutenUrl)}',event)">楽天</a>
569                    <a href="${yahooUrl}" target="_blank" rel="sponsored noopener" class="ec-btn yahoo" style="flex:1;" onclick="return showShopsPopover(this,'${_jan}','yahoo','${escapeHtml(yahooUrl)}',event)">Yahoo</a>
570                    <span class="more-shops-btn" style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:8px;background:#f1f5f9;cursor:pointer;transition:all .15s;flex-shrink:0;" title="他のショップで検索" onclick="toggleMoreShops(this,'${productName}','${escapeHtml(amazonUrl)}','${escapeHtml(rakutenUrl)}','${escapeHtml(yahooUrl)}','${escapeHtml(fujiyaUrl)}','${escapeHtml(yodobashiUrl)}','${escapeHtml(sofmapUrl)}','${_jan}')">
571                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.3" y2="16.3"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
572                    </span>
573                </div>
574            `;
575
576            // async: update button labels with shop counts AND populate prefetch cache
577            // for synchronous click handling in showShopsPopover (mobile compatibility)
578            if (_jan) {
579                if (!window._shopsCache) window._shopsCache = {};
580                const _token = (typeof CONFIG !== 'undefined' && CONFIG.API_TOKEN) ? CONFIG.API_TOKEN : '';
581                ['rakuten', 'yahoo'].forEach(function(src) {
582                    const _key = _jan + ':' + src;
583                    if (window._shopsCache[_key] !== undefined) return;
584                    window._shopsCache[_key] = null; // pending
585                    fetch('/api/shops/' + _jan + '/' + src + '?token=' + _token, {headers: {'X-API-Token': _token}})
586                        .then(function(r) { return r.ok ? r.json() : {shops:[]}; })
587                        .then(function(data) {
588                            const shops = data.shops || [];
589                            window._shopsCache[_key] = shops;
590                            if (!shops.length) return;
591                            const btn = ecContainer.querySelector('.ec-btn.' + src);
592                            if (btn) btn.textContent = (src === 'rakuten' ? '楽天' : 'Yahoo') + ' ' + shops.length;
593                        }).catch(function() { window._shopsCache[_key] = []; });
594                });
595            }
596
597            // 各店舗の前日比(画面に表示中の各店価格 vs 昨日 snapshot — 上の最高値前日比と同方針)
598            const storeDiffMap = {};
599            const nameToKey = KX_STORE_KEY_BY_NAME;
600            const _yesterdayStoreRow = _findYesterdaySnapshot(currentHistoryData);
601            if (_yesterdayStoreRow) {
602                prices.forEach(p => {
603                    const key = p.key || nameToKey[p.store];
604                    if (!key) return;
605                    const tp = deobfuscatePrice(p.price) || 0;
606                    const yp = (_yesterdayStoreRow.prices || {})[key] || 0;
607                    if (tp > 0 && yp > 0) storeDiffMap[p.store] = tp - yp;
608                });
609            }
610
611            const firstActiveIdx = prices.findIndex(p => !p.stale);
612            const _msrp = product.msrp || 0;
613            // %の意味は行ごとに書くと冗長なので、リスト上部に1回だけ注記する
614            const _rateCaption = _msrp > 0
615                ? `<div class="rate-caption">%は定価(¥${_msrp.toLocaleString()})に対する買取率</div>` : '';
616            elements.pricesGrid.innerHTML = `${_rateCaption}<div class="prices-list${_msrp > 0 ? ' rated' : ''}">${prices.map((p, i) => {
617                const diff = storeDiffMap[p.store];
618                const _arrowUp = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>';
619                const _arrowDown = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></svg>';
620                const diffHtml = diff > 0 ? `<span class="store-diff up" title="前日比 +${diff.toLocaleString()}円">${_arrowUp}${diff.toLocaleString()}</span>`
621                    : diff < 0 ? `<span class="store-diff down" title="前日比 -${Math.abs(diff).toLocaleString()}円">${_arrowDown}${Math.abs(diff).toLocaleString()}</span>`
622                    : `<span class="store-diff none">---</span>`;
623                const _storeKey = p.key || nameToKey[p.store] || p.store;
624                const _janCode = (typeof product !== 'undefined' && product.jan) ? product.jan : '';
625                const _pname = (typeof product !== 'undefined' && product.name) ? product.name.replace(/'/g, '') : '';
626                const isHighest = i === firstActiveIdx;
627                // 店舗休業(お盆休み等): サーバーが「今日が期間内の店」にだけ holiday を付ける。
628                // 期間が過ぎればサーバー側で消えるのでフロントに解除処理は要らない。
629                const _hol = (p.holiday && p.holiday.text) ? p.holiday : null;
630                // 期間限定キャンペーン(特別買取): 同じくサーバーが「今が期間内の店」にだけ
631                // campaign を付ける(data/store_campaigns.json)。url があれば条件の詳細へのリンク。
632                // 外部リンクなので iOS の Universal Link 救済(/js/exit-link.js)が自動で掛かる。
633                const _camp = (p.campaign && p.campaign.text) ? p.campaign : null;
634                const _nameInner = `<a href="${escapeHtml(p.url)}" target="_blank" ${kxStoreLinkAttrs(p.url)} onclick="if(typeof gtag==='function')gtag('event','affiliate_click',{store:'${_storeKey}',jan:'${_janCode}',product_name:'${_pname}'});">${escapeHtml(p.store)}</a>${isHighest ? '<span class="highest-badge"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 7l4.5 4L12 4l5.5 7L22 7l-2 13H4L2 7z"/></svg>最高値</span>' : ''}${p.stopped ? '<span class="stopped-badge" title="店舗側でお申込みを停止中です(金額は店舗の掲示額)">申込停止中</span>' : ''}`;
635                // 小字が付く行だけ「店名+バッジ」を1行に包んで縦積みにする(.store-name を column 化)。
636                // flex-wrap で済ませると狭幅で「最高値」バッジが単独で1行落ちして行が伸びるため。
637                const _holHtml = _hol
638                    ? `<span class="store-subnote store-holiday" title="${escapeHtml(_hol.note || _hol.text)}">${escapeHtml(_hol.text)}</span>` : '';
639                // キャンペーンは「印」列に緑のチップで置く(May 裁決 2026-09-05)。店名の下に
640                // 積むと店名ゾーンが 200px 固定なので 3 行に折れて行だけが突出し、右の印列は
641                // 空いたままだった。形は .store-mark と同じ=この店に付く条件、と意味が揃う。
642                const _campHtml = (() => {
643                    if (!_camp) return '';
644                    const tip = escapeHtml(_camp.note || _camp.text);
645                    const cta = (_camp.url && _camp.link_text)
646                        ? `<span class="store-campaign-cta">${escapeHtml(_camp.link_text)}</span>` : '';
647                    const inner = `<span class="store-campaign-txt">${escapeHtml(_camp.text)}</span>${cta}`;
648                    return _camp.url
649                        ? `<a class="store-campaign" href="${escapeHtml(_camp.url)}" target="_blank" rel="noopener" title="${tip}">${inner}</a>`
650                        : `<span class="store-campaign" title="${tip}">${inner}</span>`;
651                })();
652                const _hasSub = !!_holHtml;   // 店名の下に積むのは休業だけになった
653                const _nameHtml = _hasSub
654                    ? `<span class="store-name-row">${_nameInner}</span><span class="store-subnotes">${_holHtml}</span>`
655                    : _nameInner;
656                const classes = ['price-row'];
657                if (isHighest) classes.push('highest');
658                if (p.stale) classes.push('stale');
659                if (_hasSub) classes.push('has-subnote');    // 狭幅で小字の行だけ 3 行グリッドに組み替える
660                if (_campHtml) classes.push('has-campaign');  // 狭幅では印列 118px にチップが入らないので列を組み替える
661                return `
662                <div class="${classes.join(' ')}">
663                    <div class="store-main">
664                        <div class="store-info">
665                            <span class="store-rank">${i + 1}</span>
666                            <div class="store-name${_hasSub ? ' has-subnote' : ''}">
667                                ${_nameHtml}
668                            </div>
669                            <span class="price-updated-m">${escapeHtml(p.updated)}</span>
670                        </div>
671                        <div class="store-mark-col">${p.mark_note ? `<span class="store-mark" data-tip="${escapeHtml(p.mark_note)}"><span class="store-mark-txt">${escapeHtml(p.mark_note)}</span></span>` : ''}${_campHtml}</div>
672                    </div>
673                    <div class="price-info">
674                        ${diffHtml}<span class="price-value">Â¥${deobfuscatePrice(p.price).toLocaleString()}</span>${(() => {
675                            if (_msrp <= 0) return '';
676                            const _r = kaitoriRate(deobfuscatePrice(p.price), _msrp);
677                            // rated リスト内は列ズレ防止のため常にスパンを出す(値無しは不可視占位)
678                            return `<span class="store-rate${_r === null ? ' none' : ''}" title="買取率(定価 ¥${_msrp.toLocaleString()} 比)">${_r === null ? '' : _r + '%'}</span>`;
679                        })()}
680                        <span class="price-updated">${escapeHtml(p.updated)}</span>
681                    </div>
682                </div>`;
683            }).join('')}${kxLinkOnlyRowsHtml(product, prices.length)}</div>`;
684
685            // Product description (SEO content for users too)
686            const descEl = document.getElementById('productDescription');
687            if (descEl) {
688                descEl.innerHTML = product.description_html || '';
689            }
690
691            // Related products
692            const relatedEl = document.getElementById('relatedProducts');
693            if (relatedEl) {
694                if (product.related && product.related.length > 0) {
695                    let relHtml = '<div class="related-products">';
696                    relHtml += '<h3 class="related-title">関連商品</h3>';
697                    relHtml += '<div class="related-grid">';
698                    product.related.forEach(function(r) {
699                        const img = r.image_url
700                            ? '<img src="' + escapeHtml(r.image_url) + '" alt="' + escapeHtml(r.name) + '" loading="lazy" width="80" height="80">'
701                            : '<div class="related-img-placeholder">'+placeholderSVG(32)+'</div>';
702                        const priceHtml = r.max_price > 0
703                            ? '<div class="related-price">最高 ¥' + r.max_price.toLocaleString() + '</div>'
704                            : '';
705                        relHtml += '<a href="/dp/' + escapeHtml(r.jan) + '" data-related-jan="' + escapeHtml(r.jan) + '" class="related-card">';
706                        relHtml += img;
707                        relHtml += '<div class="related-name">' + escapeHtml(r.name) + '</div>';
708                        relHtml += priceHtml;
709                        relHtml += '</a>';
710                    });
711                    relHtml += '</div></div>';
712                    relatedEl.innerHTML = relHtml;
713                    // Event delegation for related product clicks
714                    relatedEl.querySelectorAll('a[data-related-jan]').forEach(function(a) {
715                        a.addEventListener('click', function(e) {
716                            e.preventDefault();
717                            currentFrom = 'related';
718                            _updateBackBtn('related');
719                            showProduct(this.getAttribute('data-related-jan'), true);
720                        });
721                    });
722                } else {
723                    relatedEl.innerHTML = '';
724                }
725            }
726
727            // Deal ページリンク(share メニュー内、ASIN がある場合のã
727¿è¡¨ç¤ºï¼‰
728            const shareDealEl = document.getElementById('shareDeal');
729            if (shareDealEl) {
730                if (product.asin) {
731                    shareDealEl.href = '/deal/' + escapeHtml(product.asin);
732                    shareDealEl.style.display = '';
733                } else {
734                    shareDealEl.style.display = 'none';
735                }
736            }
737        }
738
739        // ==================== 歴代最高買取ブロック ====================
740        // mobile の過去最高は「タイル直下の文脈パネル」(#statsCtx)の 1 行で、定価ストリップと枠を共有する。
741        // 行が 1 つも無い時は枠ごと消し、両方ある時だけ間に罫線を引く(2026-09-24)。
742        function syncStatsCtx() {
743            const box = document.getElementById('statsCtx');
744            if (!box) return;
745            const peakOn = !!document.getElementById('peakPriceSectionMobile')?.classList.contains('loaded');
746            const msrp = document.getElementById('statMsrp');
747            const msrpOn = !!msrp && msrp.style.display !== 'none';
748            box.classList.toggle('has-peak', peakOn);
749            box.classList.toggle('is-empty', !peakOn && !msrpOn);
750        }
751        // 'YYYY-MM-DD' → 今年なら 'M/D'、それ以外は 'YYYY/M/D'(mobile の 1 行に収めるため)
752        function _peakShortDate(s) {
753            const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || '');
754            if (!m) return s || '';
755            return (+m[1] === new Date().getFullYear() ? '' : m[1] + '/') + (+m[2]) + '/' + (+m[3]);
756        }
757        function renderPeakPrice(peak) {
758            const pc = document.getElementById('peakPriceSection');
759            const mob = document.getElementById('peakPriceSectionMobile');
760            const notePc = document.getElementById('peakExcludedNote');
761            const noteMob = document.getElementById('peakExcludedNoteMobile');
762            if (!peak || !peak.price) {
763                if (pc) pc.style.display = 'none';
764                if (mob) mob.classList.remove('loaded');
765                if (notePc) notePc.style.display = 'none';
766                if (noteMob) noteMob.style.display = 'none';
767                syncStatsCtx();
768                return;
769            }
770            // 外れ値除外通知 (例: 単店の入力ミスで突発的に高額になった日を除外した場合)
771            const excludedText = (peak.excluded && peak.excluded.price)
772                ? `※¥${peak.excluded.price.toLocaleString()} という記録もありましたが、入力ミスの可能性があるため除外しています`
773                : '';
774            if (notePc) {
775                if (excludedText) { notePc.textContent = excludedText; notePc.style.display = ''; }
776                else notePc.style.display = 'none';
777            }
778            if (noteMob) {
779                if (excludedText) { noteMob.textContent = excludedText; noteMob.style.display = ''; }
780                else noteMob.style.display = 'none';
781            }
782
783            const prices = (currentProductData && currentProductData.prices) || [];
784            const currentMax = prices.length ? deobfuscatePrice(prices[0].price) : 0;
785            const peakStr = `Â¥${peak.price.toLocaleString()}`;
786            const dateStr = peak.date || ''; // YYYY-MM-DD
787
788            // 「ピーク比」= 現在 - ピーク。↓ で下落、↑ でピーク更新。
789            let diffText = '', diffClass = 'same';
790            if (currentMax > 0) {
791                const diff = currentMax - peak.price;
792                const dropPct = peak.price ? Math.round((Math.abs(diff) / peak.price) * 1000) / 10 : 0;
793                if (diff > 0) {
794                    diffText = `↑ 更新中 +¥${diff.toLocaleString()}`;
795                    diffClass = 'up';
796                } else if (diff < 0) {
797                    diffText = `↓ ¥${Math.abs(diff).toLocaleString()} (${dropPct}%)`;
798                    diffClass = 'down';
799                } else {
800                    diffText = '= ピーク中';
801                    diffClass = 'same';
802                }
803            }
804
805            if (pc) {
806                pc.style.display = '';
807                const v = document.getElementById('peakPriceValue');
808                const d = document.getElementById('peakPriceDate');
809                const b = document.getElementById('peakPctBadge');
810                if (v) v.textContent = peakStr;
811                if (d) d.textContent = dateStr;
812                if (b) { b.textContent = diffText; b.className = 'peak-diff ' + diffClass; }
813            }
814            if (mob) {
815                mob.classList.add('loaded');
816                const vm = document.getElementById('peakPriceValueMobile');
817                const dm = document.getElementById('peakPriceDateMobile');
818                const bm = document.getElementById('peakPctBadgeMobile');
819                if (vm) vm.textContent = peakStr;
820                if (dm) { dm.textContent = _peakShortDate(dateStr); dm.title = dateStr; }
821                if (bm) { bm.textContent = diffText; bm.className = 'peak-diff ' + diffClass; }
822            }
823            syncStatsCtx();
824        }
825
826        // ==================== 売出時機インジケーター ====================
827        function renderPriceIndicator(indicator) {
828            const badge = document.getElementById('priceIndicatorBadge');
829            const label = document.getElementById('indicatorLabel');
830            if (!badge || !label || !indicator || indicator === 'unknown') {
831                if (badge) badge.style.display = 'none';
832                return;
833            }
834            const config = {
835                high: { text: '📈 高値圏 — 今が売り時!', cls: 'indicator-high' },
836                low: { text: '📉 安値圏 — 様子見推奨', cls: 'indicator-low' },
837                normal: { text: '📊 通常価格帯', cls: 'indicator-normal' }
838            };
839            const c = config[indicator];
840            if (!c) { badge.style.display = 'none'; return; }
841            label.textContent = c.text;
842            label.className = c.cls;
843            badge.style.display = '';
844        }
845
846        // ==================== まとめURL共有 ====================
847        function encodeMatomToUrl() {
848            const matome = getMatomeFromStorage();
849            if (!matome || matome.items.length === 0) return null;
850            const param = matome.items.map(item => item.jan + ':' + (item.quantity || 1)).join(',');
851            const url = new URL(window.location.origin);
852            url.searchParams.set('m', param);
853            url.searchParams.set('matome', 'compare');
854            return url.toString();
855        }
856
857        function decodeMatomFromUrl() {
858            const params = new URLSearchParams(window.location.search);
859            const m = params.get('m');
860            if (!m) return null;
861            const items = [];
862            m.split(',').forEach(part => {
863                const [jan, qty] = part.split(':');
864                if (jan && jan.match(/^\d{7,14}$/)) {
865                    items.push({ jan, quantity: parseInt(qty) || 1 });
866                }
867            });
868            return items.length > 0 ? items : null;
869        }
870
871        // ==================== Chart.js 外部 HTML tooltip (多店舗対応) ====================
872        // canvas 内描画だと買取店が多い時下部が clip される。HTML tooltip で max-height + 2 列レイアウトを実現。
873        // 🔴 タッチ端末には mouseout が無いので、一度出した tooltip が**出しっぱなし**に
874        //    なる(May 指摘 2026-09-05)。グラフの外を触ったら閉じる。
875        //    閉じるのは DOM を隠すだけでは足りない——Chart.js 側の active 要素を
876        //    消さないと、次の再描画(期間変更・凡例トグル・iOS の canvas 復元)で
877        //    同じ tooltip がまた出てくる。
878        function _bindChartTooltipDismiss() {
879            if (document.body.dataset.kxTipDismiss) return;   // リスナは 1 回だけ
880            document.body.dataset.kxTipDismiss = '1';
881            function dismiss(ev) {
882                const el = document.getElementById('chart-tooltip-ext');
883                if (!el || el.style.opacity === '0') return;   // 出ていなければ何もしない
884                const cv = document.getElementById('priceChart');
885                // グラフ本体のタップは Chart.js に任せる(別の日付へ移すため)
886                if (ev && ev.target && cv && cv.contains(ev.target)) return;
887                el.style.opacity = '0';
888                if (!priceChart) return;
889                try {
890                    priceChart.setActiveElements([]);
891                    if (priceChart.tooltip) priceChart.tooltip.setActiveElements([], { x: 0, y: 0 });
892                    priceChart.update('none');
893                } catch (_) {}
894            }
895            document.addEventListener('touchstart', dismiss, { passive: true, capture: true });
896            document.addEventListener('click', dismiss, true);
897        }
898
899        function chartTooltipExternal(context) {
900            const { chart, tooltip } = context;
901            let el = document.getElementById('chart-tooltip-ext');
902            if (!el) {
903                el = document.createElement('div');
904                el.id = 'chart-tooltip-ext';
905                el.style.cssText = 'position:absolute;pointer-events:none;z-index:9999;opacity:0;transition:opacity 0.1s;'
906                    + 'background:var(--bg-card,#fff);color:var(--text-primary,#0F172A);'
907                    + 'border:1px solid var(--border,#E2E8F0);border-radius:10px;padding:12px 14px;'
908                    + 'box-shadow:0 8px 24px rgba(0,0,0,0.12);font-size:0.85rem;line-height:1.45;'
909                    + 'box-sizing:border-box;';
910                document.body.appendChild(el);
911            }
912            if (!tooltip || tooltip.opacity === 0) { el.style.opacity = '0'; return; }
913
914            const title = (tooltip.title && tooltip.title[0]) || '';
915            const items = (tooltip.dataPoints || [])
916                .filter(d => d.parsed && d.parsed.y !== null && d.parsed.y !== undefined)
917                .sort((a, b) => (b.parsed.y || 0) - (a.parsed.y || 0));
918            // tooltip 最大幅を viewport 幅から動的に算出(左右 24px 余白)
919            const tipMaxW = Math.min(window.innerWidth - 24, 1100);
920            el.style.maxWidth = tipMaxW + 'px';
921            // 列幅は最長店名(モバイルミックス: ¥XXX,XXX 等)が折り返さない実寸 ≈ 200px
922            // auto-fit + minmax で「画面幅 / 220」で列数自動決定 → 狭い画面 1 列、広い画面 4-5 列
923            const gridCss = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));column-gap:14px;row-gap:3px;';
924            const rows = items.map(it => {
925                const c = it.dataset.borderColor || '#94A3B8';
926                const label = (it.dataset.label || '').replace(/[&<>"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));
927                return `<div style="display:flex;align-items:center;gap:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">`
928                    + `<span style="display:inline-block;width:8px;height:8px;background:${c};border-radius:50%;flex-shrink:0;"></span>`
929                    + `<span style="overflow:hidden;text-overflow:ellipsis;">${label}: ¥${it.parsed.y.toLocaleString()}</span></div>`;
930            }).join('');
931            el.innerHTML = `<div style="font-weight:700;margin-bottom:6px;">${title}</div><div style="${gridCss}">${rows}</div>`;
932
933            const rect = chart.canvas.getBoundingClientRect();
934            const left = window.scrollX + rect.left + tooltip.caretX;
935            const top = window.scrollY + rect.top + tooltip.caretY;
936            // 右辺 / 下辺をはみ出さないよう調整(タッチ後の二回目描画で正確になる)
937            el.style.opacity = '1';
938            el.style.left = '0px';
939            el.style.top = '0px';
940            el.style.transform = '';
941            const w = el.offsetWidth, h = el.offsetHeight;
942            const maxLeft = window.scrollX + window.innerWidth - w - 12;
943            const maxTop = window.scrollY + window.innerHeight - h - 12;
944            el.style.left = Math.min(Math.max(left + 14, window.scrollX + 6), Math.max(maxLeft, window.scrollX + 6)) + 'px';
945            el.style.top = Math.min(Math.max(top - h / 2, window.scrollY + 6), Math.max(maxTop, window.scrollY + 6)) + 'px';
946        }
947
948        // ==================== 图表渲染 (适配明亮模式) ====================
949        // ==================== チャート表示期間のデフォルト/記憶 ====================
950        // ユーザーが期間を選んだら localStorage に記憶。未設定時は PC=30日 / モバイル=14日。
951        const _CHART_PERIOD_OPTS = ['7', '14', '30', '90', '180'];
952        function _chartPeriodPref() {
953            try {
954                const v = localStorage.getItem('kx_chart_period');
955                if (v && _CHART_PERIOD_OPTS.indexOf(v) >= 0) return parseInt(v, 10);
956            } catch (e) {}
957            const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
958            return isMobile ? 14 : 30;
959        }
960        function _saveChartPeriod(days) {
961            try { localStorage.setItem('kx_chart_period', String(days)); } catch (e) {}
962        }
963
964        // ==================== セール期間帯(チャート背景) ====================
965        let _salePeriods = null;
966        let _showSaleBands = localStorage.getItem('kx_chart_sale') !== '0';
967        // プラットフォーム別フィルター(凡例チップをクリックで切替、2026-06-11)
968        const _saleTypes = { amazon: true, rakuten: true };
969        try {
970            const _stv = localStorage.getItem('kx_chart_sale_types');
971            if (_stv !== null) { _saleTypes.amazon = _stv.indexOf('amazon') >= 0; _saleTypes.rakuten = _stv.indexOf('rakuten') >= 0; }
972        } catch (e) {}
973        async function ensureSalePeriods() {
974            if (_salePeriods !== null) return;
975            _salePeriods = [];
976            try {
977                const d = await apiCall('/api/sale-periods');
978                _salePeriods = (d && d.periods) || [];
979                if (priceChart) priceChart.update();
980                _syncSaleBtn();
981            } catch (e) { /* 帯は飾り */ }
982        }
983        function _syncSaleBtn() {
984            const btn = document.getElementById('chartSaleBtn');
985            if (btn) btn.classList.toggle('active', _showSaleBands);
986            // セール ON のときだけプラットフォームチップ行を出す(OFF 時は行ごと消えて場所を取らない)
987            const note = document.getElementById('chartSaleNote');
988            if (note) note.style.display = _showSaleBands ? '' : 'none';
989            const ba = document.getElementById('saleTypeAmazon');
990            const br = document.getElementById('saleTypeRakuten');
991            if (ba) ba.setAttribute('aria-pressed', _saleTypes.amazon ? 'true' : 'false');
992            if (br) br.setAttribute('aria-pressed', _saleTypes.rakuten ? 'true' : 'false');
993        }
994        // 表示中チャートのセール期間 → 可視範囲・ピクセル座標を解決(帯とマーカーで共用)
995        function _resolveSaleBands(chart) {
996            if (!_showSaleBands || !_salePeriods || !_salePeriods.length) return [];
997            const labels = chart.data.labels || [];
998            if (!labels.length) return [];
999            // MM/DD ラベル → 年推定付き Date(最大180日窓なので年跨ぎは前年判定でOK)
1000            const now = new Date(), cy = now.getFullYear(), cm = now.getMonth() + 1;
1001            const dates = labels.map(l => {
1002                const p = String(l).split('/').map(Number);
1003                if (p.length < 2 || isNaN(p[0])) return null;
1004                return new Date(p[0] > cm ? cy - 1 : cy, p[0] - 1, p[1]);
1005            });
1006            const x = chart.scales.x, area = chart.chartArea;
1007            const out = [];
1008            _salePeriods.forEach(p => {
1009                if (!_saleTypes[p.type === 'rakuten' ? 'rakuten' : 'amazon']) return;
1010                const s = new Date(p.start + 'T00:00:00');
1011                const e = new Date((p.end || new Date().toISOString().slice(0, 10)) + 'T23:59:59');
1012                let i0 = -1, i1 = -1;
1013                dates.forEach((d, i) => {
1014                    if (!d) return;
1015                    if (d >= s && d <= e) { if (i0 < 0) i0 = i; i1 = i; }
1016                });
1017                if (i0 < 0) return;
1018                const half = labels.length > 1 ? (x.getPixelForValue(1) - x.getPixelForValue(0)) / 2 : 20;
1019                const px0 = Math.max(area.left, x.getPixelForValue(i0) - half);
1020                const px1 = Math.min(area.right, x.getPixelForValue(i1) + half);
1021                out.push({ p, px0, px1, mx: x.getPixelForValue(i0) });
1022            });
1023            return out;
1024        }
1025        function _saleDateShort(ymd) {
1026            const m = String(ymd || '').match(/^\d{4}-(\d{2})-(\d{2})/);
1027            return m ? `${Number(m[1])}/${Number(m[2])}` : (ymd || '');
1028        }
1029        // データ点が1日分しか無い系列(例: 今日初めて価格が付いた買取wiki)は孤立した
1030        // 小さな点になり見落とされやすい。点の左へ同色の短い実線を引いて視認性を上げる。
1031        const _singlePointStubPlugin = {
1032            id: 'kxSinglePointStub',
1033            afterDatasetsDraw(chart) {
1034                const ctx = chart.ctx;
1035                const area = chart.chartArea;
1036                chart.data.datasets.forEach((ds, i) => {
1037                    const meta = chart.getDatasetMeta(i);
1038                    if (meta.hidden) return;
1039                    // 非null(実データ)の点インデックスを収集
1040                    let only = -1, count = 0;
1041                    (ds.data || []).forEach((v, idx) => { if (v != null) { count++; only = idx; } });
1042                    if (count !== 1) return;                 // 単一データ点の系列だけ対象
1043                    const el = meta.data && meta.data[only];
1044                    if (!el) return;
1045                    const x = el.x, y = el.y;
1046                    const x0 = Math.max(area.left, x - 24);  // 左へ最大24px(軸内に収める)
1047                    if (x0 >= x) return;
1048                    ctx.save();
1049                    ctx.beginPath();
1050                    ctx.strokeStyle = ds.borderColor || '#94A3B8';
1051                    ctx.lineWidth = ds.borderWidth || 2.5;
1052                    ctx.lineCap = 'round';
1053                    ctx.moveTo(x0, y);
1054                    ctx.lineTo(x, y);                        // 点の中心まで引いて「—●」に見ã
1054›ã‚‹
1055                    ctx.stroke();
1056                    ctx.restore();
1057                });
1058            }
1059        };
1060        const _saleBandPlugin = {
1061            id: 'kxSaleBands',
1062            beforeDatasetsDraw(chart) {
1063                const bands = _resolveSaleBands(chart);
1064                if (!bands.length) { chart.$kxSaleMarkers = []; return; }
1065                const area = chart.chartArea, ctx = chart.ctx;
1066                const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1067                ctx.save();
1068                const markers = [];
1069                bands.forEach(({ p, px0, px1, mx }) => {
1070                    ctx.fillStyle = p.type === 'rakuten'
1071                        ? (isDark ? 'rgba(248,113,113,0.10)' : 'rgba(191,0,0,0.07)')
1072                        : (isDark ? 'rgba(255,153,0,0.12)' : 'rgba(255,153,0,0.09)');
1073                    ctx.fillRect(px0, area.top, px1 - px0, area.bottom - area.top);
1074                    // 日付軸下のイベントマーカー(TradingView 風): 期間開始位置に ▲
1075                    markers.push({
1076                        x: Math.max(area.left + 5, Math.min(mx, area.right - 5)),
1077                        y: area.bottom + 4,
1078                        p
1079                    });
1080                });
1081                chart.$kxSaleMarkers = markers;
1082                ctx.restore();
1083            },
1084            afterDraw(chart) {
1085                const markers = chart.$kxSaleMarkers || [];
1086                if (!markers.length) return;
1087                const ctx = chart.ctx;
1088                const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1089                ctx.save();
1090                markers.forEach(m => {
1091                    const color = m.p.type === 'rakuten' ? (isDark ? '#F87171' : '#BF0000') : '#FF9900';
1092                    ctx.beginPath();
1093                    ctx.moveTo(m.x, m.y);
1094                    ctx.lineTo(m.x - 5, m.y + 8);
1095                    ctx.lineTo(m.x + 5, m.y + 8);
1096                    ctx.closePath();
1097                    ctx.fillStyle = color;
1098                    ctx.fill();
1099                    ctx.lineWidth = 1;
1100                    ctx.strokeStyle = isDark ? 'rgba(15,23,42,0.9)' : '#fff';
1101                    ctx.stroke();
1102                });
1103                // hover/タップ中のマーカーは期間名 tooltip を描画
1104                const hov = chart.$kxSaleMarkerHover;
1105                if (hov) {
1106                    const name = hov.p.name || (hov.p.type === 'rakuten' ? '楽天セール' : 'Amazonセール');
1107                    const range = `${_saleDateShort(hov.p.start)}〜${_saleDateShort(hov.p.end)}`;
1108                    ctx.font = '600 12px sans-serif';
1109                    const w1 = ctx.measureText(name).width;
1110                    ctx.font = '11px sans-serif';
1111                    const w2 = ctx.measureText(range).width;
1112                    const bw = Math.max(w1, w2) + 20, bh = 40;
1113                    const area = chart.chartArea;
1114                    let bx = Math.max(area.left, Math.min(hov.x - bw / 2, area.right - bw));
1115                    let by = hov.y - bh - 8;  // マーカー上に表示(軸ラベルと重ならない)
1116                    ctx.fillStyle = isDark ? 'rgba(15,23,42,0.95)' : 'rgba(255,255,255,0.97)';
1117                    ctx.strokeStyle = isDark ? '#334155' : '#E2E8F0';
1118                    ctx.lineWidth = 1;
1119                    ctx.beginPath();
1120                    ctx.roundRect(bx, by, bw, bh, 6);
1121                    ctx.fill();
1122                    ctx.stroke();
1123                    ctx.fillStyle = isDark ? '#E2E8F0' : '#1E293B';
1124                    ctx.font = '600 12px sans-serif';
1125                    ctx.fillText(name, bx + 10, by + 16);
1126                    ctx.fillStyle = isDark ? '#94A3B8' : '#64748B';
1127                    ctx.font = '11px sans-serif';
1128                    ctx.fillText(range, bx + 10, by + 32);
1129                }
1130                ctx.restore();
1131            },
1132            afterEvent(chart, args) {
1133                const markers = chart.$kxSaleMarkers || [];
1134                const ev = args.event;
1135                if (!ev || (ev.type !== 'mousemove' && ev.type !== 'click' && ev.type !== 'mouseout')) return;
1136                let hit = null;
1137                if (ev.type !== 'mouseout' && ev.x != null) {
1138                    for (const m of markers) {
1139                        if (Math.abs(ev.x - m.x) <= 8 && ev.y >= m.y - 4 && ev.y <= m.y + 14) { hit = m; break; }
1140                    }
1141                }
1142                if (hit !== (chart.$kxSaleMarkerHover || null)) {
1143                    chart.$kxSaleMarkerHover = hit;
1144                    args.changed = true;  // 再描画して tooltip を反映
1145                }
1146                if (chart.canvas) chart.canvas.style.cursor = hit ? 'pointer' : '';
1147            }
1148        };
1149
1150        // ==================== 期間ブラシ(チャート下のミニマップ範囲選択) ====================
1151        let _brushState = null; // { len, start, end }
1152        const _BRUSH_MIN_SPAN = 2; // 最小ウィンドウ = 3ポイント
1153        function renderBrush(history) {
1154            const brush = document.getElementById('chartBrush');
1155            if (!brush) return;
1156            if (!history || history.length < 8) { brush.style.display = 'none'; return; }
1157            brush.style.display = '';
1158            _drawBrushMini(history);
1159            _syncBrushWin();
1160        }
1161        function _drawBrushMini(history) {
1162            const c = document.getElementById('brushMini');
1163            if (!c || !history || !history.length) return;
1164            // 🔴 親が非表示だと clientWidth は 0。`|| 300` で代用すると **300px の
1165            //    描画バッファ**が作られ、CSS の width:100% で実幅へ引き伸ばされて
1166            //    ぼやける(履歴ビュー中は #chartBrush が display:none なので、
1167            //    その状態でテーマを切り替えると起きる)。描かずに帰るのが正解——
1168            //    表示に戻る時は renderChart が描き直す。
1169            if (!c.parentElement || c.parentElement.clientWidth === 0) return;
1170            const w = c.parentElement.clientWidth, h = c.parentElement.clientHeight || 36;
1171            const dpr = window.devicePixelRatio || 1;
1172            c.width = Math.round(w * dpr); c.height = Math.round(h * dpr);
1173            const ctx = c.getContext('2d');
1174            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1175            ctx.clearRect(0, 0, w, h);
1176            // 各日の最高値ライン
1177            const vals = history.map(d => {
1178                let m = null;
1179                Object.values(d.prices || {}).forEach(v => { if (v && (m === null || v > m)) m = v; });
1180                return m;
1181            });
1182            const nums = vals.filter(v => v !== null);
1183            if (!nums.length) return;
1184            let lo = Math.min(...nums), hi = Math.max(...nums);
1185            if (hi === lo) { hi += 1; lo -= 1; }
1186            const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1187            const px = i => history.length > 1 ? (i / (history.length - 1)) * w : w / 2;
1188            const py = v => 4 + (1 - (v - lo) / (hi - lo)) * (h - 8);
1189            ctx.beginPath();
1190            let started = false;
1191            vals.forEach((v, i) => {
1192                if (v === null) return;
1193                if (!started) { ctx.moveTo(px(i), py(v)); started = true; }
1194                else ctx.lineTo(px(i), py(v));
1195            });
1196            ctx.strokeStyle = isDark ? 'rgba(147,197,253,0.7)' : 'rgba(37,99,235,0.55)';
1197            ctx.lineWidth = 1.5;
1198            ctx.stroke();
1199            ctx.lineTo(px(history.length - 1), h); ctx.lineTo(px(0), h); ctx.closePath();
1200            ctx.fillStyle = isDark ? 'rgba(147,197,253,0.10)' : 'rgba(37,99,235,0.08)';
1201            ctx.fill();
1202        }
1203        function _syncBrushWin() {
1204            if (!_brushState) return;
1205            const win = document.getElementById('brushWin');
1206            const dl = document.getElementById('brushDimL');
1207            const dr = document.getElementById('brushDimR');
1208            if (!win) return;
1209            const n = Math.max(1, _brushState.len - 1);
1210            const l = (_brushState.start / n) * 100, r = (_brushState.end / n) * 100;
1211            win.style.left = l + '%';
1212            win.style.width = Math.max(0, r - l) + '%';
1213            if (dl) dl.style.width = l + '%';
1214            if (dr) dr.style.width = (100 - r) + '%';
1215        }
1216        function _applyBrushToChart() {
1217            if (!_brushState || !currentHistoryData) return;
1218            renderChart(currentHistoryData, 0, { start: _brushState.start, end: _brushState.end });
1219            // 期間セレクトの同期(末尾 N 日と一致すればその値、それ以外は「カスタム」)
1220            const span = _brushState.end - _brushState.start + 1;
1221            const atEnd = _brushState.end === _brushState.len - 1;
1222            const sel = document.getElementById('chartRangeSel');
1223            if (!sel) return;
1224            if (atEnd && _brushState.start === 0) sel.value = '180';        // 全範囲 = 全期間
1225            else if (atEnd && [7, 14, 30, 90].indexOf(span) >= 0) sel.value = String(span);
1226            else sel.value = '0';
1227        }
1228        (function bindBrush() {
1229            let drag = null; // { mode:'l'|'r'|'win', startX, s0, e0, rect }
1230            let raf = 0;
1231            function idxFromDx(dx, rect) {
1232                if (!_brushState) return 0;
1233                return Math.round(dx / Math.max(1, rect.width) * (_brushState.len - 1));
1234            }
1235            function onDown(e) {
1236                const t = e.target;
1237                if (!_brushState) return;
1238                const brush = document.getElementById('chartBrush');
1239                if (!brush || !brush.contains(t)) return;
1240                const mode = t.id === 'brushHL' ? 'l' : t.id === 'brushHR' ? 'r' : (t.id === 'brushWin' ? 'win' : null);
1241                if (!mode) return;
1242                e.preventDefault();
1243                drag = { mode, startX: e.clientX, s0: _brushState.start, e0: _brushState.end, rect: brush.getBoundingClientRect() };
1244                try { t.setPointerCapture(e.pointerId); } catch (err) {}
1245            }
1246            function onMove(e) {
1247                if (!drag || !_brushState) return;
1248                const di = idxFromDx(e.clientX - drag.startX, drag.rect);
1249                const len = _brushState.len;
1250                let s = drag.s0, en = drag.e0;
1251                if (drag.mode === 'l') s = Math.min(Math.max(0, drag.s0 + di), en - _BRUSH_MIN_SPAN);
1252                else if (drag.mode === 'r') en = Math.max(Math.min(len - 1, drag.e0 + di), s + _BRUSH_MIN_SPAN);
1253                else { // win: 幅を保ったままパン
1254                    const spanW = drag.e0 - drag.s0;
1255                    s = Math.min(Math.max(0, drag.s0 + di), len - 1 - spanW);
1256                    en = s + spanW;
1257                }
1258                if (s === _brushState.start && en === _brushState.end) return;
1259                _brushState.start = s; _brushState.end = en;
1260                _syncBrushWin();
1261                if (!raf) raf = requestAnimationFrame(() => { raf = 0; _applyBrushToChart(); });
1262            }
1263            function onUp() { drag = null; }
1264            document.addEventListener('pointerdown', onDown);
1265            document.addEventListener('pointermove', onMove);
1266            document.addEventListener('pointerup', onUp);
1267            document.addEventListener('pointercancel', onUp);
1268            let rsz = 0;
1269            window.addEventListener('resize', () => {
1270                clearTimeout(rsz);
1271                rsz = setTimeout(() => { if (currentHistoryData) { _drawBrushMini(currentHistoryData); _syncBrushWin(); } }, 200);
1272            });
1273        })();
1274
1275        // 指定日(ISO)時点で有効な定価。無ければ null。
1276        function msrpAt(dateIso) {
1277            if (!currentMsrpHistory || !dateIso) return null;
1278            let v = null;
1279            for (const e of currentMsrpHistory) {
1280                if (e.from <= dateIso) v = e.msrp; else break;
1281            }
1282            return v;
1283        }
1284
1285        // 買取率 = 買取価格 ÷ 定価(%表示、小数1桁)
1286        function kaitoriRate(price, msrp) {
1287            if (!msrp || !price || price <= 0) return null;
1288            return Math.round(price / msrp * 1000) / 10;
1289        }
1290
1291        // ==================== 価格変動履歴(直近14日・2026-09-05)====================
1292        // グラフと同じカードの 2 枚目。源は price_updates(scanner が前回と違う値を
1293        // 書いた時だけ 1 行積むイベントログ)=**動いた瞬間しか行が無い**。
1294        // 🔴 日次スナップショットで作らない理由: 有価 24,935 JAN のうち直近 30 日に
1295        //    1 度でも動いたのは 11,432 件(46%)だけ、動いた側も 78% は ≤5 日しか
1296        //    動かない=日次表は半分以上の商品が「±0」の空行になる(実測 2026-09-05)。
1297        // 🔴 記録は無期限・表示だけ 14 日。絞り込みは端点の WHERE と下の PLOG_DAYS
1298        //    だけにあり、price_updates 側に保持期限は掛けていない。
1299        var PLOG_DAYS = 14;
1300        var PLOG_PAGE = 20;             // 1 ページ。実測 97 件の商品があり全部出すと読めない
1301        var _plogLoadedJan = null;      // どの JAN の履歴を出しているか(商品を跨いだ再利用防止)
1302        var _productReadyJan = null;    // `/api/product/{jan}` を await 済み=marker が書かれた JAN
1303        var _plogAll = [];              // 取得した全件(フィルタ前)。再取得せずに絞り込むため保持
1304        var _plogPage = 1;              // 1 始まりのページ番号
1305        // 🔴 凡例で**明示的に消された**店だけを入れる(= グラフの全 key − 表示中 key)。
1306        //    「表示中に無い」で判定してはいけない: 履歴の 14 日窓には居るがグラフの
1307        //    期間には点が無い店(下架済み等)が居て、それを勝手に隠すことになる。
1308        var _plogHidden = new Set();
1309
1310        function plogYen(n) { return 'Â¥' + Number(n).toLocaleString('ja-JP'); }
1311
1312        function _setPlogLive(text) {
1313            var live = document.getElementById('plogLive');
1314            if (live) live.textContent = text;
1315        }
1316
1317        function _plogVisible() {
1318            if (!_plogHidden.size) return _plogAll;
1319            return _plogAll.filter(function (c) { return !_plogHidden.has(c.store_key); });
1320        }
1321
1322        // ページ番号ボタン。全ページ出すと 97 件=5 ページ程度でも横に溢れるので、
1323        // 先頭・末尾+現在ページの前後 2 つだけ出して間は「…」で畳む。
1324        function _plogPagerHtml(totalPages) {
1325            if (totalPages <= 1) return '';
1326            var cur = _plogPage;
1327            var want = new Set([1, totalPages]);
1328            for (var i = cur - 2; i <= cur + 2; i++) if (i >= 1 && i <= totalPages) want.add(i);
1329            var nums = Array.from(want).sort(function (a, b) { return a - b; });
1330            var html = '<button type="button" class="plog-pg plog-pg-nav" data-p="' + (cur - 1) + '"' +
1331                       (cur === 1 ? ' disabled' : '') + ' aria-label="前のページ">‹</button>';
1332            var prev = 0;
1333            nums.forEach(function (n) {
1334                if (prev && n - prev > 1) html += '<span class="plog-pg-gap">…</span>';
1335                html += '<button type="button" class="plog-pg' + (n === cur ? ' active' : '') +
1336                        '" data-p="' + n + '"' + (n === cur ? ' aria-current="page"' : '') + '>' + n + '</button>';
1337                prev = n;
1338            });
1339            html += '<button type="button" class="plog-pg plog-pg-nav" data-p="' + (cur + 1) + '"' +
1340                    (cur === totalPages ? ' disabled' : '') + ' aria-label="次のページ">›</button>';
1341            // 手入力でページを飛ばす。番号を畳んでいる(…)ぶん、遠いページへは
1342            // これが唯一の 1 手。inputmode=numeric でモバイルはテンキーが出る。
1343            html += '<span class="plog-pg-jump">'
1344                 +  '<input type="number" class="plog-pg-input" inputmode="numeric" min="1" max="' + totalPages + '"'
1345                 +  ' value="' + cur + '" aria-label="ページ番号を入力">'
1346                 +  '<span class="plog-pg-total">/ ' + totalPages + '</span></span>';
1347            return '<div class="plog-pager" role="navigation" aria-label="価格変動履歴のページ">' + html + '</div>';
1348        }
1349
1350        function renderPriceLog(reset) {
1351            var pane = document.getElementById('logPane');
1352            if (!pane) return;
1353            var list = _plogVisible();
1354            if (reset) _plogPage = 1;
1355
1356            // 🔴 空表示の時こそ読み上げに伝える。ここで return して live 領域を
1357            //    置き去りにすると、「すべて非表示」を押した読み上げ利用者には
1358            //    **前のページの件数が残ったまま**何も知らされã
1358ªã„。
1359            if (!_plogAll.length) {
1360                var m0 = '直近' + PLOG_DAYS + '日間、買取価格の変動はありません。';
1361                pane.innerHTML = '<div class="plog-empty">' + m0 + '</div>';
1362                _setPlogLive(m0);
1363                return;
1364            }
1365            if (!list.length) {
1366                // 🔴 全店を消した時。「変動が無い」と同じ空表示にすると、絞り込みで
1367                //    空なのか本当に動いていないのか区別が付かない。
1368                var m1 = '表示中の店舗がありません。下の凡例で店舗を選んでください。';
1369                pane.innerHTML = '<div class="plog-empty">' + m1 + '</div>';
1370                _setPlogLive(m1);
1371                return;
1372            }
1373
1374            var totalPages = Math.max(1, Math.ceil(list.length / PLOG_PAGE));
1375            // 絞り込みで件数が減って現在ページが消えることがある(97 件 5 ページで
1376            // 5 ページ目にいる時に 1 店だけ残すと 1 ページしか無くなる)
1377            if (_plogPage > totalPages) _plogPage = totalPages;
1378            if (_plogPage < 1) _plogPage = 1;
1379            var from = (_plogPage - 1) * PLOG_PAGE;
1380            var to = Math.min(from + PLOG_PAGE, list.length);
1381
1382            var rows = list.slice(from, to).map(function (c) {
1383                var when  = '<div class="plog-when">' + escapeHtml((c.at || '').slice(5)) + '</div>';
1384                var store = '<span class="plog-store">' + escapeHtml(c.store) + '</span>';
1385                var move, tag;
1386                if (c.kind === 'listed') {
1387                    // 🔴 0 は価格ではなく「その店に掲載が無い」状態。引き算して
1388                    //    ▲192,600 の暴騰として出さない(直近 14 日 81,148 行のうち
1389                    //    新規 16,598・終了 12,013=35% が 0 絡み=エッジケースではない)。
1390                    move = '<span class="plog-move"><span class="plog-new">' + plogYen(c.new_price) + '</span></span>';
1391                    tag  = '<span class="plog-tag new" title="この店に新しく掲載されました">NEW</span>';
1392                } else if (c.kind === 'delisted') {
1393                    move = '<span class="plog-move"><span class="plog-old">' + plogYen(c.old_price) + '</span></span>';
1394                    tag  = '<span class="plog-tag gone" title="この店の掲載が終了しました">END</span>';
1395                } else {
1396                    move = '<span class="plog-move"><span class="plog-old">' + plogYen(c.old_price) +
1397                           '</span> → <span class="plog-new">' + plogYen(c.new_price) + '</span></span>';
1398                    var up = c.diff > 0;
1399                    tag = '<span class="plog-diff ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') +
1400                          Number(Math.abs(c.diff)).toLocaleString('ja-JP') + '</span>';
1401                }
1402                // 🔴 grid の列に載せるので **4 つとも .plog-row の直下**に置く。
1403                //    店名と値動きを div で包むと grid の子は 3 つになり、値動きの
1404                //    開始位置が店名の長さで行ごとにずれる(=縦に読めない)し、
1405                //    店名の ellipsis も効かない(inline 要素は省略されない)。
1406                return '<div class="plog-row">' + when + store + move + tag + '</div>';
1407            }).join('');
1408
1409            // 絞り込み中は「何件中いくつか」を出す。総数だけだと凡例で消したぶんが
1410            // 消えたのか元から無いのか分からない。
1411            var scope = _plogHidden.size
1412                ? PLOG_DAYS + '日間で ' + list.length + ' 件(全 ' + _plogAll.length + ' 件中・凡例で絞り込み中)'
1413                : PLOG_DAYS + '日間で ' + list.length + ' 件の変動';
1414            var range = totalPages > 1 ? ' ' + (from + 1) + '–' + to + ' 件目' : '';
1415            pane.innerHTML = '<div class="plog">' + rows + '</div>' +
1416                _plogPagerHtml(totalPages) +
1417                '<div class="plog-foot">' + scope + range + '</div>';
1418            _setPlogLive(scope + (range ? '、' + range.trim() : ''));
1419        }
1420
1421        // ページャはクリックのたびに作り直すので、リスナは pane に 1 回だけ委譲する。
1422        function setupPriceLogPager() {
1423            var pane = document.getElementById('logPane');
1424            if (!pane || pane.dataset.pagerBound) return;
1425            pane.dataset.pagerBound = '1';
1426            // 🔴 ペイン自体を live にしない。innerHTML を丸ごと差し替えるので全ノードが
1427            //    「新規」扱いになり、ページを送るたび 20 行+ページャ+フッタを全部
1428            //    読み上げてしまう。読ませたいのは「N–M 件目」だけなので、専用の
1429            //    見えない小領域を 1 つ置き、そこにだけ範囲の文字列を入れる。
1430            if (!document.getElementById('plogLive')) {
1431                var live = document.createElement('div');
1432                live.id = 'plogLive';
1433                live.className = 'sr-only';
1434                live.setAttribute('aria-live', 'polite');
1435                live.setAttribute('aria-atomic', 'true');
1436                pane.parentNode.insertBefore(live, pane);
1437            }
1438
1439            function goPage(n) {
1440                if (!n || n === _plogPage) return;
1441                _plogPage = n;
1442                renderPriceLog(false);
1443                // ページを変えたら 1 行目が見える位置へ。押した位置が末尾なので、
1444                // そのままだと新しいページの最後尾を見ることになる。
1445                try { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) { pane.scrollIntoView(); }
1446            }
1447
1448            pane.addEventListener('click', function (e) {
1449                // 🔴 `.plog-pg` は入力欄にも前方一致しないよう別クラスにしてある
1450                //    (`.plog-pg-input` は button ではないので closest には掛からない)
1451                var btn = e.target.closest ? e.target.closest('button.plog-pg') : null;
1452                if (!btn || btn.disabled) return;
1453                goPage(parseInt(btn.dataset.p, 10));
1454            });
1455
1456            function jumpFromInput(el) {
1457                var total = parseInt(el.max, 10) || 1;
1458                var n = parseInt(el.value, 10);
1459                // 空・非数字・範囲外は**飛ばずに現在ページへ戻す**。勝手に 1 ページ目へ
1460                // 飛ばすと、打ち間違いで読んでいた場所を失う。
1461                if (!n || n < 1 || n >
1461 total) { el.value = String(_plogPage); return; }
1462                if (n === _plogPage) return;
1463                goPage(n);
1464            }
1465            pane.addEventListener('keydown', function (e) {
1466                if (e.key !== 'Enter' || !e.target.classList.contains('plog-pg-input')) return;
1467                e.preventDefault();
1468                e.target.blur();          // モバイルのキーボードを閉じてから描き直す
1469                jumpFromInput(e.target);
1470            });
1471            pane.addEventListener('change', function (e) {
1472                if (e.target.classList.contains('plog-pg-input')) jumpFromInput(e.target);
1473            });
1474        }
1475
1476        // 凡例トグルからの再描画。履歴が閉じている時は何もしない(開いた時に描かれる)
1477        function refreshPriceLogFilter(hiddenKeys) {
1478            _plogHidden = hiddenKeys || new Set();
1479            var pane = document.getElementById('logPane');
1480            if (pane && !pane.hidden && _plogAll.length) renderPriceLog(true);
1481        }
1482
1483        async function loadPriceLog() {
1484            var pane = document.getElementById('logPane');
1485            if (!pane || !currentJan) return;
1486            if (_plogLoadedJan === currentJan) {
1487                // 🔴 取得済みでも**描き直す**。グラフ側に居る間に凡例で店を消しても
1488                //    `refreshPriceLogFilter` は非表示のペインを描かない(開いた時に
1489                //    描く約束)ので、ここで return すると絞り込みが効いていない古い
1490                //    HTML がそのまま出る(再現: 履歴→グラフ→凡例で店を消す→履歴)。
1491                // 🔴 reset は渡さない。絞り込みを反映し直したいだけで、
1492                //    ページまで先頭に戻すと 5 ページある商品で往復のたび読む場所を失う。
1493                //    件数が減って現在ページが消えた時は renderPriceLog 側が丸める。
1494                renderPriceLog(false);
1495                return;
1496            }
1497            // 🔴 「読み込み中…」はゲートの**後**でだけ書く。ここに置くと、ゲートが
1498            //    案内文で上書きするので描画されない死んだ書き込みになり、将来この間に
1499            //    await が入った瞬間に一瞬だけ誤った状態が見える。
1500            // 🔴 await をまたぐので JAN を**固定**する。`currentJan` を後で読み直すと、
1501            //    待っている間に別商品へ移った時に「_plogLoadedJan は B・中身は A」に
1502            //    なり、早期 return のせいで**二度と取り直されない**(商品 B に商品 A の
1503            //    変動履歴が永久に出る)。
1504            var reqJan = currentJan;
1505            // 🔴 `/api/product/{jan}` が dp_seen marker を書く**前**に /changes を撃たない。
1506            //    #loading は覆い被さらないので、読み込み中でも前の商品のタブが押せる。
1507            //    そこで押されると marker 無しで 403 →「セッションが切れました」の
1508            //    偽モーダルが出る(/history が :319 で同じ順序を守っているのと同じ理由)。
1509            // 🔴 黙って return しない。「読み込み中…」のまま固まると、商品取得が失敗した時
1510            //    (catch は setPriceLogView を呼ばない)や showProduct が前後して完了した
1511            //    時に履歴タブが永久に死ぬ。案内を出して再タップで直せる形にする。
1512            if (_productReadyJan !== reqJan) {
1513                var mW = '商品の読み込みが終わってから、もう一度「履歴」を押してください。';
1514                pane.innerHTML = '<div class="plog-empty">' + mW + '</div>';
1515                _setPlogLive(mW);
1516                // 🔴 ここに `loaded = false` と書いていたが、そんな変数は無い
1517                //    (product.html 初稿の残骸)。'use strict' なので ReferenceError に
1518                //    なり、async 関数の unhandled rejection として毎回投げていた。
1519                //    状態は `_plogLoadedJan` が持っていて、この時点で既に currentJan と
1520                //    食い違っている=再タップで普通にやり直せるのでリセットは要らない。
1521                return;
1522            }
1523            pane.innerHTML = '<div class="plog-empty">読み込み中…</div>';
1524            try {
1525                var data = await apiCall('/api/product/' + reqJan + '/changes?days=' + PLOG_DAYS);
1526                if (reqJan !== currentJan) return;      // 待っている間に別商品へ移った
1527                if (data && data.error) throw new Error('server');
1528                _plogLoadedJan = reqJan;
1529                _plogAll = data.changes || [];
1530                renderPriceLog(true);
1531            } catch (e) {
1532                // 🔴 黙って空にしない。空表示は「変動なし」と区別が付かず、
1533                //    壊れているのに健康に見える([[静音は健康と見分けが付かない]])。
1534                _plogLoadedJan = null;                      // 再タップで再試行できる
1535                var mE = '履歴を読み込めませんでした。もう一度「履歴」を押してください。';
1536                pane.innerHTML = '<div class="plog-empty">' + mE + '</div>';
1537                _setPlogLive(mE);
1538            }
1539        }
1540
1541        // グラフ専用の操作(セール帯・期間セレクタ・brush・期間統計)は履歴では意味が
1542        // 無いので一緒に隠す。canvas を残したまま重ねると Chart.js の resize が走る。
1543        // いま履歴ビューかどうか(グラフ専用の描画を抑える判定に使う)
1544        function _isPriceLogView() {
1545            var p = document.getElementById('logPane');
1546            return !!(p && !p.hidden);
1547        }
1548
1549        function setPriceLogView(showLog) {
1550            // 🔴 「すべて非表示」だけは隠さない=あれは凡例の一括トグルで、履歴表の
1551            //    絞り込みにも効く。定価トグルは店舗ではないので表には無関係=隠す。
1552            // 🔴 `#noHistory` を入れ忘れない。あれは `.chart
1552-container` の**兄弟**なので
1553            //    コンテナを隠しても残り、履歴が出ている真下に「履歴データがありません」が
1554            //    並ぶ(グラフ用の履歴が無い=登録直後の商品で実際に起きる)。
1555            var ids = ['chartSaleBtn', 'chartRangeSel', 'chartSaleNote', 'chartBrush',
1556                       'chartPeriodStats', 'chartMsrpToggle', 'noHistory'];
1557            var chartBox = document.querySelector('.chart-section .chart-container');
1558            var pane = document.getElementById('logPane');
1559            var tabC = document.getElementById('tabChartView');
1560            var tabL = document.getElementById('tabLogView');
1561            if (!pane || !tabC || !tabL) return;
1562            if (chartBox) chartBox.hidden = showLog;
1563            pane.hidden = !showLog;
1564            ids.forEach(function (id) {
1565                var el = document.getElementById(id);
1566                if (!el) return;
1567                if (showLog) {
1568                    // 元の表示状態を覚えてから隠す(display:none で始まる物がある)
1569                    if (el.dataset.plogPrev === undefined) el.dataset.plogPrev = el.style.display || '';
1570                    el.style.display = 'none';
1571                } else if (el.dataset.plogPrev !== undefined) {
1572                    el.style.display = el.dataset.plogPrev;
1573                    delete el.dataset.plogPrev;
1574                }
1575            });
1576            tabC.classList.toggle('active', !showLog);
1577            tabL.classList.toggle('active', showLog);
1578            tabC.setAttribute('aria-selected', String(!showLog));
1579            tabL.setAttribute('aria-selected', String(showLog));
1580            // roving tabindex: tablist 全体で Tab 停止は 1 回、左右キーで移動(ARIA tabs の作法)
1581            tabC.tabIndex = showLog ? -1 : 0;
1582            tabL.tabIndex = showLog ? 0 : -1;
1583            // 🔴 隠れている間はミニマップを描いていない(0 幅ガード)。戻した時に
1584            //    描き直さないと、履歴中にテーマを変えた場合は前のテーマの色が残る。
1585            if (!showLog && currentHistoryData && currentHistoryData.length) {
1586                var _bw = document.getElementById('chartBrush');
1587                if (_bw && _bw.style.display !== 'none') _drawBrushMini(currentHistoryData);
1588            }
1589            if (showLog) loadPriceLog();
1590        }
1591
1592        function setupPriceLogTabs() {
1593            var tabC = document.getElementById('tabChartView');
1594            var tabL = document.getElementById('tabLogView');
1595            if (!tabC || !tabL || tabC.dataset.bound) return;
1596            tabC.dataset.bound = '1';
1597            tabC.addEventListener('click', function () { setPriceLogView(false); });
1598            tabL.addEventListener('click', function () { setPriceLogView(true); });
1599            // 左右/Home/End で切り替え、移動先へフォーカスを送る
1600            [tabC, tabL].forEach(function (btn) {
1601                btn.addEventListener('keydown', function (e) {
1602                    var toLog;
1603                    // 🔴 tab は 2 枚しかないので、ARIA の作法(端で折り返す)に従うと
1604                    //    左右どちらの矢印でも「もう一方」へ行くのが正しい。
1605                    //    向きで場合分けすると、履歴で ← を押した時に
1606                    //    `!(btn === tabC)` が true になって**その場に留まる**。
1607                    // 🔴 横並びの tablist なので ↑↓ は**受け取らない**(APG では縦 tablist 用)。
1608                    //    preventDefault まですると、タブにフォーカスがある間は矢印キーで
1609                    //    ページをスクロールできなくなる。
1610                    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') toLog = (btn === tabC);
1611                    else if (e.key === 'Home') toLog = false;   // 先頭=グラフ
1612                    else if (e.key === 'End')  toLog = true;    // 末尾=履歴
1613                    else return;
1614                    e.preventDefault();
1615                    setPriceLogView(toLog);
1616                    (toLog ? tabL : tabC).focus();
1617                });
1618            });
1619        }
1620
1621        function renderChart(history, days, range) {
1622            if (typeof Chart === 'undefined') {
1623                console.error("Chart.js 未加載");
1624                // 优雅降级处理
1625                return;
1626            }
1627            const canvas = document.getElementById('priceChart');
1628            const ctx = canvas.getContext('2d');
1629            let recentHistory;
1630            if (range) {
1631                recentHistory = history.slice(range.start, range.end + 1);
1632            } else {
1633                recentHistory = history.slice(-days);
1634                // 期間ボタン経由: ブラシ窓を末尾 N 日へリセット
1635                _brushState = { len: history.length, start: Math.max(0, history.length - recentHistory.length), end: history.length - 1 };
1636            }
1637            renderBrush(history);
1638
1639            if (recentHistory.length === 0) {
1640                elements.noHistory.style.display = 'flex';
1641                canvas.style.display = 'none';
1642                if (priceChart) { priceChart.destroy(); priceChart = null; }
1643                const legendSection = document.getElementById('chartLegendSection');
1644                if (legendSection) legendSection.style.display = 'none';
1645                const statsEl = document.getElementById('chartPer
1645iodStats');
1646                if (statsEl) statsEl.style.display = 'none';
1647                return;
1648            }
1649
1650            elements.noHistory.style.display = 'none';
1651            canvas.style.display = 'block';
1652
1653            const storeKeys = new Set();
1654            recentHistory.forEach(day => {
1655                Object.keys(day.prices || {}).forEach(key => storeKeys.add(key));
1656            });
1657
1658            const storeNames = KX_STORE_NAME_BY_KEY;
1659
1660            // 買取中断(価格0/欠測)区間は破線で橋渡し(2026-07-25 May 裁決:
1661            // 断線ではなく虚線接続——中断が見て分かりつつ流れも追える)
1662            const _gapDash = (ctx) => (ctx.p0.skip || ctx.p1.skip) ? [5, 5] : undefined;
1663            const datasets = Array.from(storeKeys).map(key => ({
1664                label: storeNames[key] || key,
1665                data: recentHistory.map(day => day.prices?.[key] || null),
1666                borderColor: STORE_COLORS[key] || '#94A3B8',
1667                backgroundColor: (STORE_COLORS[key] || '#94A3B8') + '20',
1668                // 点円は非表示(20店×90日で視覚ノイズになるため)。hover 時のみ点を出す
1669                tension: 0.3, fill: false, pointRadius: 0, pointHoverRadius: 5, pointHitRadius: 8,
1670                spanGaps: true, segment: { borderDash: _gapDash }
1671            }));
1672
1673            // 定価点線(iPhone/主要ゲーム機のみ)。改定日があれば階段状に変化する。
1674            const _msrpLine = recentHistory.map(day => msrpAt(day.date_iso || ''));
1675            if (_msrpLine.some(v => v != null)) {
1676                const _isDarkPre = document.documentElement.getAttribute('data-theme') === 'dark';
1677                datasets.push({
1678                    label: '定価',
1679                    data: _msrpLine,
1680                    borderColor: _isDarkPre ? 'rgba(148,163,184,0.85)' : '#94A3B8',
1681                    backgroundColor: 'transparent',
1682                    borderDash: [6, 4], borderWidth: 1.5, stepped: true,
1683                    tension: 0, fill: false, pointRadius: 0, pointHoverRadius: 0, spanGaps: true
1684                });
1685            }
1686
1687            // 保存当前隐藏的 dataset
1688            const hiddenLabels = new Set();
1689            if (priceChart) {
1690                priceChart.data.datasets.forEach((ds, i) => {
1691                    if (priceChart.getDatasetMeta(i).hidden) hiddenLabels.add(ds.label);
1692                });
1693                priceChart.destroy();
1694            }
1695
1696            // 恢复隐藏状态
1697            datasets.forEach(ds => {
1698                if (hiddenLabels.has(ds.label)) ds.hidden = true;
1699            });
1700
1701            const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1702            const gridColor = isDark ? 'rgba(255,255,255,0.08)' : '#E2E8F0';
1703            const tickColor = isDark ? '#64748B' : '#64748B';
1704            const legendColor = isDark ? '#94A3B8' : '#64748B';
1705            const tooltipBg = isDark ? 'rgba(15,23,42,0.95)' : 'rgba(255,255,255,0.95)';
1706            const tooltipFg = isDark ? '#E2E8F0' : '#1E293B';
1707            const tooltipBorder = isDark ? '#334155' : '#E2E8F0';
1708
1709            ensureSalePeriods();
1710            priceChart = new Chart(ctx, {
1711                type: 'line',
1712                plugins: [_saleBandPlugin, _singlePointStubPlugin],
1713                data: { labels: recentHistory.map(day => day.date), datasets: datasets },
1714                options: {
1715                    responsive: true, maintainAspectRatio: false,
1716                    interaction: { intersect: false, mode: 'index' },
1717                    plugins: {
1718                        legend: { display: false },
1719                        // Canvas 描画 tooltip を無効化し HTML 外部 tooltip で多店舗対応
1720                        tooltip: {
1721                            enabled: false,
1722                            external: chartTooltipExternal,
1723                            itemSort: (a, b) => (b.parsed.y || 0) - (a.parsed.y || 0)
1724                        }
1725                    },
1726                    scales: {
1727                        x: { grid: { color: gridColor }, ticks: { color: tickColor, padding: 14 } },  // padding: セールマーカー(▲)の表示スペース
1728                        y: { grid: { color: gridColor }, ticks: { color: tickColor, callback: value =>
1728 'Â¥' + value.toLocaleString() } }
1729                    }
1730                }
1731            });
1732
1733            // 表示中店舗 key の Set を確定(label→key 逆引きは不安定なので key 基準で構築)
1734            // datasets は Array.from(storeKeys).map(key => ({ label: storeNames[key]||key, ... }))
1735            // で作られているため、index が storeKeys の順序と一致する。
1736            // hiddenLabels に含まれる label を持つ dataset の key を除外して Set を作る。
1737            _currentRecentHistory = recentHistory;
1738            _visibleStoreKeys = new Set(
1739                Array.from(storeKeys).filter((key, i) => !hiddenLabels.has(storeNames[key] || key))
1740            );
1741
1742            _bindChartTooltipDismiss();
1743            renderChartLegend();
1744            renderChartStats(recentHistory, _visibleStoreKeys);
1745            // 凡例の隠し状態は再描画をまたいで引き継がれる(hiddenLabels)ので、
1746            // 表側の絞り込みもここで同じ内容に揃える
1747            refreshPriceLogFilter(new Set(
1748                Array.from(storeKeys).filter(function (key) { return hiddenLabels.has(storeNames[key] || key); })
1749            ));
1750            _watchChartVisibility();
1751        }
1752
1753        // ==================== 期間統計欄(最高・最低・平均) ====================
1754        // visibleKeys: 表示中の店舗 key の Set。null の場合は全店舗を対象とする(フォールバック)。
1755        function renderChartStats(recentHistory, visibleKeys) {
1756            const el = document.getElementById('chartPeriodStats');
1757            const elHigh = document.getElementById('cpsHigh');
1758            const elHighDate = document.getElementById('cpsHighDate');
1759            const elLow = document.getElementById('cpsLow');
1760            const elLowDate = document.getElementById('cpsLowDate');
1761            const elAvg = document.getElementById('cpsAvg');
1762            if (!el || !elHigh || !elLow || !elAvg) return;
1763
1764            // 全ポイントを収集(日付と価格のペアとして保持)
1765            // visibleKeys が指定されていれば、その key に含まれる店舗だけを集計する。
1766            // 同じ最高値 / 最低値が複数日にあるとき「最も早い(最古)日付」を採用するため
1767            // 昇順(古い日から新しい日)でスキャンし、最初にヒットしたものを記録する。
1768            let maxVal = null, maxDate = '';
1769            let minVal = null, minDate = '';
1770            let sum = 0, count = 0;
1771
1772            recentHistory.forEach(day => {
1773                const dayDate = day.date_iso || day.date || '';
1774                Object.entries(day.prices || {}).forEach(([key, price]) => {
1775                    // 非表示の店舗 key は集計から除外する
1776                    if (visibleKeys && !visibleKeys.has(key)) return;
1777                    if (!price || price <= 0) return;
1778                    sum += price;
1779                    count++;
1780                    // 最高: 現在の maxVal より真に大きいときだけ更新(同値は最初の日を保持 = 最古)
1781                    if (maxVal === null || price > maxVal) {
1782                        maxVal = price;
1783                        maxDate = dayDate;
1784                    }
1785                    // 最低: 同様
1786                    if (minVal === null || price < minVal) {
1787                        minVal = price;
1788                        minDate = dayDate;
1789                    }
1790                });
1791            });
1792
1793            if (count === 0) {
1794                el.style.display = 'none';
1795                return;
1796            }
1797
1798            const avg = Math.round(sum / count);
1799            elHigh.textContent = 'Â¥' + maxVal.toLocaleString();
1800            elHighDate.textContent = maxDate || '';
1801            elLow.textContent = 'Â¥' + minVal.toLocaleString();
1802            elLowDate.textContent = minDate || '';
1803            elAvg.textContent = 'Â¥' + avg.toLocaleString();
1804
1805            // 定価セル: 現行定価 + 表示期間内の改定ログ(例: ¥194,800 → 07/18改定)
1806            const elMsrp = document.getElementById('cpsMsrp');
1807            if (elMsrp) {
1808                const elMV = document.getElementById('cpsMsrpValue');
1809                const elMN = document.getElementById('cpsMsrpNote');
1810                const firstIso = recentHistory[0]?.date_iso || '';
1811                const lastIso = recentHistory[recentHistory.length - 1]?.date_iso || '';
1812                const cur = msrpAt(lastIso);
1813                if (cur && elMV && elMN) {
1814                    elMV.textContent = 'Â¥' + cur.toLocaleString();
1815                    const changes = (currentMsrpHistory || []).filter(e => e.from > firstIso && e.from <= lastIso);
1816                    if (changes.length) {
1817                        const lastChg = changes[changes.length - 1];
1818                        const idx = currentMsrpHistory.indexOf(lastChg);
1819                        const prev = idx > 0 ? currentMsrpHistory[idx - 1].msrp : null;
1820                        const md = lastChg.from.slice(5).replace('-', '/');
1821                        elMN.textContent = (prev ? `¥${prev.toLocaleString()} → ` : '') + `${md}改定`
1822                            + (changes.length > 1 ? `(期間内${changes.length}回)` : '');
1823                        elMsrp.title = changes.map(c => {
1824                            const i2 = currentMsrpHistory.indexOf(c);
1825                            const p2 = i2 > 0 ? currentMsrpHistory[i2 - 1].msrp : null;
1826                            return `${c.from}: ${p2 ? '¥' + p2.toLocaleString() + ' → ' : ''}¥${c.msrp.toLocaleString()}`;
1827                        }).join('\n');
1828                    } else {
1829                        elMN.textContent = '';
1830                        elMsrp.title = '定価(税込)・表示期間内の改定なし';
1831                    }
1832                    elMsrp.style.display = '';
1833                } else {
1834                    elMsrp.style.display = 'none';
1835                }
1836            }
1837            el.style.display = '';
1838        }
1839
1840        // 凡例トグル後に統計を再計算する。_visibleStoreKeys を Chart.js の現状から
1841        // 再構築したうえで renderChartStats を呼ぶ。
1842        function _recomputeChartStats() {
1843            if (!priceChart || !_currentRecentHistory) return;
1844            const storeNames = KX_STORE_NAME_BY_KEY;
1845            // 現在 priceChart に登録されている dataset の hidden 状態から表示中 key Set を再構築。
1846            // dataset.label = storeNames[key] || key で作られているので、
1847            // KX_STORE_NAME_BY_KEY の逆引き Map を使って key に戻す。
1848            const labelToKey = {};
1849            Object.entries(storeNames).forEach(([k, v]) => { labelToKey[v] = k; });
1850            const keys = new Set();
1851            priceChart.data.datasets.forEach((ds, i) => {
1852                if (ds.label === '定価') return; // 定価点線は店舗ではないので統計対象外
1853                if (priceChart.getDatasetMeta(i).hidden) return;
1854                // label が storeNames に存在する場合は key に戻す。
1855                // storeNames に存在しない場合は key そのものが label なので直接使う。
1856                const key = labelToKey[ds.label] || ds.label;
1857                keys.add(key);
1858            });
1859            _visibleStoreKeys = keys;
1860            renderChartStats(_currentRecentHistory, _visibleStoreKeys);
1861            // 🔴 `renderChartStats` は末尾で #chartPeriodStats に display='' を**無条件で**
1862            //    書く。凡例チップと「すべて非表示」は履歴ビューでも生かしてある(表の
1863            //    絞り込み手段だから)ので、履歴のまま押すとグラフ専用の期間統計が
1864            //    表の下に生えてくる。plogPrev は既に立っているので setPriceLogView は
1865            //    もう隠し直してくれない=タブを押し直すまで残る。ここで閉じておく。
1866            if (!_isPriceLogView()) { /* グラフ表示中はそのまま */ }
1867            else { var _cps = document.getElementById('chartPeriodStats');
1868                   if (_cps) _cps.style.display = 'none'; }
1869            // 凡例はグラフだけでなく**履歴表も**絞る(May 指示 2026-09-05)。
1870            // 🔴 「表示中に無い=隠す」ではなく「グラフの全 key − 表示中 key」を隠す。
1871            //    履歴の 14 日窓にはグラフの期間に点が無い店(下架済み等)も居るので、
1872            //    前者だと触ってもいない店の行が勝手に消える。
1873            var _allKeys = new Set();
1874            priceChart.data.datasets.forEach(function (ds) {
1875                if (ds.label === '定価') return;
1876                _allKeys.add(labelToKey[ds.label] || ds.label);
1877            });
1878            var _hidden = new Set();
1879            _allKeys.forEach(function (k) { if (!keys.has(k)) _hidden.add(k); });
1880            refreshPriceLogFilter(_hidden);
1881        }
1882
1883        // iOS Safari はメモリ圧で画面外 canvas の描画バッファを破棄することがあり、
1884        // スクロールで到達した時に真っ白なチャートになる。ビューポート進入時に
1885        // 再描画して復元する(canvas 要素は固定なので observe は 1 回だけ)。
1886        let _chartRedrawIO = null;
1887        function _watchChartVisibility() {
1888            if (_chartRedrawIO || !('IntersectionObserver' in window)) return;
1889            const canvas = document.getElementById('priceChart');
1890            if (!canvas) return;
1891            _chartRedrawIO = new IntersectionObserver(function (entries) {
1892                entries.forEach(function (e) {
1893                    if (e.isIntersecting && priceChart) {
1894                        try { priceChart.draw(); } catch (err) {}
1895                    }
1896                });
1897            });
1898            _chartRedrawIO.observe(canvas);
1899        }
1900        // bfcache 復帰(戻る/進む)でも canvas が空になることがあるため再描画
1901        window.addEventListener('pageshow', function (e) {
1902            if (e.persisted && priceChart) {
1903                try { priceChart.update('none'); } catch (err) {}
1904            }
1905        });
1906
1907        // ==================== カスタム HTML 凡例 ====================
1908        // ==================== ダーク/ライト切替時のチャート再配色 ====================
1909        // Chart.js は生成時に色を確定するため、テーマ切替後も旧テーマの
1910        // グリッド/目盛り色が残る。data-theme の変化を監視して即時更新する。
1911        new MutationObserver(function () {
1912            if (!priceChart) return;
1913            try {
1914                const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1915                const gridColor = isDark ? 'rgba(255,255,255,0.08)' : '#E2E8F0';
1916                const tickColor = '#64748B';
1917                const sx = priceChart.options.scales.x, sy = priceChart.options.scales.y;
1918                sx.grid.color = gridColor; sy.grid.color = gridColor;
1919                sx.ticks.color = tickColor; sy.ticks.color = tickColor;
1920                priceChart.update('none');
1921                if (typeof currentHistoryData !== 'undefined' && currentHistoryData) _drawBrushMini(currentHistoryData);
1922            } catch (e) {}
1923        }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1924
1925        (function bindSaleToggle() {
1926            document.addEventListener('click', function(e) {
1927                if (e.target && e.target.id === 'chartSaleBtn') {
1928                    _showSaleBands = !_showSaleBands;
1929                    try { localStorage.setItem('kx_chart_sale', _showSaleBands ? '1' : '0'); } catch (err) {}
1930                    _syncSaleBtn();
1931                    if (priceChart) priceChart.update();
1932                    return;
1933                }
1934                // 凡例チップ: プラットフォーム別の帯フィルター
1935                const typeBtn = e.target && e.target.closest ? e.target.closest('#saleTypeAmazon, #saleTypeRakuten') : null;
1936                if (!typeBtn) return;
1937                const key = typeBtn.id === 'saleTypeRakuten' ? 'rakuten' : 'amazon';
1938                _saleTypes[key] = !_saleTypes[key];
1939                try {
1940                    localStorage.setItem('kx_chart_sale_types',
1941                        ['amazon', 'rakuten'].filter(k => _saleTypes[k]).join(','));
1942                } catch (err) {}
1943                _syncSaleBtn();
1944                if (priceChart) priceChart.update();
1945            });
1946            document.addEventListener('DOMContentLoaded', _syncSaleBtn);
1947        })();
1948
1949        function renderChartLegend() {
1950            const section = document.getElementById('chartLegendSection');
1951            const items = document.getElementById('chartLegendItems');
1952            const toggleBtn = document.getElementById('chartLegendToggleAll');
1953            if (!section || !items || !toggleBtn || !priceChart) return;
1954
1955            const datasets = priceChart.data.datasets || [];
1956            if (datasets.length === 0) { section.style.display = 'none'; return; }
1957            section.style.display = 'block';
1958
1959            // 定価はチップにせず、右上の独立トグルボタン(#chartMsrpToggle)で扱う
1960            const _msrpIdx = datasets.findIndex(ds => ds.label === '定価');
1961            items.innerHTML = datasets.map((ds, i) => {
1962                if (i === _msrpIdx) return '';
1963                const hidden = priceChart.getDatasetMeta(i).hidden;
1964                const color = ds.borderColor || '#94A3B8';
1965                const labelText = ds.label || '';
1966                const safeLabel = labelText.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
1967                return `<span class="chart-legend-item${hidden ? ' is-hidden' : ''}" data-idx="${i}">`
1968                     + `<span class="chart-legend-dot" style="background:${color}"></span>${safeLabel}</span>`;
1969            }).join('');
1970
1971            // 定価トグルボタン(定価データがある商品のみ表示)
1972            const msrpBtn = document.getElementById('chartMsrpToggle');
1973            if (msrpBtn) {
1974                if (_msrpIdx >= 0) {
1975                    msrpBtn.style.display = '';
1976                    msrpBtn.classList.toggle('is-hidden', !!priceChart.getDatasetMeta(_msrpIdx).hidden);
1977                    msrpBtn.onclick = () => {
1978                        const meta = priceChart.getDatasetMeta(_msrpIdx);
1979                        meta.hidden = !meta.hidden;
1980                        priceChart.update();
1981                        msrpBtn.classList.toggle('is-hidden', meta.hidden);
1982                    };
1983                } else {
1984                    msrpBtn.style.display = 'none';
1985                }
1986            }
1987
1988            items.querySelectorAll('.chart-legend-item').forEach(el => {
1989                el.addEventListener('click', () => {
1990                    const idx = parseInt(el.dataset.idx, 10);
1991                    const meta = priceChart.getDatasetMeta(idx);
1992                    meta.hidden = !meta.hidden;
1993                    priceChart.update();
1994                    el.classList.toggle('is-hidden', meta.hidden);
1995                    updateLegendToggleBtn();
1996                    _recomputeChartStats(); // 凡例トグル → 統計再計算
1997                });
1998            });
1999
2000            toggleBtn.onclick = () => {
2001                // 定価は店舗ではないので「すべて非表示/表示」の対象外(独立ボタンで制御)
2002                const storeIdx = datasets.map((ds, i) => i).filter(i => datasets[i].label !== '定価');
2003                const allHidden = storeIdx.every(i => priceChart.getDatasetMeta(i).hidden);
2004                const target = !allHidden;
2005                storeIdx.forEach(i => { priceChart.getDatasetMeta(i).hidden = target; });
2006                priceChart.update();
2007                items.querySelectorAll('.chart-legend-item').forEach(el => el.classList.toggle('is-hidden', target));
2008                updateLegendToggleBtn();
2009                _recomputeChartStats(); // 「すべて非表示/表示」→ 統計再計算
2010            };
2011
2012            updateLegendToggleBtn();
2013        }
2014
2015        function updateLegendToggleBtn() {
2016            const btn = document.getElementById('chartLegendToggleAll');
2017            if (!btn || !priceChart) return;
2018            const datasets = priceChart.data.datasets || [];
2019            const storeIdx = datasets.map((ds, i) => i).filter(i => datasets[i].label !== '定価');
2020            const allHidden = storeIdx.length > 0 && storeIdx.every(i => priceChart.getDatasetMeta(i).hidden);
2021            btn.textContent = allHidden ? 'すべて表示' : 'すべて非表示';
2022        }
2023
2024        // ==================== UI 辅助函数 ====================
2025        function showLoading(show) { elements.loading.style.display = show ? 'flex' : 'none'; }
2026
2027        function showError(message) {
2028            elements.searchError.innerHTML = `
2029                <div style="background: var(--danger); color: white; padding: 16px; border-radius: 12px; margin-top: 16px;">
2030                    ⚠️ ${escapeHtml(message)}
2031                </div>
2032            `;
2033        }
2034        function hideError() { elements.searchError.innerHTML = ''; }
2035
2036        const _backConfig = {
2037            'ranking':     { text: 'ランキングに戻る',         url: null },
2038            'brand':       { text: 'ブランドページに戻る',     url: null },
2039            'category':    { text: 'カテゴリページに戻る',     url: null },
2040            'store':       { text: '買取店ページに戻る',       url: null },
2041            'products':    { text: '全商品一覧に戻る',         url: '/products' },
2042            'search':      { text: '検索結果に戻る',           url: null },
2043            'profit':      { text: '利益ランキングに戻る',     url: '/ranking/profit' },
2044            'related':     { text: '商品ページに戻る',         url: null },
2045            'favorites':   { text: 'お気に入りに戻る',         url: '/favorites' },
2046            'follows':     { text: '配信通知に戻る',           url: '/favorites?tab=notify' },
2047            'popular':     { text: 'トップに戻る',             url: '/' },
2048            'history':     { text: 'トップに戻る',             url: '/' },
2049            'series':      { text: 'シリーズページに戻る',     url: null },
2050            'article':     { text: 'コラムに戻る',             url: null },
2051            'articles':    { text: '買取コラム一覧に戻る',     url: '/articles' },
2052            'apple-stock': { text: 'Apple在庫チェックに戻る', url: null },
2053            'statistics':  { text: '市場統計に戻る',           url: null },
2054            'guide':       { text: '使い方ガイドに戻る',       url: '/guide' },
2055            'deals':       { text: 'お買い得商品に戻る',       url: null },
2056        };
2057
2058        function _updateBackBtn(from) {
2059            const cfg = _backConfig[from] || { text: 'トップに戻る', url: '/' };
2060            const btn = elements.backBtn;
2061            // preserve SVG, replace the label. 文字は .back-btn-label に入れる=狭い画面(≤480px)では
2062            // 矢印だけを見せて文字は読み上げ用に残す(kaitorix.css。長い「利益ランキングに戻る」等で
2063            // 同じ行の検索欄とスキャンボタンが画面外へ押し出されていた、2026-09-27)
2064            const svg = btn.querySelector('svg');
2065            btn.textContent = '';
2066            if (svg) btn.appendChild(svg);
2067            const label = document.createElement('span');
2068            label.className = 'back-btn-label';
2069            label.textContent = cfg.text;
2070            btn.appendChild(label);
2071        }
2072
2073        function goBack(skipPush) {
2074            // from パラメータがあればその画面に戻る
2075            const cfg = _backConfig[currentFrom];
2076            if (cfg) {
2077                if (cfg.url) {
2078                    window.location.href = cfg.url + (currentFromQuery ? (cfg.url.includes('?') ? '&' : '?') + currentFromQuery : '');
2079                } else if (currentFromUrl) {
2080                    // from_url が保存されていれば直接そのページへ(ranking/brand/category/store/search など)
2081                    window.location.href = currentFromUrl;
2082                } else {
2083                    // ブラウザ履歴で戻る(フォールバック)
2084                    history.back();
2085                }
2086                return;
2087            }
2088            elements.productSection.classList.remove('active');
2089            elements.searchSection.style.display = 'block';
2090            elements.searchResults.innerHTML = '';
2091            hideDropdown();
2092            currentJan = null;
2093            currentProductData = null;
2094            // 商品ページ初回 → トップに戻った場合、人気/利益商品/最近見た が未ロードのこと
2095            // があるため、ここで(冪等に)ロードする。履歴は別途常にロード済み。
2096            ensureHomeSectionsLoaded();
2097            updateMatomeUI();
2098            if (!skipPush) history.pushState(null, '', '/');
2099            trackPageView('/');
2100            // Reset meta to default when leaving product page
2101            document.title = '買取X | 買取比較サイト - 複数社の買取価格を一括比較・買取チェッカー';
2102        }
2103
2104    // 買取チェッカー掲載品なら、該当チェッカーへの導線を商品ページに出す。
2105    // ベイブレードのようにシリーズ頁もカテゴリ頁も無いジャンルでは、ここが唯一の内部リンク。
2106    // SPA shell は / と /dp で共用の 1 枚なのでサーバ側で差し込めない。
2107    // マップは起動時にサーバが window.KX_CHECKER_JANS へ埋め込む。
2108    function renderCheckerBanner(jan) {
2109        const box = document.getElementById('checkerBannerContainer');
2110        if (!box) return;
2111        const key = (window.KX_CHECKER_JANS || {})[String(jan || '')];
2112        const conf = key && (window.KX_CHECKERS || {})[key];
2113
2114        // Apple 製品は「買取チェッカー / この世代 / Apple Store 在庫確認」を
2115        // 1 枚のカードにまとめて出す。文言もアイコンもサーバ定数をそのまま受け取る
2116        // (ここに書き写すと SSR と前端で別々の文面になる。実際にそうなっていた)。
2117        const T = window.KX_APPLE_TEXT, IC = window.KX_APPLE_ICONS || {};
2118        const AS = window.KX_APPLE_SERIES || {};
2119        const janKey = String(jan || '');
2120        const series = AS[janKey];                       // 無い世代は空文字
2121        const isApple = !!T && Object.prototype.hasOwnProperty.call(AS, janKey);
2122        if (isApple && T) {
2123            // 等分のセグメント 1 本(アイコン+名前)。説明文も矢印も付けない。
2124            // 文言・アイコンはサーバ定数をそのまま使う(前端に書き写さない)。
2125            const chip = (url, t, icon, tone) => `
2126                <a class="kx-ckc kx-ckc-${tone}" href="${escapeHtml(url)}"><svg class="kx-ckc-ico" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon || ''}</svg><span>${escapeHtml(t)}</span></a>`;
2127            let items = '';
2128            if (conf) items += chip(conf.url, T.matrix.t, IC.grid, 'm');
2129            if (series) items += chip(series, T.series.t, IC.list, 's');
2130            items += chip(T.stock.url, T.stock.t, IC.store, 'a');
2131            box.innerHTML = `<nav class="kx-ckg" aria-label="${escapeHtml(T.group.h)}">${items}</nav>`;
2132            return;
2133        }
2134
2135        if (!conf) { box.innerHTML = ''; return; }
2136        box.innerHTML = `
2137            <a class="kx-ckb" href="${escapeHtml(conf.url)}" aria-label="${escapeHtml(conf.label)}を開く">
2138                <span class="kx-ckb-ico" aria-hidden="true">
2139                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2140                        <rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/>
2141                    </svg>
2142                </span>
2143                <span class="kx-ckb-txt">
2144                    <span class="kx-ckb-t">${escapeHtml(conf.label)}</span>
2145                    <span class="kx-ckb-d">${escapeHtml(conf.desc)}</span>
2146                </span>
2147                <svg class="kx-ckb-arw" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
2148            </a>`;
2149    }
2150
2151
2152        // ===== 買取店リンクの Referrer ポリシー =====
2153        // 既定は unsafe-url(完全 URL を送る)。店側の GA に
2154        // https://kaitorix.app/product/{JAN} が残り、提携交渉の実績材料になる。
2155        // 例外=Referer に "kaitorix.app" が入ると 403 で弾く店(origin nginx の
2156        // 部分一致規則。referrerpolicy="origin" でもドメイン名は載るので無効=
2157        // no-referrer 以外に手が無い)。落ちても静かな 403 でログに何も出ないため、
2158        // scripts/referer_audit.py が週次で実測する。
2159        // 🔴 この配列は referer_audit.py が自動同期する。行の形を変えないこと。
2160        const KX_REFERRER_BLOCKED_HOSTS = ['newenoking-kaitori.com']; /* referer-audit:auto */
2161        // 価格を取れなくなった店(サーバーの link_only_stores)を、価格表の末尾に他の停止店と同じ灰色の行で並べ、
2162        // 価格の欄を「店舗サイトで確認」にする(2026-09-30 May 指示)。旧価格は出さない=最高値・順位・店数には入らない
2163        // (prices とは別の配列なので max/min/店数の計算は触らない)。店が扱っていた商品にだけサーバーが付ける。
2164        function kxLinkOnlyRowsHtml(product, offset) {
2165            const list = (product && product.link_only_stores) || [];
2166            if (!list.length) return '';
2167            const _jan = (product && product.jan) || '';
2168            const arrow = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>';
2169            return list.map((s, j) => {
2170                const attrs = `target="_blank" ${kxStoreLinkAttrs(s.url)} onclick="if(typeof gtag==='function')gtag('event','affiliate_click',{store:'${escapeHtml(s.key)}',jan:'${escapeHtml(_jan)}',link_only:true});"`;
2171                return `
2172                <div class="price-row stale link-only">
2173                    <div class="store-main">
2174                        <div class="store-info">
2175                            <span class="store-rank">${offset + j + 1}</span>
2176                            <div class="store-name"><a href="${escapeHtml(s.url)}" ${attrs}>${escapeHtml(s.store)}</a></div>
2177                        </div>
2178                        <div class="store-mark-col"></div>
2179                    </div>
2180                    <div class="price-info">
2181                        <a class="price-site-link" href="${escapeHtml(s.url)}" ${attrs}>店舗サイトで確認${arrow}</a>
2182                    </div>
2183                </div>`;
2184            }).join('');
2185        }
2186
2187        function kxStoreLinkAttrs(url, baseRel) {
2188            const rel = baseRel || 'noopener';
2189            try {
2190                const h = new URL(url, location.href).hostname.replace(/^www\./, '');
2191                if (KX_REFERRER_BLOCKED_HOSTS.some(b => h === b || h.endsWith('.' + b))) {
2192                    return 'rel="' + rel + ' noreferrer" referrerpolicy="no-referrer"';
2193                }
2194            } catch (e) { /* 相対 URL や '#' はここに来る。既定側に倒す */ }
2195            return 'rel="' + rel + '" referrerpolicy="unsafe-url"';
2196        }
2197
2198        function escapeHtml(str) {
2199            if (!str) return '';
2200            return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2201        }
2202
2203        function copyToClipboard(text, msg) {
2204            var toastMsg = msg || 'コピーしました';
2205            if (navigator.clipboard && navigator.clipboard.writeText) {
2206                navigator.clipboard.writeText(text).then(() => showCopiedToast(toastMsg)).catch(() => fallbackCopy(text, toastMsg));
2207            } else { fallbackCopy(text, toastMsg); }
2208        }
2209        window.copyToClipboard = copyToClipboard;
2210
2211        function fallbackCopy(text, msg) {
2212            const textarea = document.createElement('textarea');
2213            textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0';
2214            document.body.appendChild(textarea); textarea.select();
2215            try { document.execCommand('copy'); showCopiedToast(msg); } catch (err) { console.error('Copy failed:', err); }
2216            document.body.removeChild(textarea);
2217        }
2218
2219        function showCopiedToast(msg, durationMs) {
2220            const existing = document.querySelector('.copied-toast');
2221            if (existing) existing.remove();
2222            const toast = document.createElement('div');
2223            toast.className = 'copied-toast';
2224            toast.textContent = msg || 'コピーしました';
2225            document.body.appendChild(toast);
2226            setTimeout(() => toast.remove(), durationMs || 2000);
2227        }
2228
2229        // ==================== 利益商品機能 ====================
2230        async function loadProfitableProducts() {
2231            try {
2232                const data = await apiCall('/api/profitable?page=1&per_page=50');
2233                profitableData = data;
2234                const section = document.getElementById('profitableSection');
2235                if (!data.products || data.products.length === 0) {
2236                    section.style.display = 'none';
2237                    return;
2238                }
2239                document.getElementById('profitableBadge').textContent = `${data.total}ä»¶`;
2240                renderProfitableBanner(data.products);
2241                renderCarousel(1);
2242                // お買い得セクション表示/非表示の記憶を復元
2243                const saved = localStorage.getItem('kaitorix_deals_visible');
2244                if (saved === '0') {
2245                    document.getElementById('profitableContent').style.display = 'none';
2246                    document.getElementById('profitableToggle').textContent = '表示';
2247                }
2248            } catch (err) {
2249                document.getElementById('profitableSection').style.display = 'none';
2250            }
2251        }
2252
2253        function createCardHtml(p) {
2254            const source = p.source || 'amazon';
2255            const isRakuten = source === 'rakuten';
2256            function _mottokuRakuten(prod) {
2257                const su = prod.source_url || '';
2258                const jan = prod.jan || '';
2259                const m = su.match(/item\.rakuten\.co\.jp\/([^/]+)\//);
2260                if (jan && m && m[1]) {
2261                    return window.kxRakutenUrl({ jan: jan, shop: m[1], code: (su.match(/item\.rakuten\.co\.jp\/[^/]+\/([^/?#]+)/) || [])[1] || '', query: prod.name || '' });
2262                }
2263                return window.kxRakutenUrl({ jan: jan, query: prod.name || '' });
2264            }
2265            const productUrl = isRakuten
2266                ? _mottokuRakuten(p)
2267                : (p.asin
2268                    ? `https://www.amazon.co.jp/dp/${escapeHtml(p.asin)}?tag=${AMAZON_TAG}&linkCode=ogi&th=1&psc=1`
2269                    : `https://www.amazon.co.jp/s?k=${encodeURIComponent(p.name)}`);
2270            const sourceBadge = isRakuten
2271                ? `<span style="font-size:0.7rem;font-weight:600;color:#BF0000;border:1.5px solid #BF0000;padding:2px 0;border-radius:4px;display:inline-block;width:56px;text-align:center;line-height:1.3;vertical-align:middle;">
2271楽天</span>`
2272                : `<span style="font-size:0.7rem;font-weight:600;color:#E47911;border:1.5px solid #E47911;padding:2px 0;border-radius:4px;display:inline-block;width:56px;text-align:center;line-height:1.3;vertical-align:middle;">Amazon</span>`;
2273            const imgHtml = p.image_url ? `<img src="${escapeHtml(p.image_url)}" alt="${escapeHtml(p.name || '')}" loading="lazy" width="80" height="80">` : `<svg style="width:40px;height:40px;color:var(--text-muted);opacity:0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`;
2274            return `
2275            <div class="profit-card">
2276                <a href="${productUrl}" target="_blank" rel="noopener" style="text-decoration:none;color:inherit;flex:1;display:flex;flex-direction:column;">
2277                    <div class="profit-card-img">${imgHtml}</div>
2278                    <div class="profit-card-badge">+Â¥${p.profit.toLocaleString()}</div>
2279                    <div class="profit-card-body">
2280                        <div class="profit-card-name">${escapeHtml(p.name)}</div>
2281                        <div class="profit-card-price">${sourceBadge} ¥${p.amazon_price.toLocaleString()}</div>
2282                        ${p.updated ? `<div style="font-size:0.7rem;color:var(--text-muted);margin-top:2px;">${escapeHtml(p.updated)} 取得</div>` : ''}
2283                        <div class="profit-card-store">→ ${escapeHtml(p.best_store)} ¥${p.best_kaitori_price.toLocaleString()}</div>
2284                    </div>
2285                </a>
2286                <a href="/dp/${escapeHtml(p.jan)}?from=profit" class="profit-card-compare" data-jan="${escapeHtml(p.jan)}">買取比較 →</a>
2287            </div>
2288            `;
2289        }
2290
2291        function bindCompareButtons(container) {
2292            container.querySelectorAll('.profit-card-compare').forEach(btn => {
2293                btn.addEventListener('click', (e) => {
2294                    e.preventDefault(); e.stopPropagation();
2295                    currentFrom = 'profit';
2296                    _updateBackBtn('profit');
2297                    showProduct(btn.dataset.jan);
2298                });
2299            });
2300        }
2301
2302        function renderProfitableBanner(products) {
2303            const container = document.getElementById('profitableBannerInner');
2304            if (!products || products.length === 0) return;
2305            container.innerHTML = products.map(p => createCardHtml(p)).join('');
2306            bindCompareButtons(container);
2307        }
2308
2309        function renderCarousel(page) {
2310            if (!profitableData || !profitableData.products) return;
2311            const products = profitableData.products;
2312            const totalPages = Math.ceil(products.length / CARDS_PER_PAGE);
2313            if (page < 1) page = totalPages;
2314            if (page > totalPages) page = 1;
2315            carouselPage = page;
2316
2317            const start = (page - 1) * CARDS_PER_PAGE;
2318            const pageProducts = products.slice(start, start + CARDS_PER_PAGE);
2319            const container = document.getElementById('profitableCardsRow');
2320            container.innerHTML = pageProducts.map(p => createCardHtml(p)).join('');
2321            bindCompareButtons(container);
2322
2323            const indicator = document.getElementById('pageIndicator');
2324            indicator.textContent = totalPages > 1 ? `${page} / ${totalPages}` : '';
2325        }
2326
2327        function carouselPrevPage() { renderCarousel(carouselPage - 1); }
2328        function carouselNextPage() { renderCarousel(carouselPage + 1); }
2329
2330        function toggleProfitableContent() {
2331            const content = document.getElementById('profitableContent');
2332            const btn = document.getElementById('profitableToggle');
2333            if (content.style.display === 'none') {
2334                content.style.display = 'block'; btn.textContent = '非表示';
2335                localStorage.setItem('kaitorix_deals_visible', '1');
2336            } else {
2337                content.style.display = 'none'; btn.textContent = '表示';
2338                localStorage.setItem('kaitorix_deals_visible', '0');
2339            }
2340        }
2341
2342        // ==================== 利益ランキング TOP5 プレビュー(人気メニュー左) ====================
2343        async function loadProfitPreview() {
2344            try {
2345                const data = await apiCall('/api/profitable?per_page=5&preorder=false&cats=' + encodeURIComponent('スマホ,カメラ,ゲーム'));
2346                const card = document.getElementById('kxProfitPreview');
2347                const list = document.getElementById('kxProfitList');
2348                const items = (data && data.products) || [];
2349                if (!card || !list || !items.length) return;
2350                list.innerHTML = items.slice(0, 5).map((p, i) => {
2351                    const img = p.image_url
2352                        ? `<img src="${escapeHtml(p.image_url)}" alt="" width="40" height="40" loading="lazy">`
2353                        : `<span class="kx-pv-noimg">${placeholderSVG(18)}</span>`;
2354                    const store = p.best_store ? `<span class="kx-pv-store">${escapeHtml(p.best_store)}</span>` : '';
2355                    return `<a class="kx-pv-item" href="/dp/${escapeHtml(p.jan)}" data-jan="${escapeHtml(p.jan)}" title="${escapeHtml(p.name)}">
2356                        <span class="kx-pv-rank">${i + 1}</span>${img}
2357                        <span class="kx-pv-body"><span class="kx-pv-name">${escapeHtml(p.name)}</span>${store}</span>
2358                        <span class="kx-pv-right">
2359                            <span class="kx-pv-price">Â¥${(p.best_kaitori_price || 0).toLocaleString()}</span>
2360                            <span class="kx-pv-profit">利益 +¥${(p.profit || 0).toLocaleString()}</span>
2361                        </span>
2362                    </a>`;
2363                }).join('');
2364                list.querySelectorAll('.kx-pv-item').forEach(a => a.addEventListener('click', (e) => {
2365                    if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
2366                    e.preventDefault();
2367                    showProduct(a.dataset.jan, true);
2368                }));
2369                card.style.display = '';
2370            } catch (err) { /* プレビューは飾り: 失敗時は静かにスキップ */ }
2371        }
2372
2373        // ==================== 利益チャンス ticker(ホームbanner直下・/deal 導線) ====================
2374        // Amazon 利益商品の 買取価格+利益 のみ表示(Amazon 価格は出さない)。クリックで /deal/{asin}。
2375        // 買取店フィルター=利益TOP(/ranking/profit)と同じ localStorage kaitorix_kaitori_stores を参照し、
2376        // 選択店の最高価格で 買取店/価格/利益 を再計算する(未設定=全店)。
2377        function _tickerStoreFilter() {
2378            try {
2379                const v = localStorage.getItem('kaitorix_kaitori_stores');
2380                if (!v) return null;
2381                const arr = JSON.parse(v);
2382                if (Array.isArray(arr) && arr.length) return new Set(arr);
2383            } catch (e) { /* 破損時は全店扱い */ }
2384            return null;
2385        }
2386        async function loadDealTicker() {
2387            const wrap = document.getElementById('kxDealTicker');
2388            try {
2389                const track = document.getElementById('kxDealTickerTrack');
2390                if (!wrap || !track) return;
2391                // ホームでは中身が届く前から帯の高さを取ってある(price-checker.html の直後の script)。
2392                // 届く前に商品やまとめへ切り替わっても隠れるよう、表示の追従は最初に繋ぐ。
2393                _bindTickerVisibility();
2394                const storeSel = _tickerStoreFilter();
2395                const storeMap = window.KX_STORE_MAP || {};
2396                const refine = (p) => {
2397                    if (!p.asin || (p.profit || 0) <= 0 || (p.best_kaitori_price || 0) <= 0) return null;
2398                    if (!storeSel) return { ...p, _store: p.best_store, _kaitori: p.best_kaitori_price, _profit: p.profit };
2399                    // 選択店のみで最高価格を取り直し、利益は差分で再計算
2400                    let bp = 0, bk = '';
2401                    const prices = p.prices || {};
2402                    storeSel.forEach(k => { const v = prices[k] || 0; if (v > bp) { bp = v; bk = k; } });
2403                    if (bp <= 0) return null;
2404                    const profit2 = (p.profit || 0) - (p.best_kaitori_price || 0) + bp;
2405                    if (profit2 <= 0) return null;
2406                    return { ...p, _store: storeMap[bk] || bk, _kaitori: bp, _profit: profit2 };
2407                };
2408                // カラー違いの同一商品を 1 件に畳む(2026-08-03 May 指示)。ticker は商品名を
2409                // 出さないため「AT-LP60X レッド / ダークガンメタリック」のように末尾の色名
2410                // だけが違う 2 件は、店名・買取価格・利益が同一で完全な重複に見える。
2411                // 判定は保守的に「末尾トークンが ASCII 英数字を含まない(=カナ/漢字/[色]表記)
2412                // 場合のみ落として比較」——型番 (MFY84J/A, CFI-7100B01) や「2025年版」は
2413                // 残るので、別 SKU が誤って畳まれない。
2414                const dedupeKey = (name) => {
2415                    const parts = String(name || '').trim().split(/[\s ]+/).filter(Boolean);
2416                    if (parts.length > 1 && !/[0-9A-Za-z]/.test(parts[parts.length - 1])) parts.pop();
2417                    return parts.join(' ');
2418                };
2419                // 利益上位は特定の買取店に偏る(2026-08-03 実測: amazon 母集団 53 件中 42 件が
2420                // 同一店ベスト)。店フィルター有効時に上位20件だけ見ると選択店が1件も入らず
2421                // ticker が丸ごと消えるため、利益TOP と同じく母集団を送って絞り込む。
2422                const perPage = storeSel ? 50 : 20;
2423                const maxPages = storeSel ? 4 : 1;
2424                const seen = new Set();
2425                let items = [];
2426                for (let pg = 1; pg <= maxPages; pg++) {
2427                    const data = await apiCall(`/api/profitable?page=${pg}&per_page=${perPage}&source=amazon&preorder=false`);
2428                    const chunk = (data && data.products) || [];
2429                    for (const p of chunk) {
2430                        const r = refine(p);
2431                        if (!r) continue;
2432                        const k = dedupeKey(r.name);
2433                        if (k) {
2434                            if (seen.has(k)) continue;   // 先に出た方=利益が高い方を残す
2435                            seen.add(k);
2436                        }
2437                        items.push(r);
2438                    }
2439                    if (chunk.length < perPage || items.length >= 15) break;
2440                }
2441                items = items.sort((a, b) => b._profit - a._profit).slice(0, 15);
2442                // 最低件数は 2(旧 3)。カラー違いを畳むようにした結果、店フィルターが
2443                // 効いている環境では 3 件→2 件になり ticker ごと消えてしまうため。
2444                // セット複製でループさせるので 2 件でも帯は途切れない。
2445                if (items.length < 2) { _tickerEmpty = true; wrap.style.display = 'none'; return; }
2446                _tickerEmpty = false;
2447                const itemsHtml = items.map(p => {
2448                    const img = p.image_url
2449                        ? `<img src="${escapeHtml(p.image_url)}" alt="" width="40" height="40" loading="lazy">`
2450                        : `<span class="kx-ticker-noimg">${placeholderSVG(16)}</span>`;
2451                    return `<a class="kx-ticker-item" href="/deal/${encodeURIComponent(p.asin)}?s=web" title="${escapeHtml(p.name || '')}">
2452                        ${img}
2453                        <span class="kx-ticker-body">
2454                            <span class="kx-ticker-store">${escapeHtml(p._store || '買取価格')}</span>
2455                            <span class="kx-ticker-price">Â¥${(p._kaitori || 0).toLocaleString()}</span>
2456                        </span>
2457                        <span class="kx-ticker-profit">利益 +¥${(p._profit || 0).toLocaleString()}</span>
2458                    </a>`;
2459                }).join('');
2460                // 複製セットはスクリーンリーダー/Tab 巡回から除外
2461                const cloneHtml = '<div class="kx-ticker-set" aria-hidden="true">'
2462                    + itemsHtml.replace(/<a class="kx-ticker-item"/g, '<a tabindex="-1" class="kx-ticker-item"') + '</div>';
2463                track.innerHTML = '<div class="kx-ticker-set">' + itemsHtml + '</div>' + cloneHtml;
2464                wrap.style.display = '';
2465                // 測定は表示中しか出来ない(offsetWidth=0)ため関数化し、非表示中なら表示時に再試行
2466                _tickerMeasureFn = () => {
2467                    const set = track.querySelector('.kx-ticker-set');
2468                    if (!set) return;
2469                    const setW = set.offsetWidth;
2470                    if (!setW) return; // 非表示中: sync() 側から再試行される
2471                    // シームレスループ: アニメ中もビューポートが常に埋まるまでセットを複製
2472                    const vpW = wrap.clientWidth || 0;
2473                    let guard = 0;
2474                    while (setW * (track.children.length - 1) < vpW && guard < 6) {
2475                        track.insertAdjacentHTML('beforeend', cloneHtml);
2476                        guard++;
2477                    }
2478                    track.style.setProperty('--kx-tk-shift', setW + 'px');
2479                    track.style.setProperty('--kx-tk-dur', Math.max(18, Math.round(setW / 32)) + 's');
2480                };
2481                requestAnimationFrame(_tickerMeasureFn);
2482            } catch (err) {
2483                /* ティッカーは飾り: 失敗時は静かにスキップ(取ってあった高さは畳む) */
2484                _tickerEmpty = true;
2485                if (wrap) wrap.style.display = 'none';
2486            }
2487        }
2488        // ticker は #searchSection の外(banner 直下)に居るため、ホーム⇄商品/まとめ切替に
2489        // 自前で追従する必要がある。searchSection の inline style を単一真理源として同期。
2490        // _tickerEmpty=出す物が無い(2 件未満・取得失敗)。ホームに戻っても空の帯を出さない。
2491        let _tickerVisObserver = null;
2492        let _tickerMeasureFn = null;
2493        let _tickerEmpty = false;
2494        function _bindTickerVisibility() {
2495            if (_tickerVisObserver) return;
2496            const wrap = document.getElementById('kxDealTicker');
2497            const sec = document.getElementById('searchSection');
2498            const track = document.getElementById('kxDealTickerTrack');
2499            if (!wrap || !sec || !('MutationObserver' in window)) return;
2500            const sync = () => {
2501                const hidden = sec.style.display === 'none' || _tickerEmpty;
2502                wrap.style.display = hidden ? 'none' : '';
2503                if (!hidden && track && !track.style.getPropertyValue('--kx-tk-shift') && _tickerMeasureFn) {
2504                    requestAnimationFrame(_tickerMeasureFn);
2505                }
2506            };
2507            _tickerVisObserver = new MutationObserver(sync);
2508            _tickerVisObserver.observe(sec, { attributes: true, attributeFilter: ['style'] });
2509            sync();
2510        }
2511
2512        // ==================== 最近見た商品 + 速報カード ====================
2513        function timeAgoJp(iso) {
2514            try {
2515                const t = new Date(iso).getTime();
2516                const m = Math.max(1, Math.round((Date.now() - t) / 60000));
2517                if (m < 60) return m + '分前';
2518                const h = Math.round(m / 60);
2519                if (h < 24) return h + '時間前';
2520                return Math.round(h / 24) + '日前';
2521            } catch (e) { return ''; }
2522        }
2523
2524        async function loadSokuho(jan) {
2525            const cards = [document.getElementById('sokuhoCard'), document.getElementById('sokuhoCardMobile')].filter(Boolean);
2526            if (!cards.length) return;
2527            cards.forEach(c => { c.style.display = 'none'; });
2528            try {
2529                const d = await apiCall('/api/product/' + jan + '/sokuho');
2530                if (jan !== currentJan) return;
2531                // 配信実績が無くてもカードは表示する(フォロー導線のため。2026-07-14 May 指示)
2532                const txt = (d && d.has)
2533                    ? '最終 ' + timeAgoJp(d.last) + ' ・ 累計 ' + d.count + ' 回'
2534                    : '配信実績 0 回';
2535                ['sokuhoMeta', 'sokuhoMetaMobile'].forEach(id => {
2536                    const m = document.getElementById(id);
2537                    if (m) m.textContent = txt;
2538                });
2539                cards.forEach(c => { c.style.display = ''; });
2540                initSokuhoFollow(jan);
2541            } catch (err) { /* 速報情報は飾り */ }
2542        }
2543
2544        // ==================== 在庫速報フォロー(ブラウザ通知 / Discord DM) ====================
2545        // 配信対象バッジ内のフォローボタン。状態機:
2546        //   受け取り先なし(未ログイン / Discord DM もブラウザ通知も無い)
2547        //     → kx-push.js の「受け取り方を選ぶ」モーダル(ブラウザ通知 or Discord)
2548        //   受け取り先あり → POST/DELETE /api/follow/{jan} でトグル
2549        //   フォロー成功直後、このブラウザが未設定なら「このブラウザでも受け取りますか?」を出す
2550        // 受け取り先の判定は kxPush.hasChannel(サーバーの has_channel と同じ形)だけを使う
2551        let _followState = null;
2552        const _FOLLOW_BELL_SVG = '<svg width="11" height="11" viewB
2552ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>';
2553
2554        function _followHasChannel(st) {
2555            if (window.kxPush) return window.kxPush.hasChannel(st);
2556            return !!(st && st.logged_in && ((st.discord_linked && st.discord_dm !== false) || st.webpush));
2557        }
2558
2559        function _followBtns() {
2560            return [document.getElementById('sokuhoFollowBtn'), document.getElementById('sokuhoFollowBtnMobile')].filter(Boolean);
2561        }
2562
2563        function _renderFollowBtns() {
2564            const st = _followState;
2565            _followBtns().forEach(b => {
2566                if (!st) { b.style.display = 'none'; return; }
2567                const following = !!(_followHasChannel(st) && st.following);
2568                b.classList.toggle('following', following);
2569                b.innerHTML = _FOLLOW_BELL_SVG + '<span>' + (following ? 'フォロー中' : 'フォロー') + '</span>';
2570                b.title = following
2571                    ? 'フォロー解除(配信時の通知を停止)'
2572                    : '在庫速報が配信されたらお知らせします(ブラウザ通知 / Discord)';
2573                b.style.display = '';
2574            });
2575        }
2576
2577        async function initSokuhoFollow(jan) {
2578            _followState = null;
2579            _followBtns().forEach(b => { b.style.display = 'none'; b.dataset.jan = jan; });
2580            try {
2581                const res = await fetch('/api/follow/' + jan, { credentials: 'same-origin' });
2582                if (!res.ok) return;
2583                const st = await res.json();
2584                if (jan !== currentJan) return;
2585                _followState = st;
2586                _renderFollowBtns();
2587            } catch (e) { /* フォローボタンは飾り: 失敗時は非表示のまま */ }
2588        }
2589
2590        async function _onFollowClick(ev) {
2591            const jan = ev.currentTarget.dataset.jan || currentJan;
2592            const st = _followState;
2593            if (!st || !jan) return;
2594            if (!_followHasChannel(st)) {
2595                // 受け取り先が無い → 選んでもらう(ブラウザ通知なら許可と購読までここで済む)
2596                if (!window.kxPush) {
2597                    location.href = '/auth/discord?next=' + encodeURIComponent('/dp/' + jan) + '&follow_jan=' + jan;
2598                    return;
2599                }
2600                const ch = await window.kxPush.chooseChannel({
2601                    jan: jan, loggedIn: st.logged_in, next: '/dp/' + jan,
2602                    discordLinked: !!st.discord_linked, discordUsername: st.discord_username || ''
2603                });
2604                if (!ch) return;                       // 閉じた / 未連携の Discord は連携画面へ遷移済み
2605                if (ch === 'webpush') st.webpush = true; else st.discord_dm = true;
2606                st.has_channel = true;
2607                if (st.following) {
2608                    _renderFollowBtns();
2609                    showCopiedToast(ch === 'webpush' ? 'このブラウザで在庫速報を受け取れるようになりました'
2610                                                     : 'Discord の DM で在庫速報を受け取れるようになりました', 3000);
2611                    return;
2612                }
2613            }
2614            try {
2615                const res = await fetch('/api/follow/' + jan, {
2616                    method: st.following ? 'DELETE' : 'POST',
2617                    credentials: 'same-origin'
2618                });
2619                if (res.status === 409) { // 受け取り先が消えていた(別端末で解除・DM オフ・通知の失効等)
2620                    st.has_channel = false; st.discord_dm = false; st.webpush = false;
2621                    _onFollowClick({ currentTarget: ev.currentTarget });
2622                    return;
2623                }
2624                if (!res.ok) throw new Error('follow api ' + res.status);
2625                st.following = !st.following;
2626                _renderFollowBtns();
2627                if (!st.following) { showCopiedToast('フォローを解除しました', 3000); return; }
2628                // このブラウザが未設定なら「このブラウザでも受け取りますか?」(出した時は toast を省く)
2629                const offered = window.kxPush ? await window.kxPush.offerAfterFollow() : false;
2630                if (!offered) showCopiedToast('フォローしました。在庫速報が配信されたらお知らせします', 3000);
2631            } catch (e) {
2632                showCopiedToast('通信エラーが発生しました。時間をおいてお試しください', 3000);
2633            }
2634        }
2635
2636        // Discord OAuth コールバックからの ?discord=<flag> を toast 表示して URL から除去
2637        function _handleDiscordFlag() {
2638            try {
2639                const sp = new URLSearchParams(location.search);
2640                const flag = sp.get('discord');
2641                if (!flag) return;
2642                const DM_HELP = 'Discord のプライバシー設定で「サーバーにいるメンバーからのダイレクトメッセージ」を許可してください';
2643                const messages = {
2644                    linked: 'Discord 連携が完了しました',
2645                    followed: 'Discord 連携が完了し、この商品をフォローしました。配信時にDMでお知らせします',
2646                    linked_dm_blocked: 'Discord 連携は完了しましたが、DMを送信できませんでした。' + DM_HELP,
2647                    followed_dm_blocked: 'フォローしましたが、DMを送信できませんでした。' + DM_HELP,
2648                    taken: 'この Discord アカウントは既に別の買取X アカウントに連携されています',
2649                    denied: 'Discord 連携がキャンセルされました',
2650                    state_error: 'Discord 連携に失敗しました(セッション切れ)。もう一度お試しください',
2651                    exchange_error: 'Discord 連携に失敗しました。時間をおいてもう一度お試しください',
2652                    db_error: 'Discord 連携に失敗しました。時間をおいてもう一度お試しください',
2653                    login_required: 'ログインセッションが切れました。もう一度お試しください'
2654                };
2655                if (flag === 'linked_not_member' || flag === 'followed_not_member') {
2656                    const base = flag === 'followed_not_member'
2657                        ? 'フォローしました。通知を受け取るには買取X の Discord サーバーへの参加が必要です。'
2658                        : 'Discord 連携が完了しました。通知を受け取るには買取X の Discord サーバーへの参加が必要です。';
2659                    if (confirm(base + '\n\n招待ページを開きますか?')) {
2660                        window.open('https://discord.gg/VJBSYD9Jg8', '_blank', 'noopener');
2661                    }
2662                } else if (messages[flag]) {
2663                    showCopiedToast(messages[flag], 5000);
2664                }
2665                sp.delete('discord');
2666                const q = sp.toString();
2667                history.replaceState(null, '', location.pathname + (q ? '?' + q : '') + location.hash);
2668            } catch (e) { /* 表示だけの処理: 失敗しても実害なし */ }
2669        }
2670
2671        document.addEventListener('DOMContentLoaded', function() {
2672            _followBtns().forEach(b => b.addEventListener('click', _onFollowClick));
2673            _handleDiscordFlag();
2674        });
2675
2676        async function loadRecentViewed() {
2677            // 値動きピックアップ: お気に入り ∪ 閲覧履歴 のうち「前日比で実際に動いた」商品だけ表示。
2678            // 全部動いてなければセクションごと非表示(ノイズゼロ)。履歴タグ行=ナビ、ここ=アラートと役割分担。
2679            try {
2680                const sec = document.getElementById('kxRecent');
2681                const row = document.getElementById('kxRecentRow');
2682                if (!sec || !row) return;
2683                let items = (getFavoritesFromStorage().items || []).slice();
2684                getHistoryFromStorage().forEach(hv => items.push({ jan: hv.jan, name: hv.name || '', image_url: hv.img || '', addedAt: hv.timestamp || 0 }));
2685                // ログイン済みならサーバのお気に入りもマージ
2686                try {
2687                    const res = await fetch('/api/favorites', { credentials: 'same-origin' });
2688                    if (res.ok) {
2689                        const sv = (await res.json()).favorites || [];
2690                        sv.forEach(v => items.push({ jan: v.jan, name: v.name || '', image_url: v.image_url || '', addedAt: v.added_at ? Date.parse(v.added_at) : 0 }));
2691                    }
2692                } catch (e) {}
2693                const seen = {};
2694                items = items.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0))
2695                    .filter(it => it.jan && !seen[it.jan] && (seen[it.jan] = 1)).slice(0, 24);  // 収藏が多くても履歴が押し出されないよう拡大
2696                if (!items.length) { sec.style.display = 'none'; return; }
2697                let snap = {};
2698                try { snap = await apiCall('/api/price-snap?jans=' + items.map(i => i.jan).join(',')); } catch (e) {}
2699                // 値動きがあった商品だけに絞る
2700                items = items.filter(it => (snap[it.jan] || {}).d).slice(0, 8);
2701                if (!items.length) { sec.style.display = 'none'; return; }
2702                row.innerHTML = items.map(it => {
2703                    const sn = snap[it.jan] || {};
2704                    const now = sn.p || 0;
2705                    const imgUrl = it.image_url || sn.img || '';
2706                    const img = imgUrl
2707                        ? `<img src="${escapeHtml(imgUrl)}" alt="" width="56" height="56" loading="lazy">`
2708                        : `<span class="kx-rv-noimg">${placeholderSVG(20)}</span>`;
2709                    const priceHtml = now > 0 ? `<span class="kx-rv-price">Â¥${now.toLocaleString()}</span>` : '';
2710                    let diffHtml = '';
2711                    if (now > 0 && sn.d) {
2712                        const pct = Math.abs(Math.round(sn.d / (now - sn.d) * 1000) / 10);
2713                        diffHtml = `<span class="kx-rv-diff ${sn.d > 0 ? 'up' : 'down'}" title="前日比">${sn.d > 0 ? '+' : '−'}¥${Math.abs(sn.d).toLocaleString()} (${pct}%)</span>`;
2714                    }
2715                    return `<a class="kx-rv-item" href="/dp/${escapeHtml(it.jan)}" data-jan="${escapeHtml(it.jan)}" title="${escapeHtml(it.name)}">
2716                        ${img}
2717                        <span class="kx-rv-name">${escapeHtml(it.name)}</span>
2718                        ${priceHtml}${diffHtml}
2719                    </a>`;
2720                }).join('');
2721                row.querySelectorAll('.kx-rv-item').forEach(a => a.addEventListener('click', (e) => {
2722                    if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
2723                    e.preventDefault();
2724                    showProduct(a.dataset.jan, true);
2725                }));
2726                sec.style.display = '';
2727            } catch (err) { }
2728        }
2729
2730        // ==================== 人気検索・検索履歴 ====================
2731        // 初期10件表示 → 「もっと見る」で +10 ずつ、最大100件まで展開する共通レンダラ。
2732        function _renderExpandableTags(container, items, from) {
2733            const CAP = Math.min(items.length, 100);
2734            let shown = Math.min(container._shownCount || 10, CAP);
2735            function paint() {
2736                container._shownCount = shown;
2737                // タグの HTML は price-checker.html の kxHintTagsHtml が唯一の正本(最初の描画の前に
2738                // 同じ物で組んである=ここで組み直しても見た目は動かない)
2739                container.innerHTML = window.kxHintTagsHtml(items, shown);
2740                container.querySelectorAll('.hint-tag').forEach(tag => {
2741                    if (tag.classList.contains('hint-more')) {
2742                        tag.addEventListener('click', () => { shown = Math.min(shown + 10, CAP); paint(); });
2743                    } else if (tag.dataset.q) {
2744                        // 全件検索履歴 → /search へ
2745                        tag.addEventListener('click', () => { window.location.href = '/search?q=' + encodeURIComponent(tag.dataset.q); });
2746                    } else {
2747                        tag.addEventListener('click', () => { currentFrom = from; _updateBackBtn(from); showProduct(tag.dataset.jan); });
2748                    }
2749                });
2750            }
2751            paint();
2752        }
2753
2754        async function loadPopularSearches() {
2755            try {
2756                // トップの殻に埋めてあればそれを使う(同じ /api/popular の中身・1 回だけ)
2757                const embedded = window.KX_POPULAR;
2758                window.KX_POPULAR = null;
2759                const data = (embedded && embedded.popular && embedded.popular.length)
2760                    ? embedded : await apiCall('/api/popular');
2761                if (data.popular && data.popular.length > 0) {
2762                    _renderExpandableTags(document.getElementById('popularTags'), data.popular, 'popular');
2763                    document.getElementById('popularSearches').style.display = 'flex';
2764                }
2765            } catch (err) { }
2766        }
2767
2768        function loadHistory() {
2769            const history = getHistoryFromStorage();
2770            if (history.length > 0) {
2771                _renderExpandableTags(document.getElementById('historyTags'), history, 'history');
2772                document.getElementById('historySection').style.display = 'flex';
2773            } else {
2774                document.getElementById('historySection').style.display = 'none';
2775            }
2776        }
2777
2778        // ==================== カテゴリタブ・折りたたみ ====================
2779        let _categoryCache = null;
2780        let _currentPopularTab = 'search_count';
2781        let _currentCategoryId = null;
2782        let _currentCategoryName = null;
2783
2784        async function loadCategoryChips() {
2785            if (_categoryCache) return _categoryCache;
2786            try {
2787                const data = await apiCall('/api/trend_categories');
2788                _categoryCache = (data && data.categories) ? data.categories : [];
2789                return _categoryCache;
2790            } catch (err) { return []; }
2791        }
2792
2793        async function renderCategoryChips() {
2794            const container = document.getElementById('categoryChipsContainer');
2795            if (!container) return;
2796            if (container.dataset.rendered === '1') return;
2797            const cats = await loadCategoryChips();
2798            if (!cats.length) {
2799                container.innerHTML = '<span style="color:var(--text-muted);font-size:0.85rem;padding:6px 0;">カテゴリなし</span>';
2800                container.dataset.rendered = '1';
2801                return;
2802            }
2803            container.innerHTML = cats.map(c =>
2804                `<span class="hint-tag" data-category-id="${c.id}" data-category-name="${escapeHtml(c.keyword_ja)}">${escapeHtml(c.keyword_ja)}</span>`
2805            ).join('');
2806            container.querySelectorAll('.hint-tag').forEach(chip => {
2807                chip.addEventListener('click', () => {
2808                    selectCategory(chip.dataset.categoryId, chip.dataset.categoryName);
2809                });
2810            });
2811            container.dataset.rendered = '1';
2812        }
2813
2814        let _categoryResults = [];
2815        let _categoryHasMore = true;
2816        let _categoryLoading = false;
2817        const _CATEGORY_PAGE = 30;
2818
2819        async function selectCategory(categoryId, categoryName) {
2820            _currentCategoryId = categoryId;
2821            _currentCategoryName = categoryName;
2822            _categoryResults = [];
2823            _categoryHasMore = true;
2824            _categoryLoading = false;
2825            await fetchCategoryPage();
2826        }
2827
2828        async function fetchCategoryPage() {
2829            if (_categoryLoading || !_categoryHasMore || !_currentCategoryId) return;
2830            _categoryLoading = true;
2831            try {
2832                const offset = _categoryResults.length;
2833                const data = await apiCall(`/api/products/by_category?category_id=${encodeURIComponent(_currentCategoryId)}&limit=${_CATEGORY_PAGE}&offset=${offset}`);
2834                const more = (data && data.products) ? data.products : [];
2835                _categoryResults = _categoryResults.concat(more);
2836                _categoryHasMore = !!(data && data.has_more);
2837            } catch (err) { _categoryHasMore = false; }
2838            _categoryLoading = false;
2839            renderCategoryDropdown();
2840        }
2841
2842        function renderCategoryDropdown() {
2843            if (!elements.searchDropdown) return;
2844            if (!_categoryResults.length) {
2845                elements.searchDropdown.innerHTML = '<div class="dropdown-empty">該当商品なし</div>';
2846                elements.searchDropdown.classList.add('active');
2847                return;
2848            }
2849            let html = _categoryResults.map(p => {
2850                const img = p.image_url
2851                    ? `<img src="${escapeHtml(p.image_url)}" loading="lazy" alt="" style="width:36px;height:36px;object-fit:contain;border-radius:4px;background:#fff;flex-shrink:0;">`
2852                    : `<div style="width:36px;height:36px;border-radius:4px;background:var(--border);flex-shrink:0;"></div>`;
2853                return `<div class="dropdown-item" data-jan="${escapeHtml(p.jan)}" style="display:flex;align-items:center;gap:10px;">
2854                    ${img}
2855                    <div style="flex:1;min-width:0;">
2856                        <div class="dropdown-item-name">${escapeHtml((p.name || '').substring(0,80))}</div>
2857                        <div class="dropdown-item-code">JAN: ${escapeHtml(p.jan)}</div>
2858                    </div>
2859                </div>`;
2860            }).join('');
2861            if (_categoryHasMore) {
2862                const label = _categoryLoading ? '読み込み中...' : `さらに読み込む (+${_CATEGORY_PAGE})`;
2863                html += `<div id="categoryLoadMore" style="padding:10px;text-align:center;cursor:pointer;color:var(--primary);font-weight:600;font-size:0.85rem;border-top:1px solid var(--border);">${label}</div>`;
2864            }
2865            elements.searchDropdown.innerHTML = html;
2866            elements.searchDropdown.classList.add('active');
2867            elements.searchDropdown.querySelectorAll('.dropdown-item').forEach(el => {
2868                el.addEventListener('click', () => {
2869                    elements.searchDropdown.classList.remove('active');
2870                    currentFrom = 'popular'; _updateBackBtn('popular');
2871                    showProduct(el.dataset.jan);
2872                });
2873            });
2874            const moreBtn = document.getElementById('categoryLoadMore');
2875            if (moreBtn) {
2876                moreBtn.addEventListener('click', (e) => {
2877                    e.stopPropagation();
2878                    fetchCategoryPage();
2879                });
2880            }
2881        }
2882
2883        async function switchPopularTab(tabName) {
2884            _currentPopularTab = tabName;
2885            try { localStorage.setItem('kaitorix_popular_tab', tabName); } catch(e) {}
2886            const tSearch = document.getElementById('tabSearchCount');
2887            const tCat = document.getElementById('tabCategory');
2888            const popTags = document.getElementById('popularTags');
2889            const catCont = document.getElementById('categoryChipsContainer');
2890            if (!tSearch || !tCat || !popTags || !catCont) return;
2891            if (tabName === 'search_count') {
2892                tSearch.classList.add('is-active');
2893                tCat.classList.remove('is-active');
2894                popTags.style.display = 'flex';
2895                catCont.style.display = 'none';
2896            } else {
2897                tCat.classList.add('is-active');
2898                tSearch.classList.remove('is-active');
2899                popTags.style.display = 'none';
2900                catCont.style.display = 'flex';
2901                await renderCategoryChips();
2902            }
2903        }
2904
2905        function toggleCollapse(bodyId, btnId, storageKey) {
2906            const body = document.getElementById(bodyId);
2907            const btn = document.getElementById(btnId);
2908            if (!body || !btn) return;
2909            const isCollapsed = body.classList.toggle('is-hidden');
2910            btn.classList.toggle('is-collapsed', isCollapsed);
2911            btn.setAttribute('aria-expanded', String(!isCollapsed));
2912            try { localStorage.setItem(storageKey, isCollapsed ? '1' : '0'); } catch(e) {}
2913        }
2914
2915        function restoreCollapsedState() {
2916            try {
2917                if (localStorage.getItem('kaitorix_popular_collapsed') === '1') {
2918                    const body = document.getElementById('popularBody');
2919                    const btn = document.getElementById('popularCollapseBtn');
2920                    if (body) body.classList.add('is-hidden');
2921                    if (btn) { btn.classList.add('is-collapsed'); btn.setAttribute('aria-expanded', 'false'); }
2922                }
2923                if (localStorage.getItem('kaitorix_history_collapsed') === '1') {
2924                    const body = document.getElementById('historyBody');
2925                    const btn = document.getElementById('historyCollapseBtn');
2926                    if (body) body.classList.add('is-hidden');
2927                    if (btn) { btn.classList.add('is-collapsed'); btn.setAttribute('aria-expanded', 'false'); }
2928                }
2929                const savedTab = localStorage.getItem('kaitorix_popular_tab');
2930                if (savedTab === 'category') {
2931                    switchPopularTab('category');
2932                }
2933            } catch (e) {}
2934        }
2935
2936        function getHistoryFromStorage() {
2937            try {
2938                const data = localStorage.getItem(HISTORY_KEY);
2939                if (!data) return [];
2940                const history = JSON.parse(data);
2941                const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
2942                return history.filter(item => item.timestamp > weekAgo);
2943            } catch (err) { return []; }
2944        }
2945
2946        function saveToHistory(jan, name, extra) {
2947            try {
2948                let history = getHistoryFromStorage().filter(item => item.jan !== jan);
2949                const entry = { jan, name, timestamp: Date.now() };
2950                if (extra) {
2951                    if (extra.price > 0) entry.price = extra.price;   // 閲覧時点の最高買取価格
2952                    if (extra.img) entry.img = extra.img;
2953                    if (extra.cat) entry.cat = extra.cat;             // カテゴリ嗜好の蓄積
2954                }
2955                history.unshift(entry);
2956                history = history.slice(0, MAX_HISTORY_ITEMS);
2957                localStorage.setItem(HISTORY_KEY, JSON.stringify(history)
2957);
2958                loadHistory();
2959                // ログイン済みならサーバにも保存(端末またぎ)。匿名は黙ってスキップ。
2960                // ログイン判定は /api/auth/me で確定した印(data-auth)で見る。アバター枠は常時表示で、
2961                // is-guest も localStorage の先当てで外れる=どちらも退出後・期限切れで嘘をつく(2026-09-26 review)
2962                const avatarBtn = document.getElementById('userAvatarBtn');
2963                if (avatarBtn && avatarBtn.dataset.auth === 'user') {
2964                    fetch('/api/me/recent-views', {
2965                        method: 'POST', credentials: 'same-origin',
2966                        headers: { 'Content-Type': 'application/json' },
2967                        body: JSON.stringify({ jan, name, ts: entry.timestamp,
2968                            price: entry.price || 0, img: entry.img || '', cat: entry.cat || '' })
2969                    }).catch(() => {});
2970                }
2971            } catch (err) { }
2972        }
2973
2974        function clearHistory() {
2975            localStorage.removeItem(HISTORY_KEY);
2976            document.getElementById('historySection').style.display = 'none';
2977        }
2978
2979        // ==================== まとめ機能 (Matome Cart) ====================
2980        function getMatomeFromStorage() {
2981            try {
2982                const data = localStorage.getItem(MATOME_KEY);
2983                return data ? JSON.parse(data) : { items: [], updatedAt: null };
2984            } catch (err) { return { items: [], updatedAt: null }; }
2985        }
2986
2987        function saveMatomeToStorage(matome) {
2988            matome.updatedAt = Date.now();
2989            localStorage.setItem(MATOME_KEY, JSON.stringify(matome));
2990        }
2991
2992        function addToMatome(jan, name, quantity, image_url) {
2993            const matome = getMatomeFromStorage();
2994            const existingIndex = matome.items.findIndex(item => item.jan === jan);
2995            if (existingIndex >= 0) {
2996                matome.items[existingIndex].quantity += quantity;
2997                if (matome.items[existingIndex].quantity > 999) matome.items[existingIndex].quantity = 999;
2998                saveMatomeToStorage(matome);
2999                return { success: true, message: `数量を更新しました(合計 ${matome.items[existingIndex].quantity}個)` };
3000            }
3001            if (matome.items.length >= MAX_MATOME_ITEMS) return { success: false, message: `最大${MAX_MATOME_ITEMS}種類までです` };
3002            matome.items.push({ jan, name, quantity, image_url, addedAt: Date.now() });
3003            saveMatomeToStorage(matome);
3004            return { success: true, message: `${quantity}個追加しました` };
3005        }
3006
3007        function updateMatomeItemQuantity(jan, quantity) {
3008            const matome = getMatomeFromStorage();
3009            const item = matome.items.find(item => item.jan === jan);
3010            if (item) { item.quantity = Math.max(1, Math.min(999, quantity)); saveMatomeToStorage(matome); }
3011            updateMatomeUI();
3012        }
3013
3014        function removeFromMatome(jan) {
3015            const matome = getMatomeFromStorage();
3016            matome.items = matome.items.filter(item => item.jan !== jan);
3017            saveMatomeToStorage(matome);
3018            updateMatomeUI();
3019        }
3020
3021        function clearMatome() {
3022            if (!confirm('まとめリストをすべて削除しますか?')) return;
3023            saveMatomeToStorage({ items: [], updatedAt: Date.now() });
3024            collapseMatomeBar();
3025            updateMatomeUI();
3026        }
3027
3028        function updateMatomeUI() {
3029            const matome = getMatomeFromStorage();
3030            const count = matome.items.length;
3031            const totalQty = matome.items.reduce((sum, item) => sum + item.quantity, 0);
3032
3033            const bar = document.getElementById('matomeBar');
3034            const fab = document.getElementById('matomeCartFab');
3035            const popover = document.getElementById('matomeCartPopover');
3036
3037            if (count === 0) {
3038                bar.style.display = 'none';
3039                document.body.classList.remove('has-matome-bar');
3040                if (fab) { fab.classList.remove('visible'); fab.style.display = 'none'; }
3041                if (popover) popover.classList.remove('open');
3042            } else {
3043                bar.style.display = 'block';
3044                document.body.classList.add('has-matome-bar');
3045                document.getElementById('matomeItemCount').textContent = count;
3046                document.getElementById('matomeTotalQuantity').textContent = totalQty;
3047                if (fab) {
3048                    fab.classList.add('visible');
3049                    fab.style.display = 'flex';
3050                    const badge = document.getElementById('matomeCartBadge');
3051                    if (badge) { badge.textContent = count; badge.style.display = 'flex'; }
3052                }
3053                const popoverCount = document.getElementById('matomePopoverCount');
3054                const popoverTotalQty = document.getElementById('matomePopoverTotalQty');
3055                if (popoverCount) popoverCount.textContent = count;
3056                if (popoverTotalQty) popoverTotalQty.textContent = totalQty;
3057                renderMatomeItemsList();
3058            }
3059        }
3060
3061        function renderMatomeItemsList() {
3062            const matome = getMatomeFromStorage();
3063            const itemsHtml = matome.items.map(item => {
3064                const imgHtml = item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name || '')}" width="40" height="40">` : `<svg class="ui-icon" style="opacity:0.3" viewBox="0 0 24 24">
3064<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`;
3065                return `
3066                    <div class="matome-item" data-jan="${escapeHtml(item.jan)}">
3067                        <div class="matome-item-img">${imgHtml}</div>
3068                        <div class="matome-item-info">
3069                            <div class="matome-item-name">${escapeHtml(item.name)}</div>
3070                            <div class="matome-item-jan">JAN: ${escapeHtml(item.jan)}</div>
3071                        </div>
3072                        <div class="matome-item-controls">
3073                            <div class="matome-item-qty">
3074                                <button class="matome-qty-minus">-</button>
3075                                <input type="number" class="matome-qty-input" value="${item.quantity}" min="1" max="999">
3076                                <button class="matome-qty-plus">+</button>
3077                            </div>
3078                            <button class="matome-item-delete">✕</button>
3079                        </div>
3080                    </div>
3081                `;
3082            }).join('');
3083            document.getElementById('matomeItemsList').innerHTML = itemsHtml;
3084            const popoverItems = document.getElementById('matomePopoverItems');
3085            if (popoverItems) popoverItems.innerHTML = itemsHtml;
3086            bindMatomeItemEvents();
3087        }
3088
3089        function bindMatomeItemEvents() {
3090            document.querySelectorAll('.matome-item').forEach(el => {
3091                const jan = el.dataset.jan;
3092                const input = el.querySelector('.matome-qty-input');
3093                el.querySelector('.matome-qty-minus').addEventListener('click', () => { input.value = Math.max(1, parseInt(input.value) - 1); updateMatomeItemQuantity(jan, input.value); });
3094                el.querySelector('.matome-qty-plus').addEventListener('click', () => { input.value = Math.min(999, parseInt(input.value) + 1); updateMatomeItemQuantity(jan, input.value); });
3095                input.addEventListener('change', () => { input.value = Math.max(1, Math.min(999, parseInt(input.value) || 1)); updateMatomeItemQuantity(jan, input.value); });
3096                el.querySelector('.matome-item-delete').addEventListener('click', () => removeFromMatome(jan));
3097            });
3098        }
3099
3100        function expandMatomeBar() {
3101            matomeBarExpanded = true;
3102            document.getElementById('matomeBarCollapsed').style.display = 'none';
3103            document.getElementById('matomeBarExpanded').style.display = 'flex';
3104            renderMatomeItemsList();
3105        }
3106
3107        function collapseMatomeBar() {
3108            matomeBarExpanded = false;
3109            document.getElementById('matomeBarCollapsed').style.display = 'flex';
3110            document.getElementById('matomeBarExpanded').style.display = 'none';
3111        }
3112
3113        async function startMatomeComparison() {
3114            const matome = getMatomeFromStorage();
3115            if (matome.items.length === 0) return;
3116
3117            // GA4: begin_checkout (まとめ比較開始)
3118            if (window.gtag) gtag('event', 'begin_checkout', {
3119                items: matome.items.map(i => ({item_id: i.jan, quantity: i.quantity}))
3120            });
3121
3122            previousViewState = elements.productSection.classList.contains('active') ? { type: 'product', jan: currentJan } : { type: 'search' };
3123            elements.searchSection.style.display = 'none';
3124            elements.productSection.classList.remove('active');
3125            // まとめ比較画面では「値動き」「人気メニュー」セクションを隠す(戻る時に復帰)
3126            const kxQuickSection = document.querySelector('.kx-quick-section');
3127            if (kxQuickSection) kxQuickSection.style.display = 'none';
3128            // 比較結果の下にサイト紹介が丸ごと続くと、結果を読み終えた導線が切れる。
3129            // コミュニティへの誘導(tg-dc-grid)だけ残して他は畳む
3130            toggleAboutForMatome(true);
3131            // 矢印とドットは #heroCarousel の外(.hero-wrap 直下の #heroControls)にあるので
3132            // カルーセルだけ隠すと操作系が残る。ラッパーごと畳む
3133            const heroWrap = document.querySelector('.hero-wrap');
3134            if (heroWrap) heroWrap.style.display = 'none';
3135            // 比較結果の上に浮くものは出さない(May 指示 2026-08-14)。
3136            // ただし **まとめ FAB だけは残す**(May 指示 2026-08-19)——比較したまま
3137            // 中身を確認・増減できる導線が無いと、リストを直すのに一度戻るしかない。
3138            document.querySelectorAll('.discord-fab, .x-fab').forEach(el => { el.style.display = 'none'; });
3139            // 対象商品は入るたびに畳んだ状態から。既定値だけ変えても「前回開いていた」が
3140            // 保存されている人には効かず、225 種類だと結果まで辿り着けない(保存はしない)
3141            splitSettings.itemsCollapsed = true;
3142            document.getElementById('matomeBar').style.display = 'none';
3143            document.body.classList.remove('has-matome-bar');
3144            const fab = document.getElementById('matomeCartFab');
3145            const popover = document.getElementById('matomeCartPopover');
3146            // この画面では DC/X FAB を出さない=右下の席が空くので、まとめ FAB を
3147            // 本来の「席 +68px」から角の席へ寄せる(CSS: body.kx-matome-compare)。
3148            // ポップオーバーは元から角に置かれているので、寄せた方が縦に揃う。
3149            document.body.classList.add('kx-matome-compare');
3150            if (fab && matome.items.length > 0) {
3151                fab.classList.add('visible');
3152                fab.style.display = 'flex';
3153                const cartBadge = document.getElementById('matomeCartBadge');
3154                if (cartBadge) { cartBadge.textContent = matome.items.length; cartBadge.style.display = 'flex'; }
3155            }
3156            if (popover) popover.classList.remove('open');
3157            collapseMatomeBar();
3158            showLoading(true);
3159
3160            try {
3161                fetch('/api/matome/log', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({count: matome.items.length})}).catch(() => {});
3162                const products = await fetchProductsBatch(matome.items.map(item => item.jan));
3163                const validProducts = products.filter(p => p !== null);
3164                if (validProducts.length === 0) throw new Error('商品データを取得できませんでした');
3165
3166                const storeResults = calculateStoreResults(matome.items, validProducts);
3167                renderMatomeComparison(matome.items, validProducts, storeResults);
3168
3169                showLoading(false);
3170                document.getElementById('matomeResultSection').style.display = 'block';
3171                // 比較結果画面では下の「まとめ ◯件/一括比較する」バーは出さない。
3172                // 今まさに見ているものをもう一度実行するボタンで、下端を塞ぐだけだった。
3173                // has-matome-bar を残すとバーが無いのに下余白だけ空くので一緒に外す
3174                document.getElementById('matomeBar').style.display = 'none';
3175                document.body.classList.remove('has-matome-bar');
3176                // URLにmatome=compareを追加(リロード時に自動復帰)
3177                if (!new URLSearchParams(location.search).has('matome')) {
3178                    const u = new URL(location.href); u.searchParams.set('matome', 'compare');
3179                    history.replaceState(null, '', u.toString());
3180                }
3181
3182                // まとめURL共有ボタン。バーの中の静的ボタンを出すだけにする
3183                const shareUrl = encodeMatomToUrl();
3184                const shareBtn = document.getElementById('matomeShareBtn');
3185                if (shareBtn) {
3186                    shareBtn.hidden = !shareUrl;
3187                    shareBtn.onclick = shareUrl ? function () {
3188                        copyToClipboard(shareUrl, 'まとめ比較のURLをコピーしました');
3189                        shareBtn.classList.add('is-done');
3190                        setTimeout(() => shareBtn.classList.remove('is-done'), 2000);
3191                    } : null;
3192                }
3193            } catch (err) {
3194                showLoading(false); showError(err.message); goBackFromMatomeComparison();
3195            }
3196        }
3197
3198        const MATOME_STORE_NAMES = KX_STORE_NAME_BY_KEY;
3199        let bestFilterStores = (() => {
3200            try { const s = JSON.parse(localStorage.getItem('kaitorix_bestFilter')); if (Array.isArray(s)) return new Set(s); } catch {}
3201            return new Set(Object.values(MATOME_STORE_NAMES));
3202        })();
3203        function saveBestFilter() { try { localStorage.setItem('kaitorix_bestFilter', JSON.stringify([...bestFilterStores])); } catch {} }
3204
3205        // ===== まとめ買取の「分けて売る」最適化 =====
3206        // 1店にまとめるより複数店に分けた方が手取りが増えることがある一方、箱が増えるぶん
3207        // 送料と振込手数料がかかり、元の店の送料無料条件を割ってしまうこともある。
3208        // 全組合せ(最大3店=4,991通り)を素直に総当たりして手取りが最大になる分け方を出す。
3209        // 実測 20点でも 25ms 程度なので、剪定はしない(最高値を持つ店だけに絞ると最適解を取り逃す)。
3210        // 店舗チップは SP だと 31 個で 1 画面を埋めるので、狭い画面は初期状態で畳んでおく
3211        const SPLIT_DEFAULTS = { maxStores: 2, shipping: true, shippingCost: 1100, transferFee: true,
3212                                 walkin: false,
3213                                 itemsCollapsed: true, storesCollapsed: window.matchMedia('(max-width: 768px)').matches };
3214
3215        // ===== 店頭に持ち込む(店頭買取)モード =====
3216        // 自分で店まで運ぶので送料は一切掛からず、その場で現金を受け取るのが基本なので
3217        // 振込手数料も掛からない。さらに一部の店は「来店+200円」のように**持ち込んだ時だけ**
3218        // 単価を上げる(DB の買取価は郵送基準のまま=May 2026-07-23 裁決。加算は
3219        // product_mark_notes の備考から取り出して walkin_bonus で降ってくる)。
3220        // 送料・振込手数料のトグルは値を保ったまま、この 2 本の出口だけで無効化する。
3221        // そうしないとモードを戻した時に利用者の設定が消える。
3222        // 店頭買取の拠点がある店だけが持ち込み先になる(宅配専門の店を送料0で並べると
3223        // 存在しない得を見せることになる)。真源は shop_snapshots の layer7_locations。
3224        function splitWalkinMap() { return (window.KX_SHIPPING_RULES || {}).walkin || {}; }
3225        function splitCanWalkin(storeKey) { return !!splitWalkinMap()[storeKey]; }
3226        // 🔴 「店頭モードか」の判定はこの 1 本だけ。`splitSettings.walkin` を直に見ては
3227        //    いけない——拠点データが空(snapshot が読めない等。_load_walkin() は握り潰して
3228        //    {} を返す)だと画面側は全部「店頭モードではない」として描くのに、計算側だけが
3229        //    ON のままになる。そうなると送料が黙って計算から外れ、送料トグルは ON で
3230        //    有効に見えるのに押しても何も変わらず、しかも 店頭に持ち込む ボタン自体が
3231        //    描かれないので**フラグを戻す手段が無い**(設定は保存されるのでリロードでも
3232        //    直らない)。splitRenderEmpty で塞いだのと同じ「詰み」がもう一方の枝から入る。
3233        function splitWalkinOn() {
3234            return !!splitSettings.walkin && Object.keys(splitWalkinMap()).length > 0;
3235        }
3236        function splitEffShipCost() {
3237            if (splitWalkinOn()) return null;
3238            return splitSettings.shipping ? splitSettings.shippingCost : null;
3239        }
3240        function splitEffFeeOn() { return !splitWalkinOn() && !!splitSettings.transferFee; }
3241        // 「来店増額はあるが金額が書かれていない」備考(森森の
3242        // 「※来店増額金額は在庫状況により変動いたします」など)。数字を作らずに注記だけ出す。
3243        // 🔴 ただの /来店/ で見てはいけない——「*北戸田郵送&本部来店限定*」は加算ではなく
3244        //    受付条件で、拾うと全く別の意味の注記が出る。
3245        const KX_WALKIN_VAGUE_RE = /来店\s*(?:増額|加算|アップ|UP)/i;
3246        // 拠点の要約(「東京2・大阪1」)。areas は snapshot の「東京 2」形式なので空白を詰める
3247        function splitWalkinAreas(storeKey) {
3248            const w = splitWalkinMap()[storeKey];
3249            if (!w) return '';
3250            const a = (w.areas || []).map(x => String(x).replace(/\s+/g, ''));
3251            // 空なら何も足さない。呼び出し側が既に「N店舗」と出しているので、
3252            // ここで拠点数を繰り返すと「3店舗(3拠点)」になる
3253            if (!a.length) return '';
3254            // 🔴 「ほか7」と単位を省くと、括弧の中の数字が店舗数なので
3255            //    「あと7店舗」と読める(実際は 7 エリア=東京4・千葉3…の続き)。
3256            //    /shops の拠点カードと同じく「ほか◯エリア」と単位を書く
3257            return a.length > 4 ? a.slice(0, 4).join('・') + `ほか${a.length - 4}エリア` : a.join('・');
3258        }
3259
3260        let splitSettings = (() => {
3261            try {
3262                const s = JSON.parse(localStorage.getItem('kaitorix_splitSettings'));
3263                if (s && typeof s === 'object') return Object.assign({}, SPLIT_DEFAULTS, s);
3264            } catch {}
3265            return Object.assign({}, SPLIT_DEFAULTS);
3266        })();
3267
3268        function saveSplitSettings() {
3269            try { localStorage.setItem('kaitorix_splitSettings', JSON.stringify(splitSettings)); } catch {}
3270            // ログイン中ならアカウント側にも保存して端末をまたいで同じ設定を使えるようにする
3271            fetch('/api/user-settings', {
3272                method: 'PUT', credentials: 'same-origin',
3273                headers: { 'Content-Type': 'application/json' },
3274                body: JSON.stringify({ split_settings: splitSettings })
3275            }).catch(() => {});
3276        }
3277
3278        function loadSplitSettingsFromAccount() {
3279            return fetch('/api/user-settings', { credentials: 'same-origin' })
3280                .then(r => (r.ok ? r.json() : null))
3281                .then(s => {
3282                    if (s && s.split_settings && typeof s.split_settings === 'object') {
3283                        splitSettings = Object.assign({}, SPLIT_DEFAULTS, s.split_settings);
3284                        try { localStorage.setItem('kaitorix_splitSettings', JSON.stringify(splitSettings)); } catch {}
3285                    }
3286                })
3287                .catch(() => {});
3288        }
3289
3290        // --- ルール判定 ---
3291        function splitItemMatches(item, cond) {
3292            if (cond.categories && cond.categories.indexOf(item.category) === -1) return false;
3293            if (cond.include_name_patterns &&
3294                !cond.include_name_patterns.some(p => (item.name || '').toLowerCase().includes(p.toLowerCase()))) return false;
3295            return true;
3296        }
3297
3298        // この商品を点数に数えるか(買取wiki の相紙類、ドラゴンのゲーム機・カード類などを落とす)。
3299        // 🔴 単純な部分一致だけで「除外語」を書くと必ず獲り過ぎる。買取wiki の
3300        //    「チェキ用フィルム関連は数えない」を ['チェキ','instax','フィルム'] と書いた結果、
3301        //    チェキ**本体**も、商品名に「富士フィルム」を含むだけの X-T5 / X100VI も数えない
3302        //    扱いになっていた(2026-08-16 発見。DB 実測 995 件中 936 件が誤除外)。
3303        //    そのため「語の合わせ技」を rules 側で書けるようにしてある:
3304        //      count_exclude_name_rules[] = {
3305        //        strip:   語を探す前に消す文字列(ブランド名「富士フイルム」自体が"フイルム"を含む)
3306        //        all_of:  [[候補…], [候補…]] — 各グループから 1 つ以上含む時だけ該当
3307        //        none_of: 1 つでも含んだら除外しない(ケース・アルバム等の周辺小物の救済)
3308        //      }
3309        //    none_of は strip 前の元の名前で見る(ブランド名を消した副作用で救済語まで
3310        //    消えるのを避けるため)。
3311        function splitCountItem(it, fs) {
3312            const name = (it.name || '').toLowerCase();
3313            if (fs.count_exclude_categories && fs.count_exclude_categories.indexOf(it.category) !== -1) return false;
3314            if (fs.count_exclude_name_patterns &&
3315                fs.count_exclude_name_patterns.some(p => name.includes(p.toLowerCase()))) return false;
3316            for (const r of (fs.count_exclude_name_rules || [])) {
3317                let s = name;
3318                for (const w of (r.strip || [])) s = s.split(w.toLowerCase()).join(' ');
3319                if (!(r.all_of || []).every(g => g.some(a => s.includes(a.toLowerCase())))) continue;
3320                if ((r.none_of || []).some(w => name.includes(w.toLowerCase()))) continue;
3321                return false;
3322            }
3323            return true;
3324        }
3325
3326        function splitCountable(items, fs) {
3327            return items.filter(it => splitCountItem(it, fs));
3328        }
3329
3330        // countable は箱ごとに 1 回だけ作って使い回す。条件ごとに filter すると
3331        // 5,000 通り × 箱数 ぶん配列を作ることになり、60点規模で 1 秒級に膨らむ
3332        function splitCondOk(cond, countable, amount) {
3333            if (cond.any_of) return cond.any_of.some(c => splitCondOk(c, countable, amount));
3334            if (cond.amount) return amount >= cond.amount.min;
3335            if (cond.count) {
3336                let n = 0;
3337                for (const it of countable) if (splitItemMatches(it, cond.count)) n += it.quantity;
3338                return n >= cond.count.min;
3339            }
3340            if (cond.weighted_count) {
3341                const w = cond.weighted_count.weights || {};
3342                let sum = 0;
3343                for (const it of countable) sum += (w[it.category] || 0) * it.quantity;
3344                return sum >= cond.weighted_count.min;
3345            }
3346            return true;
3347        }
3348
3349        // 1 箱ぶんの送料負担と、その理由(画面にそのまま出す日本語)を返す。
3350        // 🔴 costArg === null は「送料を計算に入れる」が OFF。その時は実費だけでなく
3351        //    **店側の固定差引(買取wiki 3,000円・モバイルミックス 2,000円)も引かない**。
3352        //    以前は OFF を「実費 0 円」で表していたため、unmet.type='fixed' の分岐だけが
3353        //    設定をすり抜けて残り、チェックを外しても「送料 −¥3,000」が出た上に
3354        //    その店の手取りが 3,000 円低く評価されて分割案の順位まで狂っていた
3355        //    (2026-08-17 利用者報告)。OFF を null で表して各分岐の入口で落とす。
3356        function splitShipping(storeKey, items, amount, costArg) {
3357            const rules = (window.KX_SHIPPING_RULES || {}).stores || {};
3358            const r = rules[storeKey];
3359            const cost = costArg !== undefined ? costArg : splitEffShipCost();
3360            // 金額は 0 に落とすが、条件そのもの(あと何点で無料か)は判断材料として出したいので
3361            // 判定は最後まで続ける。画面側は off を見て「計算に含ã
3361‚ãšã€ã¨æ›¸ã
3362            const off = cost === null;
3363            const paid = off ? 0 : cost;
3364            if (!r || !r.free_ship) return { cost: paid, off: off, label: '送料は自己負担', free: false };
3365            const fs = r.free_ship;
3366            if (fs.kind === 'always') return { cost: 0, off: off, label: '条件なしで着払いOK', free: true, keepLabel: true };
3367            if (fs.kind === 'never') return { cost: paid, off: off, label: '着払いに対応していないため自己負担', free: false };
3368            if (fs.kind === 'unknown') return { cost: paid, off: off, label: '送料条件が公表されていないため自己負担で計算', free: false };
3369
3370            const countable = splitCountable(items, fs);
3371            const ok = (fs.all_of || []).every(c => splitCondOk(c, countable, amount));
3372            if (ok) return { cost: 0, off: off, label: '着払いの条件を満たすため送料無料', free: true, keepLabel: true };
3373
3374            // 何が足りないのかを日本語で出す。ここが利用者にとって一番使える情報になる。
3375            // 「あと」は文頭に一度だけ置く(各項に付けると「あと3点とあと2万円」と重複して読みにくい)
3376            const lack = c => {
3377                if (c.amount) return (c.amount.min - amount).toLocaleString() + '円';
3378                if (c.count) {
3379                    let n = 0;
3380                    for (const it of countable) if (splitItemMatches(it, c.count)) n += it.quantity;
3381                    // 単位は常に「点」(2026-08-16 May 指示)。店の原文は「スマホ3点」(wiki・
3382                    // 一丁目・ドラゴン)と「スマホ3台」(モバイル一番・買取奥・モバステ・
3383                    // モバイルミックス・ゲスト)が混在しているので、どちらかに寄せないと
3384                    // 同じ画面で表記が割れる。以前は「categories にスマホが入っていれば台」
3385                    // としていたが、森森のように 8 カテゴリを数える条件まで「台」になっていた。
3386                    // 単位を揃えると「5点以上 or スマホ3点以上」が「あと3点か2点」になって
3387                    // どちらの条件か読めなくなるので、絞り込みのある側だけ括弧で対象を添える。
3388                    // 対象が多すぎる時(森森の 8 カテゴリ)は書かない=ただの「点」に戻す。
3389                    const cs = c.count.categories || [], ps = c.count.include_name_patterns || [];
3390                    const scope = ps.length ? ps.join('・') : (cs.length === 1 ? cs[0] : '');
3391                    return (c.count.min - n) + '点' + (scope ? `(${scope})` : '');
3392                }
3393                return null;
3394            };
3395            const short = [];
3396            (fs.all_of || []).forEach(c => {
3397                if (splitCondOk(c, countable, amount)) return;
3398                if (c.any_of) {
3399                    const cands = c.any_of.map(lack).filter(Boolean);
3400                    if (cands.length) short.push(cands.join('か'));
3401                } else {
3402                    const t = lack(c);
3403                    if (t) short.push(t);
3404                }
3405            });
3406            const unmet = fs.unmet || { type: 'actual' };
3407            const c = off ? 0 : (unmet.type === 'fixed' ? unmet.amount : cost);
3408            const label = short.length
3409                ? 'あと' + short.join('と') + 'で送料無料'
3410                : '着払いの条件を満たさないため自己負担';
3411            // `unmet.extra_fee`(ゲストモバイルの事務手数料 1,000 円)は**金額に足さない**。
3412            // 原文は「ゆうパック以外の着払い/受取人払いは 着払い送料+事務手数料1,000円を差引」=
3413            // 掛かるかどうかは運送会社の選び方で決まり、こちらでは分からない。多数派(ゆうパック・
3414            // 元払い)には掛からないので、黙って引くとその店を不当に低く見せる。振込手数料が
3415            // 非公開の店と同じ扱いで、**引かずに注記だけ出す**(2026-08-17 May 裁決)。
3416            const extra = unmet.extra_fee
3417                ? { amount: unmet.extra_fee, carriers: (r.free_ship.carriers || []).join('・') } : null;
3418            return { cost: c, off: off, label: label, free: false, extra: extra,
3419                     fixed: !off && unmet.type === 'fixed', keepLabel: short.length > 0 };
3420        }
3421
3422        function splitTransferFee(storeKey, items, amount) {
3423            if (!splitEffFeeOn()) return 0;
3424            const rules = (window.KX_SHIPPING_RULES || {}).stores || {};
3425            const tf = (rules[storeKey] || {}).transfer_fee;
3426            // 🔴 「ルールが無い」=「0 円」ではない。店を足した直後は必ずここに落ち、
3427            //    その店だけ手数料ゼロ扱い=手取りを過大評価して「分けて売る」最適化が
3428            //    その店を不当に選ぶ(2026-08-16 に新6店で発生)。
3429            //    実額が分からない以上、勝手な既定値を引くのも同じくらい嘘なので
3430            //    **引かずに 0 を返し、その代わり UI に「振込手数料は非
3430公開」と出す**
3431            //    (表示は split-box-meta 側。数字を黙って作らないことを優先する)。
3432            if (!tf || tf.unknown) return 0;
3433            const qty = items.reduce((s, it) => s + it.quantity, 0);
3434            for (const t of (tf.tiers || [])) {
3435                if (t.when) {
3436                    if (qty >= (t.when.count_min || 0) && amount >= (t.when.amount_min || 0)) return t.fee;
3437                } else if (t.amount_below !== undefined) {
3438                    if (amount < t.amount_below) return t.fee;
3439                } else if (t.amount_at_least !== undefined) {
3440                    if (amount >= t.amount_at_least) return t.fee;
3441                }
3442            }
3443            return tf.default || 0;
3444        }
3445
3446        // --- 最適化本体 ---
3447        // 5,000 通りの組合せを回すので、列挙中は「箱の中身の配列」を作らない。
3448        // 条件判定に要る数(点数・金額・カテゴリ別台数)は事前に item×店 のマスクへ落としておき、
3449        // 列挙中はマスクを見て足すだけにする。明細を組み立てるのは上位に残った組合せだけ。
3450        function splitPrepare(items, stores, priceOf) {
3451            const nI = items.length, nS = stores.length;
3452            const price = new Float64Array(nI * nS);
3453            const qty = new Float64Array(nI);
3454            items.forEach((it, i) => {
3455                qty[i] = it.quantity;
3456                stores.forEach((s, k) => { price[i * nS + k] = priceOf(it, s) || 0; });
3457            });
3458
3459            const rules = (window.KX_SHIPPING_RULES || {}).stores || {};
3460            // 店ごとに「この条件に当てはまる商品はどれか」を先に配列で持つ
3461            const meta = stores.map((s) => {
3462                const fs = (rules[s] || {}).free_ship || { kind: 'never' };
3463                // 判定は splitCountable と同じ 1 本(splitCountItem)に寄せる。
3464                // ここに同じ条件をもう一度書くと、片方だけ直した時に
3465                // 「明細の点数」と「最適化が使う点数」が静かに食い違う
3466                const countable = items.map(it => splitCountItem(it, fs));
3467                const leaves = [];   // {kind:'count'|'weighted', mask|weights}
3468                const index = c => {
3469                    if (c.any_of) return { any_of: c.any_of.map(index) };
3470                    if (c.amount) return { amount: c.amount };
3471                    if (c.count) {
3472                        const mask = items.map((it, i) => countable[i] && splitItemMatches(it, c.count));
3473                        leaves.push({ mask: mask });
3474                        return { countIdx: leaves.length - 1, min: c.count.min, raw: c };
3475                    }
3476                    if (c.weighted_count) {
3477                        const w = c.weighted_count.weights || {};
3478                        const vals = items.map((it, i) => (countable[i] ? (w[it.category] || 0) : 0));
3479                        leaves.push({ vals: vals });
3480                        return { weightedIdx: leaves.length - 1, min: c.weighted_count.min, raw: c };
3481                    }
3482                    return { always: true };
3483                };
3484                const tree = (fs.all_of || []).map(index);
3485                return { store: s, fs: fs, tree: tree, leaves: leaves, countable: countable,
3486                         tf: (rules[s] || {}).transfer_fee };
3487            });
3488            return { nI, nS, price, qty, items, stores, meta };
3489        }
3490
3491        function splitCondOkFast(node, sums, amount) {
3492            if (node.any_of) return node.any_of.some(n => splitCondOkFast(n, sums, amount));
3493            if (node.amount) return amount >= node.amount.min;
3494            if (node.countIdx !== undefined) return sums[node.countIdx] >= node.min;
3495            if (node.weightedIdx !== undefined) return sums[node.weightedIdx] >= node.min;
3496            return true;
3497        }
3498
3499        function splitShipCostFast(m, sums, amount, totalQty, cost) {
3500            // cost === null=「送料を計算に入れる」OF
3500F。固定差引も含めて一切引かない
3501            // (ここを素通りさせると、fixed の店だけ手取りが低く出て順位が狂う)
3502            if (cost === null) return 0;
3503            const fs = m.fs;
3504            if (fs.kind === 'always') return 0;
3505            if (fs.kind === 'never' || fs.kind === 'unknown') return cost;
3506            if (m.tree.every(n => splitCondOkFast(n, sums, amount))) return 0;
3507            const u = fs.unmet || { type: 'actual' };
3508            return u.type === 'fixed' ? u.amount : cost;
3509        }
3510
3511        function splitFeeFast(m, amount, totalQty, on) {
3512            if (!on || !m.tf) return 0;
3513            for (const t of (m.tf.tiers || [])) {
3514                if (t.when) {
3515                    if (totalQty >= (t.when.count_min || 0) && amount >= (t.when.amount_min || 0)) return t.fee;
3516                } else if (t.amount_below !== undefined) {
3517                    if (amount < t.amount_below) return t.fee;
3518                } else if (t.amount_at_least !== undefined) {
3519                    if (amount >= t.amount_at_least) return t.fee;
3520                }
3521            }
3522            return m.tf.default || 0;
3523        }
3524
3525        // 組合せ 1 つぶんの手取りを、配列を作らずに数だけで出す
3526        function splitScore(prep, subset, cost, feeOn) {
3527            const { nS, price, qty, meta } = prep;
3528            const k = subset.length;
3529            const amount = new Float64Array(k), tq = new Float64Array(k);
3530            const sums = subset.map(si => new Float64Array(meta[si].leaves.length));
3531            const used = new Uint8Array(k);
3532            let gross = 0;
3533            for (let i = 0; i < prep.nI; i++) {
3534                let bj = -1, bp = 0;
3535                const base = i * nS;
3536                for (let j = 0; j < k; j++) {
3537                    const p = price[base + subset[j]];
3538                    if (p > bp) { bp = p; bj = j; }
3539                }
3540                if (bj < 0) continue;
3541                const q = qty[i];
3542                gross += bp * q;
3543                amount[bj] += bp * q; tq[bj] += q; used[bj] = 1;
3544                const m = meta[subset[bj]], sv = sums[bj];
3545                for (let L = 0; L < m.leaves.length; L++) {
3546                    const lf = m.leaves[L];
3547                    if (lf.mask) { if (lf.mask[i]) sv[L] += q; }
3548                    else sv[L] += lf.vals[i] * q;
3549                }
3550            }
3551            let ship = 0, fee = 0, boxes = 0;
3552            const usedStores = [];
3553            for (let j = 0; j < k; j++) {
3554                if (!used[j]) continue;
3555                boxes++; usedStores.push(subset[j]);
3556                ship += splitShipCostFast(meta[subset[j]], sums[j], amount[j], tq[j], cost);
3557                fee += splitFeeFast(meta[subset[j]], amount[j], tq[j], feeOn);
3558            }
3559            return { net: gross - ship - fee, gross, ship, fee, boxes, usedStores };
3560        }
3561
3562        // 上位 topN 件の組合せを返す(実際に商品が入った店の組合せで重複を畳む)。
3563        // 「一番得な分け方」だけだと、特定の店に送りたくない等の事情に応えられないので候補を出す。
3564        function splitRank(items, candidates, maxStores, priceOf) {
3565            const prep = splitPrepare(items, candidates, priceOf);
3566            // OFF は 0 円ではなく null(=計算に入れない)。0 にすると固定差引の店だけ残る。
3567            // 店頭持ち込みモードでは送料も振込手数料も無い(splitEff* が面倒を見る)
3568            const cost = splitEffShipCost();
3569            const feeOn = splitEffFeeOn();
3570            const seen = new Map();
3571            const chosen = [];
3572            const walk = (start) => {
3573                if (chosen.length) {
3574                    const r = splitScore(prep, chosen, cost, feeOn);
3575                    if (r.boxes) {
3576                        const key = r.usedStores.slice().sort().join('|');
3577                        const prev = seen.get(key);
3578                        if (!prev || r.boxes < prev.boxes) seen.set(key, r);
3579                    }
3580                }
3581                if (chosen.length >= maxStores) return;
3582                for (let i = start; i < candidates.length; i++) {
3583                    chosen.push(i); walk(i + 1); chosen.pop();
3584                }
3585            };
3586            walk(0);
3587            const all = [...seen.values()].sort((a, b) => (b.net - a.net) || (a.boxes - b.boxes));
3588            // 画面に出すぶんだけ、あとから箱の中身を組み立てる
3589            return all.map(r => ({ __prep: prep, __raw: r, net: r.net, gross: r.gross,
3590                                   ship: r.ship, fee: r.fee, boxCount: r.boxes,
3591                                   stores: r.usedStores.map(si => candidates[si]) }));
3592        }
3593
3594        // 選ばれた組合せだけ明細(どの商品をどの店へ)を作る
3595        function splitDetail(entry) {
3596            const prep = entry.__prep;
3597            const subset = entry.__raw.usedStores;
3598            const groups = new Map(subset.map(si => [si, []]));
3599            prep.items.forEach((it, i) => {
3600                let bj = -1, bp = 0;
3601                for (const si of subset) {
3602                    const p = prep.price[i * prep.nS + si];
3603                    if (p > bp) { bp = p; bj = si; }
3604                }
3605                if (bj >= 0) groups.get(bj).push({ it: it, unit: bp });
3606            });
3607            // 順位計算(splitRank)と同じ値を渡す。OFF は null=計算に入れない
3608            const cost = splitEffShipCost();
3609            const boxes = [];
3610            for (const [si, list] of groups) {
3611                if (!list.length) continue;
3612                const its = list.map(x => x.it);
3613                const amount = list.reduce((s, x) => s + x.unit * x.it.quantity, 0);
3614                boxes.push({
3615                    store: prep.stores[si], items: its, list: list, amount: amount,
3616                    ship: splitShipping(prep.stores[si], its, amount, cost),
3617                    fee: splitFeeFast(prep.meta[si], amount, its.reduce((s, x) => s + x.quantity, 0), splitEffFeeOn()),
3618                });
3619            }
3620            return boxes;
3621        }
3622
3623        // 一覧に出す店名の並び順。明細の箱(1箱目・2箱目…)と同じ「買取額の多い順」に揃える。
3624        // 列挙順のままだと「1番目に書いてある店」が一覧と明細で食い違い、照合できない
3625        function splitStoreOrder(entry) {
3626            return splitDetail(entry).sort((a, b) => b.amount - a.amount).map(b => b.store);
3627        }
3628
3629        function renderMatomeComparison(matomeItems, products, storeResults) {
3630            comparisonItems = matomeItems.map(item => ({ ...item }));
3631            comparisonProducts = products;
3632            comparisonModified = false;
3633            renderComparisonSummary();
3634            renderBestFilter();
3635            // 比較対象店や数量を変えたら分割案も出し直す(下のカードと食い違わせない)
3636            renderSplitPlan();
3637            renderComparisonStoreCards(storeResults);
3638        }
3639
3640        function calculateStoreResults(matomeItems, products) {
3641            const productMap = {}; products.forEach(p => { if (p && p.jan) productMap[p.jan] = p; });
3642
3643            // 各商品の最高買取価格を事前計算(フィルタ対象店舗のみ)
3644            const bestPriceMap = {};
3645            matomeItems.forEach(matomeItem => {
3646                const product = productMap[matomeItem.jan];
3647                if (!product || !product.prices?.length) return;
3648                let bestPrice = 0, bestStore = '';
3649                product.prices.forEach(p => {
3650                    if (!bestFilterStores.has(p.store)) return;
3651                    if (p.stale) return; // 更新停止/買取停止疑いの店は最高値の対象外(DP 頁と同基準)
3652                    const dp = deobfuscatePrice(p.price) || 0;
3653                    if (dp > bestPrice) { bestPrice = dp; bestStore = p.store; }
3654                });
3655                bestPriceMap[matomeItem.jan] = { price: bestPrice, store: bestStore };
3656            });
3657
3658            const storeResults = {};
3659            Object.keys(MATOME_STORE_NAMES).forEach(k => storeResults[k] = { name: MATOME_STORE_NAMES[k], total: 0, items: [], availableCount: 0, unavailableCount: 0, staleCount: 0 });
3660
3661            matomeItems.forEach(matomeItem => {
3662                const product = productMap[matomeItem.jan];
3663                if (!product) return;
3664                const best = bestPriceMap[matomeItem.jan] || { price: 0, store: '' };
3665                Object.keys(MATOME_STORE_NAMES).forEach(storeKey => {
3666                    const priceInfo = product.prices?.find(p => p.store === MATOME_STORE_NAMES[storeKey]);
3667                    // stale = 価格更新が止まっている店(買取停止/下架の可能性)。
3668                    // DP 頁では灰色表示+最高値対象外なので、まとめ比較でも合計・最高値に含めない
3669                    const isStale = !!(priceInfo && priceInfo.stale);
3670                    const price = isStale ? 0 : (deobfuscatePrice(priceInfo?.price) || 0);
3671                    const subtotal = price * matomeItem.quantity;
3672                    storeResults[storeKey].items.push({
3673                        jan: matomeItem.jan, name: matomeItem.name, quantity: matomeItem.quantity,
3674                        image: product.image_url || matomeItem.image_url || '',
3675                        unitPrice: price, subtotal: subtotal, stale: isStale,
3676                        bestPrice: best.price, bestStore: best.store,
3677                    });
3678                    if (price > 0) { storeResults[storeKey].total += subtotal; storeResults[storeKey].availableCount += matomeItem.quantity; }
3679                    else if (isStale) { storeResults[storeKey].staleCount += matomeItem.quantity; }
3680                    else { storeResults[storeKey].unavailableCount += matomeItem.quantity; }
3681                });
3682            });
3683            // 「比較対象店舗」で選択された店のみカード表示(未選択店は非表示)。
3684            // これで表示カードと「他店最高」基準が同じ店舗集合になり、整合する。
3685            // 🔴 ここは**店頭モード(splitSettings.walkin)を知らない**——来店加算を乗せず、
3686            //    持ち込めない宅配専門店も落とさない。送料ルールが有る間は
3687            //    renderComparisonStoreCards() が何も描かない(legacy 表示は rules が
3688            //    読めない時だけ)ので実害は無いが、legacy を復活させるなら
3689            //    renderSplitPlan と同じ基準(priceOf に来店加算・candidates を walkin で
3690            //    絞る)へ揃えないと、同じ画面に食い違う「他店最高」が 2 系統出る。
3691            return Object.values(storeResults)
3692                .filter(s => bestFilterStores.has(s.name))
3693                .sort((a, b) => b.total - a.total);
3694        }
3695
3696        // 店舗チップは分割案パネルの操作行の下に出す(トグル本体は renderSplitPlan 側)。
3697        // 分割案が出せない環境では従来どおり単独ブロックに描く
3698        function renderBestFilter() {
3699            const allNamesSet = new Set();
3700            comparisonProducts.forEach(p => { (p.prices || []).forEach(pr => { if (pr.store) allNamesSet.add(pr.store); }); });
3701            const allNames = [...allNamesSet];
3702            // 店頭に持ち込むモードでは、店頭買取の拠点が無い店(宅配専門)は持ち込めない。
3703            // チップを消さずに「押せない+理由」で残す=**なぜ候補から消えたのかが読める**
3704            // ようにするため(黙って消すと店を外した覚えが無いのに減って見える)。
3705            const walkinOn = splitWalkinOn();
3706            const keyByName = {};
3707            Object.keys(MATOME_STORE_NAMES).forEach(k => { keyByName[MATOME_STORE_NAMES[k]] = k; });
3708            const noWalkin = n => walkinOn && !splitCanWalkin(keyByName[n]);
3709            // 「すべて選択/解除」も押せる店だけを見る。押せない店を数に入れると
3710            // 全部選んでも allChecked にならず、ボタンが「すべて選択」のまま固まる
3711            const pickable = allNames.filter(n => !noWalkin(n));
3712            const allChecked = pickable.length > 0 && pickable.every(n => bestFilterStores.has(n));
3713            const chips = `
3714                <button type="button" class="store-chip is-all${allChecked ? ' is-on' : ''}"
3715                    onclick="toggleAllBestFilter(${allChecked ? 'false' : 'true'})">${allChecked ? 'すべて解除' : 'すべて選択'}</button>
3716                ${allNames.map(name => {
3717                    const on = bestFilterStores.has(name);
3718                    if (noWalkin(name)) {
3719                        return `<button type="button" class="store-chip is-off is-nowalkin" disabled
3720                            title="宅配買取のみのため、持ち込める店舗がありません">${escapeHtml(name)}</button>`;
3721                    }
3722                    return `<button type="button" class="store-chip${on ? '' : ' is-off'}" aria-pressed="${on}"
3723                        onclick="toggleBestFilterStore('${escapeHtml(name)}', ${on ? 'false' : 'true'})">${escapeHtml(name)}</button>`;
3724                }).join('')}`;
3725
3726            const inPanel = document.getElementById('matomeBestFilterInner');
3727            const standalone = document.getElementById('matomeBestFilter');
3728            if (inPanel) {
3729                inPanel.innerHTML = chips;
3730                const cnt = document.getElementById('matomeStoresCount');
3731                // 分母・分子とも「今の条件で実際に選べる店」で数える。
3732                // 店頭モードで宅配専門店を分母に残すと、全部選んでも 21/23 のままになる
3733                if (cnt) cnt.textContent = `${pickable.filter(n => bestFilterStores.has(n)).length}/${pickable.length}店`;
3734                const tg = document.getElementById('matomeStoresToggle');
3735                if (tg) tg.onclick = () => {
3736                    splitSettings.storesCollapsed = !splitSettings.storesCollapsed;
3737                    saveSplitSettings();
3738                    inPanel.hidden = splitSettings.storesCollapsed;
3739                    tg.setAttribute('aria-expanded', String(!splitSettings.storesCollapsed));
3740                };
3741                if (standalone) standalone.style.display = 'none';
3742                return;
3743            }
3744            if (!standalone) return;
3745            standalone.style.display = 'block';
3746            standalone.innerHTML = `<div class="matome-best-filter-inner">${chips}</div>`;
3747        }
3748
3749        window.toggleAllBestFilter = function(checked) {
3750            if (checked) {
3751                const all = new Set();
3752                comparisonProducts.forEach(p => { (p.prices || []).forEach(pr => { if (pr.store) all.add(pr.store); }); });
3753                bestFilterStores = all;
3754            } else bestFilterStores = new Set();
3755            saveBestFilter(); renderBestFilter(); recalcAndRenderCards();
3756        };
3757        window.toggleBestFilterStore = function(name, checked) {
3758            if (checked) bestFilterStores.add(name); else bestFilterStores.delete(name);
3759            saveBestFilter(); renderBestFilter(); recalcAndRenderCards();
3760        };
3761        function recalcAndRenderCards() {
3762            const activeItems = comparisonItems.filter(i => i.quantity > 0);
3763            if (activeItems.length === 0) return;
3764            const storeResults = calculateStoreResults(activeItems, comparisonProducts);
3765            // 比較対象店や数量を変えたら分割案も出し直す。
3766            // ここを忘れると、外した店が分割案に残り続けて下のカードと矛盾する
3767            renderSplitPlan();
3768            renderComparisonStoreCards(storeResults);
3769        }
3770
3771        let comparisonItems = []; let comparisonProducts = []; let comparisonModified = false;
3772
3773        // 選ばれている代替案(0 = 一番お得な組み合わせ)。店舗数や設定を変えたら先頭に戻す
3774        let splitPickedIdx = 0;
3775        // 代替案リストを開いているか。開閉は details の toggle で拾い、案を選んだら閉じる。
3776        // 「選んだ案が先頭以外なら開いたまま」にすると 2 件目以降だけ畳まれず壊れて見える
3777        let splitAltsOpen = false;
3778
3779        function renderSplitPlan() {
3780            const host = document.getElementById('matomeSplitPlan');
3781            if (!host) return;
3782            const rules = (window.KX_SHIPPING_RULES || {}).stores;
3783            if (!rules) { host.style.display = 'none'; return; }
3784
3785            const items = comparisonItems.filter(i => i.quantity > 0).map(i => {
3786                const p = comparisonProducts.find(x => x && x.jan === i.jan) || {};
3787                return { jan: i.jan, name: i.name, quantity: i.quantity, category: p.category || 'その他',
3788                         image: p.image_url || i.image_url || '', prices: p.prices || [] };
3789            });
3790            if (!items.length) { host.style.display = 'none'; return; }
3791
3792            const walkinMap = splitWalkinMap();
3793            const walkin = splitWalkinOn();
3794            // 店頭に持ち込むモードでは、持ち込める店(=店頭買取の拠点がある店)だけが候補。
3795            // 宅配専門の店を「送料0」で並べると、実際には受け取れない得を見せることになる。
3796            const candidates = Object.keys(MATOME_STORE_NAMES)
3797                .filter(k => bestFilterStores.has(MATOME_STORE_NAMES[k]))
3798                .filter(k => !walkin || walkinMap[k]);
3799            // 価格は先に表に落とす(既存カードと同じ基準:比較対象店のみ・stale 除外)。
3800            // 毎回 find() すると 60点×5,000通りで効いてくる。
3801            // 店頭モードでは来店加算を**単価に上乗せした値**を入れる。以降の最適化・
3802            // 「他店最高」・明細は全部この表を見るので、1 か所直せば全部そろう。
3803            const mat = new Map();
3804            const bonusMat = new Map();   // jan -> {key: 実際に上乗せした来店加算}
3805            const vagueMat = new Map();   // jan -> {key: true}=来店増額はあるが金額が非公開
3806            items.forEach(it => {
3807                const row = {}, brow = {}, vrow = {};
3808                (it.prices || []).forEach(p => {
3809                    if (p.stale || !bestFilterStores.has(p.store)) return;
3810                    const base = deobfuscatePrice(p.price) || 0;
3811                    let bonus = 0;
3812                    if (walkin && base > 0 && walkinMap[p.key]) {
3813                        bonus = Math.max(0, parseInt(p.walkin_bonus, 10) || 0);
3814                        // 金額が書かれていない来店増額(森森「在庫状況により変動」など)は
3815                        // 数字を作らずに注記だけ出す。振込手数料が非公開の店と同じ扱い。
3816                        if (!bonus && KX_WALKIN_VAGUE_RE.test(p.mark_note || '')) vrow[p.key] = true;
3817                    }
3818                    row[p.key] = base + bonus;
3819                    if (bonus) brow[p.key] = bonus;
3820                });
3821                mat.set(it.jan, row);
3822                bonusMat.set(it.jan, brow);
3823                vagueMat.set(it.jan, vrow);
3824            });
3825            const priceOf = (it, key) => mat.get(it.jan)[key] || 0;
3826            const bonusOf = (jan, key) => (bonusMat.get(jan) || {})[key] || 0;
3827            const vagueOf = (jan, key) => !!(vagueMat.get(jan) || {})[key];
3828            const usable = candidates.filter(k => items.some(it => priceOf(it, k) > 0));
3829            // 案が作れない時の**理由**。店頭フィルタのせいなのか、利用者が比較対象店舗を
3830            // 外し切ったのかで案内が変わる。mat は walkin で絞る前の選択店を全部持っている
3831            // ので、「選択店には価格が在るのに usable が空」=店頭フィルタが原因と分かる。
3832            // これを見ないと「『店頭に持ち込む』を外すと比べられます」と**外しても直らない
3833            // 操作**を案内することになる(実測で誤案内していた)。
3834            const emptyCause = (walkin && Object.keys(MATOME_STORE_NAMES)
3835                .filter(k => bestFilterStores.has(MATOME_STORE_NAMES[k]))
3836                .some(k => items.some(it => (mat.get(it.jan)[k] || 0) > 0)))
3837                ? 'walkin' : 'filter';
3838            // 比較対象店の価格を降順に並べた表。分割案で選ばれなかった店との差を出すため。
3839            // 🔴 **1位だけでなく並び全体を持つ**——表示側で「この箱の店」を除いてから
3840            //    上位2件を採るので、自店が最高値の時でも「自店を除いた他店の1位/2位」が
3841            //    正しく引ける。1位だけを持っていた頃は自店が最高だと bp=自店単価になり、
3842            //    `> unit` が偽になって hint ごと消えていた(結果は同じだが、2位を出す
3843            //    には並びが要る)。同じ店が2回入ることはない(key ごとに1価格)。
3844            const bestOf = new Map();
3845            items.forEach(it => {
3846                const row = mat.get(it.jan);
3847                bestOf.set(it.jan, candidates
3848                    .map(k => ({ store: k, price: row[k] || 0 }))
3849                    .filter(x => x.price > 0)
3850                    .sort((a, b) => b.price - a.price));
3851            });
3852            if (!usable.length) { splitRenderEmpty(host, walkin, emptyCause); return; }
3853
3854            // 3店までの全列挙を 1 回だけ回し、1店/2店/3店 の最良も上位20件もここから切り出す。
3855            // ボタンごとに列挙し直すと 4 倍の時間がかかる(60点規模で 1 秒超)
3856            const all = splitRank(items, usable, 3, priceOf);
3857            const perMax = {};
3858            for (const n of [1, 2, 3]) perMax[n] = all.find(r => r.boxCount <= n) || null;
3859            // 店舗数を増やしても手取りが増えないなら、そのボタンは押せなくする。
3860            // 押せてしまうと「3店にしたのに何も変わらない=壊れている」に見える
3861            const noGain = {};
3862            for (const n of [2, 3]) {
3863                noGain[n] = !perMax[n] || !perMax[n - 1] || perMax[n].net <= perMax[n - 1].net + 0.5;
3864            }
3865            // 上限は保存された希望値のまま。実際に使うのは「増える範囲での最大」
3866            let effMax = splitSettings.maxStores;
3867            while (effMax > 1 && noGain[effMax]) effMax--;
3868            const ranked = all.filter(r => r.boxCount <= effMax).slice(0, 20);
3869            if (!ranked.length) { splitRenderEmpty(host, walkin, emptyCause); return; }
3870            if (splitPickedIdx >= ranked.length) splitPickedIdx = 0;
3871            const cur = ranked[splitPickedIdx];
3872            const best = ranked[0];
3873
3874            const surveyed = (window.KX_SHIPPING_RULES || {}).surveyed_at || '';
3875            // 表示する日付は「この方案に出てくる店を最後に確認した日」のうち一番古いもの。
3876            // 全店一律の日付を出すと、その月に取得できなかった店まで確認済みに見えてしまう
3877            // 店頭モードで出すべきは送料条件の確認日ではなく**拠点を確認した日**
3878            const planChecked = cur.stores
3879                .map(k => (walkin
3880                    ? (splitWalkinMap()[k] || {}).at
3881                    : ((rules[k] || {}).free_ship || {}).checked_at) || surveyed)
3882                .filter(Boolean).sort();
3883            // 日本語ページに ISO の 2026-08-13 が出ていたので和文表記に直す
3884            const jpDate = d => {
3885                const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d || '');
3886                return m ? `${+m[1]}年${+m[2]}月${+m[3]}日` : (d || '');
3887            };
3888            const shownDate = jpDate(planChecked.length ? planChecked[0] : surveyed);
3889            // マイナスは「¥-820」でなく「−¥820」。他の箇所(送料 −¥1,100)と揃える
3890            const yen = n => (n < 0 ? '−¥' : '¥') + Math.abs(Math.round(n)).toLocaleString();
3891            const curBoxes = splitDetail(cur);
3892            const nBox = curBoxes.length;
3893
3894
3895            // 箱の中身は下の店舗カードと同じ「1行1商品」の表で出す。
3896            // 商品名をカンマで並べると 60 点規模で読めなくなるため
3897            const boxes = curBoxes.slice().sort((a, b) => b.amount - a.amount).map((b, i) => {
3898                const nm = MATOME_STORE_NAMES[b.store] || b.store;
3899                const conf = ((rules[b.store] || {}).free_ship || {}).confidence;
3900                const qty = b.items.reduce((s, it) => s + it.quantity, 0);
3901                const rows = b.list.slice().sort((x, y) => y.unit * y.it.quantity - x.unit * x.it.quantity).map(({ it, unit }) => {
3902                    const img = it.image
3903                        ? `<img src="${escapeHtml(it.image)}" alt="" loading="lazy" width="48" height="48">`
3904                        : '<span class="matome-img-ph" aria-hidden="true"></span>';
3905                    // 「他店」=この箱に入れる店を除いた比較対象店。自店を混ぜると
3906                    // 「他店最高」が自分自身を指すことがあるので必ず落としてから採る。
3907                    const others = (bestOf.get(it.jan) || []).filter(x => x.store !== b.store);
3908                    const bo = others[0] || { price: 0, store: '' };
3909                    const bo2 = others[1] || null;
3910                    const storeNm = k => escapeHtml(MATOME_STORE_NAMES[k] || k);
3911                    // この箱の店より高く買う店があるなら差額を出す(分割しない選択の判断材料)
3912                    // SP では「最高」と括弧を CSS で落として 2 行に割る。
3913                    // 置き場所も PC は商品名の下、SP は「@¥◯ × n」の下(商品名側が 4 行になって背が伸びるため)
3914                    // 1位も 2位 と同じく「割れない1単位」にする。包まないと PC で折り返した時に
3915                    // 店名の途中(買取ソムリ/エ)で切れる。折るのは 1位 と 2位 の間だけ。
3916                    const hintInner = (bo.price > unit)
3917                        ? `<span class="mibh-1st">他店<span class="mibh-h">最高</span> ¥${bo.price.toLocaleString()}`
3918                          + `<span class="mibh-store">${storeNm(bo.store)}</span></span>` : '';
3919                    // 🔴 2位は **PC だけ**(SP は幅が無く、既に 2 行に割っている)。
3920                    //    条件は1位と揃えて「自店より高い」時のみ=この hint の役目が
3921                    //    「分割すればもっと高く売れる先」の提示なので、自店より安い店を
3922                    //    並べても判断材料にならない。
3923                    // 🔴 先頭の `<wbr>` は必須。1位 も 2位 も nowrap なので、**両者の間に
3924                    //    改行機会が無いと white-space:normal でも折れず**、親 td の
3925                    //    overflow:hidden に 2位 が丸ごと食われる(実測 innerWidth 1331px で
3926                    //    高さ 17px の1行のまま右が切れていた。<wbr> を入れると 35px の2行に
3927                    //    なって収まる)。テンプレートが `${hintInner}${hint2}` と隙間無しで
3928                    //    連結するので、空白文字は入らない。
3929                    const hint2 = (hintInner && bo2 && bo2.price > unit)
3930                        ? `<wbr><span class="mibh-2nd">2位 ¥${bo2.price.toLocaleString()}`
3931                          + `<span class="mibh-store">${storeNm(bo2.store)}</span></span>` : '';
3932                    // 2位が出ない行(他店が1つしか自店を上回らない)で「上位2店」と言わない
3933                    const hintTitle = hint2
3934                        ? 'この箱の店を除いた比較対象店のうち、自店より高く買う上位2店'
3935                        : 'この箱の店を除いた比較対象店のうち、自店より高く買う店';
3936                    const hint = hintInner
3937                        ? `<span class="matome-item-best-hint hint-pc" title="${hintTitle}">${hintInner}${hint2}</span>` : '';
3938                    const hintSp = hintInner ? `<span class="matome-item-best-hint hint-sp">${hintInner}</span>` : '';
3939                    const diff = (bo.price > unit) ? (bo.price - unit) * it.quantity : 0;
3940                    // 来店加算は「どこから増えたか」が分からないと数字が信じられないので、
3941                    // 単価の隣に必ず出す(unit には既に加算後の額が入っている)
3942                    const bn = bonusOf(it.jan, b.store);
3943                    const bnTag = bn
3944                        ? `<span class="msi-walkin" title="店頭に持ち込んだ時だけ付く加算(各店の備考欄より)">
3944来店 +¥${bn.toLocaleString()}</span>`
3945                        : (vagueOf(it.jan, b.store)
3946                            ? '<span class="msi-walkin is-vague" title="来店増額はありますが、金額が公表されていないため計算に含めていません">来店増額あり(未計上)</span>'
3947                            : '');
3948                    return `
3949                    <tr class="matome-store-item">
3950                        <td class="matome-store-item-img"><a href="/dp/${escapeHtml(it.jan)}" tabindex="-1" aria-hidden="true">${img}</a></td>
3951                        <td class="matome-store-item-name"><a href="/dp/${escapeHtml(it.jan)}" style="text-decoration:none;color:inherit;">${escapeHtml(it.name)}</a>${hint}<button type="button" class="msi-jan matome-jan-copy-btn" onclick="copyToClipboard('${escapeHtml(it.jan)}','JANをコピーしました')" title="JANをコピー">${escapeHtml(it.jan)}</button></td>
3952                        <td class="matome-store-item-copy-jan"><button type="button" class="matome-jan-copy-btn" onclick="copyToClipboard('${escapeHtml(it.jan)}','JANをコピーしました')" title="JANをコピー">${escapeHtml(it.jan)}</button></td>
3953                        <td class="matome-store-item-qty"><span class="msi-unit">@¥${unit.toLocaleString()} × ${it.quantity}</span>${bnTag}${hintSp}</td>
3954                        <td class="matome-store-item-price">Â¥${(unit * it.quantity).toLocaleString()}${diff > 0 ? `<span class="matome-item-diff">+Â¥${diff.toLocaleString()}</span>` : ''}</td>
3955                    </tr>`;
3956                }).join('');
3957                // 「送料を計算に入れる」を外している時に出すべきなのは金額ではない。
3958                // −¥0 でも −¥3,000 でもなく「計算に含めていない」と書く。
3959                // 条件そのもの(あと◯点で送料無料)は判断材料になるので残し、
3960                // 自己負担うんぬんの言い訳だけ落とす(keepLabel)
3961                const shipChip = walkin
3962                    ? '<span class="split-ship is-walkin">店頭持ち込み</span>'
3963                    : b.ship.free
3964                    ? '<span class="split-ship is-free">送料無料</span>'
3965                    : b.ship.off
3966                        ? '<span class="split-ship is-skip">送料 計算に含めず</span>'
3967                        : `<span class="split-ship is-paid">送料 −${yen(b.ship.cost)}</span>`;
3968                const shipReason = walkin
3969                    ? '<span class="split-reason">送料・振込手数料は掛かりません</span>'
3970                    : (b.ship.off && !b.ship.keepLabel) ? ''
3971                    : `<span class="split-reason">${escapeHtml(b.ship.label)}</span>`;
3972                // どこへ持ち込む店なのか。同じ手取りでも「秋葉原3軒」と「東京+大阪+福岡」では
3973                // 現実味が全く違うので、案を選ぶ前に見えている必要がある
3974                const wk = walkin ? (splitWalkinMap()[b.store] || null) : null;
3975                const walkinLoc = wk
3976                    ? `<span class="split-reason" title="${escapeHtml((wk.names || []).join('/'))}${
3977                        wk.n > (wk.names || []).length ? ' ほか' : ''}">${wk.n}店舗${
3978                        splitWalkinAreas(b.store) ? `(${escapeHtml(splitWalkinAreas(b.store))})` : ''}</span>`
3979                    : '';
3980                // この箱で乗った来店加算の合計。単価の隣にも出しているが、
3981                // 「この店に持ち込むと総額がいくら増えるのか」は箱の単位でしか読めない
3982                const boxBonus = walkin
3983                    ? b.list.reduce((t, { it }) => t + bonusOf(it.jan, b.store) * it.quantity, 0) : 0;
3984                const walkinBonusChip = boxBonus
3985                    ? `<span class="split-walkin-bonus">来店加算 +${yen(boxBonus)}</span>` : '';
3986                // 運送会社次第で掛かる事務手数料。計上しない代わりに条件ごと書く
3987                const shipExtra = b.ship.extra
3988                    ? `<span class="split-reason">${b.ship.extra.carriers
3989                        ? escapeHtml(b.ship.extra.carriers) + '以外の着払いは' : ''}事務手数料 ¥${
3990                        b.ship.extra.amount.toLocaleString()}(未計上)</span>` : '';
3991                return `
3992                <!-- 箱は「1位・2位」ではなく同じ 1 案の 1箱目・2箱目。順位色(.first の緑)は付けない -->
3993                <div class="matome-store-card split-plan-card">
3994                    <div class="matome-store-card-header">
3995                        <div class="matome-store-card-rank">
3996                            ${nBox > 1 ? `<span class="split-box-no" aria-hidden="true">${i + 1}</span>` : ''}
3997                            <a href="/store/${escapeHtml(b.store)}?from=matome" class="matome-store-name-link">${escapeHtml(nm)}</a>
3998                            <span class="split-box-qty">${nBox > 1 ? `${i + 1}箱目・` : ''}${qty}点</span>
3999                        </div>
4000                        <div class="matome-store-total">
4001                            <div class="matome-store-total-label">${nBox > 1 ? 'この箱の買取価格' : '合計買取価格'}</div>
4002                            <div class="matome-store-total-value">${yen(b.amount)}</div>
4003                        </div>
4004                    </div>
4005                    <div class="split-box-meta">
4006                        ${shipChip}
4007                        ${walkinBonusChip}
4008                        ${walkinLoc}
4009                        ${shipReason}
4010                        ${walkin ? '' : shipExtra}
4011                        ${(!walkin && b.fee) ? `<span class="split-fee">振込手数料 −${yen(b.fee)}</span>` : ''}
4012                        ${(!walkin && (((window.KX_SHIPPING_RULES || {}).stores || {})[b.store] || {}).transfer_fee?.unknown)
4013                            ? '<span class="split-reason">振込手数料は非公開のため未計上</span>' : ''}
4014                        ${walkin
4015                            ? `<button type="button" class="split-warn kx-loc-open" data-store="${escapeHtml(b.store)}"
4016                                 aria-haspopup="dialog">${wk && wk.h ? '店舗・営業時間を見る' : '店舗を見る'}</button>`
4017                            : `<a href="/store/${escapeHtml(b.store)}?from=matome" class="split-warn${(conf && conf !== 'high') ? ' is-vague' : ''}">${
4018                                conf && conf !== 'high'
4019                                    ? '送料条件があいまいなため要確認'
4020                                    : '発送前に送料条件を確認'}</a>`}
4021                    </div>
4022                    <div class="matome-store-items-wrap"><table class="matome-store-items-table">${rows}</table></div>
4023                </div>`;
4024            }).join('');
4025
4026            const alts = ranked.map((r, i) => {
4027                const names = splitStoreOrder(r).map(k => escapeHtml(MATOME_STORE_NAMES[k] || k)).join('+');
4028                const diff = i === 0 ? '<span class="split-alt-diff split-alt-top">一番お得</span>'
4029                    : `<span class="split-alt-diff">${yen(r.net - best.net)}</span>`;
4030                return `<li><button type="button" class="split-alt${i === splitPickedIdx ? ' active' : ''}" data-idx="${i}">
4031                    <span class="split-alt-rank">${i + 1}</span>
4032                    <span class="split-alt-stores">${names}</span>
4033                    <span class="split-alt-net">${yen(r.net)}</span>${diff}
4034                </button></li>`;
4035            }).join('');
4036
4037            // 選んだ案で乗った来店加算の合計。「郵送ならいくらだったか」が分からないと
4038            // 店頭へ行く価値が判断できないので、加算ぶんは必ず分けて出す
4039            const curBonus = walkin
4040                ? curBoxes.reduce((t, b) => t + b.list.reduce(
4041                    (u, { it }) => u + bonusOf(it.jan, b.store) * it.quantity, 0), 0) : 0;
4042            const breakdown = walkin
4043                ? `買取 <strong>${yen(cur.gross)}</strong>`
4044                    + (curBonus ? `(うち来店加算 +${yen(curBonus)})` : '')
4045                    + '<span class="split-ms"> / 店頭持ち込みのため送料・振込手数料なし</span>'
4046                : `買取 ${yen(cur.gross)}`
4047                    + (cur.ship ? ` − 送料 ${yen(cur.ship)}` : '')
4048                    + (cur.fee ? ` − 振込手数料 ${yen(cur.fee)}` : '')
4049                    + ` = <strong>${yen(cur.net)}</strong>`;
4050
4051            host.style.display = '';
4052            host.innerHTML = `
4053                <div class="split-head">
4054                    <h3>いくつの店に分けるのが一番お得か</h3>
4055                    ${splitControlsHtml()}
4056                </div>
4057                <div class="split-seg" role="group" aria-label="分ける店舗数">
4058                    ${[1, 2, 3].map(n => {
4059                        const r = perMax[n];
4060                        const off = !r || noGain[n];
4061                        const why = noGain[n] ? ` title="${n}店に分けても手取りは増えません"` : '';
4062                        // 金額は必ず「1店にまとめた場合」を基準にする。基準を混ぜると
4063                        // 「3店の +◯ は 1店比なのか 2店比なのか」が読めなくなるので、文言にも基準を書く。
4064                        // 押せない店舗数は空白にせず、どこから増えないのかを書く
4065                        const base = perMax[1] ? perMax[1].net : 0;
4066                        const up = (r && n > 1) ? r.net - base : 0;
4067                        const flat = n > 1 && r && noGain[n];
4068                        const upTxt = (!r || n === 1) ? ''
4069                            : flat ? (up > 0.5 ? `${n - 1}店から増えません` : '1店と同額')
4070                                   : `1店より +${yen(up)}`;
4071                        return `<button type="button" class="split-seg-btn${effMax === n ? ' active' : ''}" data-max="${n}"${off ? ' disabled' : ''}${why}>
4072                            <span class="split-seg-label">${n}店まで</span>
4073                            <span class="split-seg-sum">${r ? yen(r.gross) : '—'}</span>
4074                            <span class="split-seg-up${flat ? ' is-flat' : ''}">${upTxt}</span>
4075                        </button>`;
4076                    }).join('')}
4077                </div>
4078                <p class="split-seg-note">金額は買取総額${walkin ? '(来店加算を含む)' : '(送料・振込手数料を差し引く前)'}です。${walkin ? '複数店に分ける場合は、すべての店舗に持ち込む前提です。' : ''}${noGain[2] || noGain[3] ? '手取りが増えない店舗数は選べません。' : ''}</p>
4079                <details class="split-alts"${splitAltsOpen ? ' open' : ''}>
4080                    <summary><svg class="split-alts-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>ほかの${splitSettings.maxStores > 1 ? '組み合わせ' : '店'}を見る(上位${ranked.length}件)</summary>
4081                    <ol class="split-alt-list">${alts}</ol>
4082                </details>
4083                <div class="split-boxes">${boxes}</div>
4084                <p class="split-total">${breakdown}</p>
4085                <p class="split-note">
4086                    ${walkin
4087                        ? `店頭買取の店舗は${shownDate ? escapeHtml(shownDate) + 'に' : ''}<a href="/store">各店</a>の公式サイトで確認した内容です。
4088                           来店加算は各店の買取価格表の備考欄(「来店+◯円」等)にもとづく目安で、店舗・在庫状況によって変わることがあります。
4089                           店舗によって取り扱う商品や受付時間、本人確認の条件が異なります。営業時間とあわせて、来店前に各店の公式サイトでご確認ください。`
4090                        : `送料条件は${shownDate ? escapeHtml(shownDate) + 'に' : ''}<a href="/store">各店</a>の公式サイトで確認した内容にもとづく目安です(毎月見直しています)。
4091                           サイズ上限・複数個口の詰め方・沖縄/離島からの発送は計算に含めていません。
4092                           発送前に各店の公式サイトで最新の条件をご確認ください。`}<br>
4093                    条件に誤りを見つけた場合は<a href="/contact" target="_blank" rel="noopener">お問い合わせ</a>からご連絡いただけると助かります。
4094                </p>`;
4095
4096            host.querySelectorAll('.split-seg-btn').forEach(b => b.addEventListener('click', () => {
4097                splitSettings.maxStores = parseInt(b.dataset.max, 10);
4098                splitPickedIdx = 0;
4099                saveSplitSettings(); renderSplitPlan();
4100            }));
4101            const altsBox = host.querySelector('.split-alts');
4102            if (altsBox) altsBox.addEventListener('toggle', () => { splitAltsOpen = altsBox.open; });
4103            host.querySelectorAll('.split-alt').forEach(b => b.addEventListener('click', () => {
4104                splitPickedIdx = parseInt(b.dataset.idx, 10);
4105                // 選んだら一覧は畳んで明細を見せる(何件目を選んでも同じ挙動)
4106                splitAltsOpen = false;
4107                renderSplitPlan();
4108                // 下のほうの案を選ぶと、畳んだ拍子に見出しが画面の上へ外れて
4109                // 「どこに飛んだか分からない」状態になる。外れたときだけ見える位置へ戻す
4110                const sm = host.querySelector('.split-alts > summary');
4111                if (!sm) return;
4112                const bar = document.querySelector('.sticky-toolbar');
4113                const offset = (bar ? bar.offsetHeight : 0) + 12;
4114                if (sm.getBoundingClientRect().top < offset) {
4115                    window.scrollTo({ top: sm.getBoundingClientRect().top + window.pageYOffset - offset, behavior: 'smooth' });
4116                }
4117            }));
4118            bindSplitControls(host);
4119        }
4120
4121        function renderComparisonSummary() {
4122            const activeItems = comparisonItems.filter(item => item.quantity > 0);
4123            const totalQty = activeItems.reduce((sum, item) => sum + item.quantity, 0);
4124            document.getElementById('matomeResultSummary').textContent = `${activeItems.length}種類 合計${totalQty}個を比較中`;
4125            // 見出し・操作バーは静的 HTML 側。ここで作り直すと共有ボタンが消えるので触らない
4126            document.getElementById('matomeItemsCount').textContent = `${activeItems.length}種類`;
4127            document.getElementById('matomeRecompareBtn').disabled = !comparisonModified;
4128
4129            const productsBox = document.getElementById('matomeProductsSummary');
4130            productsBox.hidden = !!splitSettings.itemsCollapsed;
4131            productsBox.innerHTML = `
4132                <div class="matome-summary-items">
4133                    ${comparisonItems.map(item => {
4134                        const _img = item.image_url || (comparisonProducts.find(p => p && p.jan === item.jan) || {}).image_url || '';
4135                        const _imgCell = _img
4136                            ? `<img src="${escapeHtml(_img)}" alt="" loading="lazy" width="44" height="44">`
4137                            : '<span class="matome-img-ph" aria-hidden="true"></span>';
4138                        return `
4139                        <div class="matome-summary-item ${item.quantity === 0 ? 'zero-qty' : ''}" data-jan="${escapeHtml(item.jan)}">
4140                            <a href="/dp/${escapeHtml(item.jan)}" class="matome-summary-item-img" tabindex="-1" aria-hidden="true">${_imgCell}</a>
4141                            <div class="matome-summary-item-main">
4142                                <a href="/dp/${escapeHtml(item.jan)}" class="matome-summary-item-name" style="text-decoration:none;color:inherit;">${escapeHtml(item.name)}</a>
4143                                <!-- SP ではこの行の JAN を出す(右側のボタンは幅が無く隠している) -->
4144                                <span class="kx-jan-copy sp-jan" data-jan="${escapeHtml(item.jan)}" role="button" tabindex="0" data-copied-text="コピーしました" title="タップでJANをコピー">JAN ${escapeHtml(item.jan)}</span>
4145                            </div>
4146                            <button type="button" class="matome-summary-item-copy-jan matome-jan-copy-btn" onclick="copyToClipboard('${escapeHtml(item.jan)}','JANをコピーしました')" title="JANをコピー">${escapeHtml(item.jan)}</button>
4147                            <div class="matome-summary-item-qty">
4148                                <button class="comparison-qty-minus">-</button>
4149                                <input type="number" class="comparison-qty-input" value="${item.quantity}" min="0" max="999">
4150                                <button class="comparison-qty-plus">+</button>
4151                            </div>
4152                            <button class="matome-summary-item-delete">✕</button>
4153                        </div>
4154                    `;
4155                    }).join('')}
4156                </div>
4157            `;
4158
4159            document.querySelectorAll('.matome-summary-item').forEach(el => {
4160                const jan = el.dataset.jan;
4161                const input = el.querySelector('.comparison-qty-input');
4162                el.querySelector('.comparison-qty-minus').addEventListener('click', () => { input.value = Math.max(0, parseInt(input.value) - 1); updateComparisonItemQty(jan, parseInt(input.value)); });
4163                el.querySelector('.comparison-qty-plus').addEventListener('click', () => { input.value = Math.min(999, parseInt(input.value) + 1); updateComparisonItemQty(jan, parseInt(input.value)); });
4164                input.addEventListener('change', () => { input.value = Math.max(0, Math.min(999, parseInt(input.value) || 0)); updateComparisonItemQty(jan, parseInt(input.value)); });
4165                el.querySelector('.matome-summary-item-delete').addEventListener('click', () => {
4166                    comparisonItems = comparisonItems.filter(i => i.jan !== jan); comparisonModified = true; renderComparisonSummary();
4167                });
4168            });
4169            // 対象商品の開閉。60点規模だと一覧が画面を占領するので畳めるようにする。
4170            // 開閉状態は localStorage とログイン中のアカウント設定の両方に残す
4171            bindMatomeAddPanel();
4172            // バーのボタンは静的要素。再描画のたび addEventListener するとハンドラが積み上がるので onclick で置き換える
4173            const itemsToggle = document.getElementById('matomeItemsToggle');
4174            itemsToggle.setAttribute('aria-expanded', String(!splitSettings.itemsCollapsed));
4175            itemsToggle.onclick = () => {
4176                const willOpen = !!splitSettings.itemsCollapsed;
4177                if (willOpen) toggleMatomeAddPanel(false);   // 追加パネルとは排他
4178                setMatomeItemsOpen(willOpen, true);
4179            };
4180            document.getElementById('matomeAddMoreBtn').onclick = (() => {
4181                toggleMatomeAddPanel();
4182            });
4183            document.getElementById('matomeRecompareBtn').onclick = (() => { recompareMatome(); });
4184        }
4185
4186        // 追加した商品はまだ価格を持っていないので、再比較の前に足りない分だけ取りに行く
4187        async function recompareMatome() {
4188            const activeItems = comparisonItems.filter(item => item.quantity > 0);
4189            if (activeItems.length === 0) return alert('比較する商品がありません');
4190            saveMatomeToStorage({ items: activeItems, updatedAt: Date.now() });
4191            document.getElementById('matomeStoreResults').innerHTML = '';
4192            showLoading(true);
4193            try {
4194                const missing = activeItems.filter(it => !comparisonProducts.some(p => p && p.jan === it.jan));
4195                if (missing.length) {
4196                    const got = await fetchProductsBatch(missing.map(it => it.jan));
4197                    comparisonProducts = comparisonProducts.concat(got.filter(Boolean));
4198                }
4199                const storeResults = calculateStoreResults(activeItems, comparisonProducts);
4200                comparisonModified = false;
4201                renderComparisonSummary();
4202                renderComparisonStoreCards(storeResults);
4203                renderBestFilter();
4204                renderSplitPlan();
4205            } catch (err) {
4206                showError(err.message || '再比較に失敗しました');
4207            } finally { showLoading(false); }
4208        }
4209
4210        // ==================== まとめ比較の「追加」パネル ====================
4211        // 検索ページへ飛ばす旧実装は、比較結果が消える・利益チャンスの帯が出る・
4212        // 候補を押すと商品ページへ飛ぶ(=追加にならない)の 3 点で使えなかった。
4213        // 計算に入れる/入れないは色だけで示さず、**記号の形**で示す(色覚に依存させない)。
4214        // 🔴 以前は ON/OFF どちらでも同じ✓を描いており、実際の差は色だけだった=注記の意図が
4215        //    果たされていなかった(2026-08-17 May 指示で修正)。ON=枠にチェック、OFF=空の枠。
4216        //    チェックボックスの見た目そのままなので、色が見えなくても状態が読める。
4217        const splitCheckIco = (on) => '<svg class="split-ctl-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
4218            + 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
4219            + '<rect x="3" y="3" width="18" height="18" rx="4"></rect>'
4220            + (on ? '<polyline points="16.8 8.8 10.5 15.2 7.2 12"></polyline>' : '')
4221            + '</svg>';
4222
4223        // 店舗フィルタ・3 つの計算トグル・送料額は同じ「条件を決める」操作なので、
4224        // 見た目も 1 つの部品系(.split-ctl-btn)に揃えて 1 行にまとめる。
4225        // 分割案が 1 つも作れなかった時(店頭に持ち込める店に価格が無い等)にも
4226        // この行だけは出す=**設定を戻す手段が消えない**ようにするため関数に切り出してある。
4227        function splitControlsHtml() {
4228            // 拠点データを 1 件も読めていない時はモードが成立しない(全店が
4229            // 「持ち込めない」になる)。押しても空の結果しか出せないボタンは出さない
4230            const hasWalkinData = Object.keys(splitWalkinMap()).length > 0;
4231            const walkin = splitWalkinOn();
4232            // 店頭では送料も振込手数料も掛からない。トグルの保存値は変えずに、
4233            // 表示上は「計算に含めていない」実態どおり OFF+押せない状態にする
4234            const feeOn = splitEffFeeOn();
4235            const shipOn = !walkin && splitSettings.shipping;
4236            const lockTip = '店頭に持ち込む場合は掛かりません';
4237            return `
4238                    <div class="split-controls">
4239                        <button type="button" class="split-ctl-btn" id="matomeStoresToggle"
4240                            aria-expanded="${!splitSettings.storesCollapsed}" aria-controls="matomeBestFilterInner">
4241                            <svg class="split-ctl-ico split-ctl-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
4242                            <span>比較対象店舗</span><span class="split-ctl-count" id="matomeStoresCount"></span>
4243                        </button>
4244                        <!-- チップは押したボタンの真下に出す。トグルを挟むと何を開いたのか分からない -->
4245                        <div class="matome-best-filter-inner" id="matomeBestFilterInner"${splitSettings.storesCollapsed ? ' hidden' : ''}></div>
4246                        ${!hasWalkinData ? '' : `
4247                        <button type="button" class="split-ctl-btn${walkin ? ' is-on' : ''}" id="splitWalkinChk"
4248                            aria-pressed="${walkin}" title="ご自身で店舗に持ち込む前提で計算します。送料・振込手数料は掛からず、来店加算のある商品は加算後の価格で比べます。">
4249                            ${splitCheckIco(walkin)}<span>店頭に持ち込む</span>
4250                        </button>`}
4251                        <button type="button" class="split-ctl-btn${feeOn ? ' is-on' : ''}" id="splitFeeChk"
4252                            aria-pressed="${feeOn}"${walkin ? ` disabled title="${lockTip}"` : ''}>
4253                            ${splitCheckIco(feeOn)}<span>振込手数料<span class="sc-tail">を計算に入れる</span></span>
4254                        </button>
4255                        <button type="button" class="split-ctl-btn${shipOn ? ' is-on' : ''}" id="splitShipChk"
4256                            aria-pressed="${shipOn}"${walkin ? ` disabled title="${lockTip}"` : ''}>
4257                            ${splitCheckIco(shipOn)}<span>送料<span class="sc-tail">を計算に入れる</span></span>
4258                        </button>
4259                        <label class="split-ctl-btn split-cost${shipOn ? '' : ' is-off'}" title="${walkin ? lockTip
4260                            : (splitSettings.shipping
4261                                ? '着払いの条件を満たさない箱で、自己負担になる送料です'
4262                                : '「送料」をオンにすると入力できます')}">
4263                            <span class="split-cost-label">送料の自己負担額<span class="sc-tail">(1箱あたり)</span></span>
4264                            <span class="split-cost-field"><span class="split-cost-yen">Â¥</span><input type="number" id="splitShipCost" inputmode="numeric" value="${splitSettings.shippingCost}" min="0" max="9999" step="100" ${shipOn ? '' : 'disabled'}></span>
4265                        </label>
4266                    </div>`;
4267        }
4268
4269        // ===== 店舗・営業時間の popup =====
4270        // 比較の途中で /shops へ飛ばすと、せっかく出した案が画面から消える。
4271        // 住所・電話・営業時間を全ページのインライン script に載せるには重いので
4272        // (37 店ぶん。拠点名とエリアだけの今でも 3.8KB)、押した店だけ
4273        // /api/shop-locations から取りに行って畳んで持つ。
4274        // 🔴 <dialog>.showModal() を使う=top layer に出るので、親が作る重ね合わせ
4275        //    文脈(.main{position:relative;z-index:1})に潜らない。body 直下に 1 個だけ。
4276        const _kxLocCache = new Map();
4277        let _kxLocSeq = 0;
4278        function kxLocDialog() {
4279            let d = document.getElementById('kxLocDialog');
4280            if (d) return d;
4281            d = document.createElement('dialog');
4282            d.id = 'kxLocDialog';
4283            d.className = 'kx-loc-dialog';
4284            d.setAttribute('aria-labelledby', 'kxLocTitle');
4285            d.innerHTML = `
4286                <div class="kx-loc-head">
4287                    <h3 id="kxLocTitle"></h3>
4288                    <button type="button" class="kx-loc-close" aria-label="閉じる">
4289                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
4290                    </button>
4291                </div>
4292                <div class="kx-loc-body"></div>
4293                <div class="kx-loc-foot"></div>`;
4294            document.body.appendChild(d);
4295            d.querySelector('.kx-loc-close').addEventListener('click', () => d.close());
4296            // 背景(::backdrop)を押したら閉じる。クリックは dialog 自身が受けるので、
4297            // 中身の矩形の外かどうかで判定する
4298            d.addEventListener('click', (e) => {
4299                if (e.target !== d) return;
4300                const r = d.getBoundingClientRect();
4301                if (e.clientX < r.left || e.clientX > r.right ||
4302                    e.clientY < r.top || e.clientY > r.bottom) d.close();
4303            });
4304            return d;
4305        }
4306
4307        async function openStoreLocations(storeKey) {
4308            const d = kxLocDialog();
4309            const nm = MATOME_STORE_NAMES[storeKey] || storeKey;
4310            const shopLink = `<a href="/store/${escapeHtml(storeKey)}?from=matome">${escapeHtml(nm)}の店舗ページ</a>`;
4311            d.querySelector('#kxLocTitle').textContent = `${nm}の店舗`;
4312            let data = _kxLocCache.get(storeKey);
4313            // 一度見た店は取りに行かないので「読み込み中…」を挟まない
4314            // (出すと、開いた瞬間に中身が入れ替わるちらつきになる)
4315            if (!data) {
4316                d.querySelector('.kx-loc-body').innerHTML = '<p class="kx-loc-msg">読み込み中…</p>';
4317                d.querySelector('.kx-loc-foot').innerHTML = '';
4318            }
4319            if (typeof d.showModal === 'function') { if (!d.open) d.showModal(); }
4320            else d.setAttribute('open', '');
4321
4322            // 🔴 応答が返るまでに別の店を開かれている可能性がある。**店名は B・住所は A**
4323            //    の画面を作ると、そのまま違う店へ行かせることになる。開くたび番号を進めて、
4324            //    自分が最新でなければ描かずに降りる(`d.open` だけでは閉じた時しか防げない)。
4325            const seq = ++_kxLocSeq;
4326            if (!data) {
4327                try {
4328                    const r = await fetch('/api/shop-locations/' + encodeURIComponent(storeKey),
4329                                          { credentials: 'same-origin' });
4330                    if (!r.ok) throw new Error('http ' + r.status);
4331                    data = await r.json();
4332                    _kxLocCache.set(storeKey, data);
4333                } catch (e) {
4334                    if (seq !== _kxLocSeq) return;
4335                    // 取れなかった時に空の枠だけ見せない。店舗ページには必ず載っている
4336                    d.querySelector('.kx-loc-body').innerHTML =
4337                        `<p class="kx-loc-msg">店舗情報を取得できませんでした。${shopLink}でご確認ください。</p>`;
4338                    return;
4339                }
4340            }
4341            if (!d.open || seq !== _kxLocSeq) return;   // 閉じられた/別の店に切り替わった
4342
4343            const list = (data.stores || []).map(st => {
4344                const meta = [];
4345                // 🔴 無い時に何も出さないと「見落とした」のか「元から無い」のかが読めない。
4346                //    住所の扱い(住所は公式サイトで公開されていません)と揃える
4347                if (!st.hours) meta.push('<span class="kx-loc-meta-item is-muted">営業時間の記載なし</span>');
4348                if (st.hours) meta.push(`<span class="kx-loc-meta-item">
4349                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15.5 14"></polyline></svg>
4350                    ${escapeHtml(st.hours)}</span>`);
4351                if (st.tel) meta.push(`<a class="kx-loc-meta-item kx-loc-tel" href="tel:${escapeHtml(String(st.tel).replace(/-/g, ''))}">
4352                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.3 1.8.6 2.7a2 2 0 0 1-.4 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.5 2.7.6a2 2 0 0 1 1.7 2z"></path></svg>
4353                    ${escapeHtml(st.tel)}</a>`);
4354                return `<li class="kx-loc-item">
4355                    <div class="kx-loc-name">${escapeHtml(st.name)}</div>
4356                    <div class="kx-loc-addr${st.address ? '' : ' is-muted'}">${st.address
4357                        ? escapeHtml(st.address) : '住所は公式サイトで公開されていません'}</div>
4358                    ${meta.length ? `<div class="kx-loc-meta">${meta.join('')}</div>` : ''}
4359                    ${st.note ? `<div class="kx-loc-note">${escapeHtml(st.note)}</div>` : ''}
4360                    ${st.map_url ? `<a class="kx-loc-map" href="${escapeHtml(st.map_url)}" target="_blank"
4361                        rel="noopener nofollow noreferrer" referrerpolicy="no-referrer"
4362                        aria-label="${escapeHtml(st.name)} を Google マップで開く">
4363                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 6-9 13-9 13s-9-7-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
4364                        Google マップで開く</a>` : ''}
4365                </li>`;
4366            }).join('');
4367
4368            d.querySelector('.kx-loc-body').innerHTML = list
4369                ? `<ul class="kx-loc-list">${list}</ul>`
4370                : '<p class="kx-loc-msg">店頭買取の店舗が登録されていません。</p>';
4371            const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(data.surveyed_at || '');
4372            const day = m ? `${+m[1]}年${+m[2]}月${+m[3]}日` : '';
4373            d.querySelector('.kx-loc-foot').innerHTML =
4374                (data.note ? `<p class="kx-loc-fnote">拠点について: ${escapeHtml(data.note)}</p>` : '')
4375                + `<p class="kx-loc-fnote">${day ? escapeHtml(day) + 'に' : ''}各店の公式サイトで確認した内容です。`
4376                + `営業時間や住所は変更されることがあるため、来店前に${shopLink}と公式サイトでご確認ください。</p>`;
4377        }
4378
4379        // 案が 1 つも作れなかった時。**パネルごと消してはいけない**——店頭モードは
4380        // 候補店を絞るので、消すと「店頭に持ち込む」を戻すボタンまで一緒に消えて
4381        // 画面が空のまま詰む(設定は保存されるのでリロードしても直らない)。
4382        function splitRenderEmpty(host, walkin, cause) {
4383            if (!walkin) { host.style.display = 'none'; return; }
4384            host.style.display = '';
4385            // 🔴 案内する操作は、実際にやれば直る操作でなければならない。
4386            //    店を外し切っただけの人に「店頭に持ち込むを外せ」と言っても何も起きない。
4387            const msg = cause === 'walkin'
4388                ? `店頭に持ち込める店舗では、この商品の買取価格が見つかりませんでした。
4389                   「店頭に持ち込む」を外すと、宅配買取のみの店舗も含めて比べられます。`
4390                : `比較対象店舗が選ばれていないか、選んだ店舗にこの商品の買取価格がありません。
4391                   上の「比較対象店舗」から選び直してください。`;
4392            host.innerHTML = `
4393                <div class="split-head">
4394                    <h3>いくつの店に分けるのが一番お得か</h3>
4395                    ${splitControlsHtml()}
4396                </div>
4397                <p class="split-empty">${msg}</p>`;
4398            bindSplitControls(host);
4399        }
4400
4401        // 操作行のハンドラ。描いた場所(通常の案/案が作れなかった時のお知らせ)が
4402        // どちらでも同じ配線になるよう 1 本にまとめる
4403        function bindSplitControls(host) {
4404            const walkinBtn = host.querySelector('#splitWalkinChk');
4405            if (walkinBtn) walkinBtn.addEventListener('click', () => {
4406                splitSettings.walkin = !splitSettings.walkin;
4407                splitPickedIdx = 0; saveSplitSettings(); renderSplitPlan();
4408            });
4409            const chk = host.querySelector('#splitShipChk');
4410            if (chk && !chk.disabled) chk.addEventListener('click', () => {
4411                splitSettings.shipping = !splitSettings.shipping; splitPickedIdx = 0; saveSplitSettings(); renderSplitPlan();
4412            });
4413            const cost = host.querySelector('#splitShipCost');
4414            if (cost) cost.addEventListener('change', () => {
4415                const v = parseInt(cost.value, 10);
4416                splitSettings.shippingCost = isNaN(v) ? SPLIT_DEFAULTS.shippingCost : Math.max(0, Math.min(9999, v));
4417                splitPickedIdx = 0; saveSplitSettings(); renderSplitPlan();
4418            });
4419            const fee = host.querySelector('#splitFeeChk');
4420            if (fee && !fee.disabled) fee.addEventListener('click', () => {
4421                splitSettings.transferFee = !splitSettings.transferFee; splitPickedIdx = 0; saveSplitSettings(); renderSplitPlan();
4422            });
4423            // 「来店前に営業時間・店舗を確認」は /shops へ飛ばさず popup で出す
4424            // (比較の途中で遷移すると、出したばかりの案が画面から消える)
4425            host.querySelectorAll('.kx-loc-open').forEach(b => b.addEventListener('click',
4426                () => openStoreLocations(b.dataset.store)));
4427            // 店舗チップはこのパネルの中に置くので、パネルを描き終えてから流し込む
4428            renderBestFilter();
4429        }
4430
4431        let _mapTimer = null, _mapSeq = 0;
4432
4433        // 対象商品一覧の開閉。自動で閉じる場合は persist しない(ユーザーが選んだ状態ではないため)
4434        function setMatomeItemsOpen(open, persist) {
4435            const box = document.getElementById('matomeProductsSummary');
4436            const btn = document.getElementById('matomeItemsToggle');
4437            if (!box || !btn) return;
4438            splitSettings.itemsCollapsed = !open;
4439            box.hidden = !open;
4440            btn.setAttribute('aria-expanded', String(open));
4441            if (persist) saveSplitSettings();
4442        }
4443
4444        function toggleMatomeAddPanel(open) {
4445            const panel = document.getElementById('matomeAddPanel');
4446            const btn = document.getElementById('matomeAddMoreBtn');
4447            if (!panel || !btn) return;
4448            const next = open === undefined ? panel.hidden : open;
4449            panel.hidden = !next;
4450            btn.setAttribute('aria-expanded', String(next));
4451            btn.classList.toggle('is-on', next);
4452            if (next) {
4453                // 追加パネルと対象商品一覧は同時に開くと SP で画面が埋まる。片方だけ開く
4454                setMatomeItemsOpen(false);
4455                const input = document.getElementById('matomeAddInput');
4456                input.value = '';
4457                document.getElementById('matomeAddResults').innerHTML =
4458                    '<p class="map-hint">商品名・JAN・ASIN を入力すると候補が表示されます。選ぶとこの比較に
4458追加されます。</p>';
4459                setTimeout(() => input.focus(), 30);
4460            }
4461        }
4462
4463        function renderMatomeAddResults(list, query) {
4464            const host = document.getElementById('matomeAddResults');
4465            if (!list.length) {
4466                host.innerHTML = `<p class="map-hint">「${escapeHtml(query)}」に一致する商品は見つかりませんでした。</p>`;
4467                return;
4468            }
4469            host.innerHTML = '<ul class="map-list">' + list.map(p => {
4470                const added = comparisonItems.some(i => i.jan === p.jan && i.quantity > 0);
4471                const img = p.image_url
4472                    ? `<img src="${escapeHtml(p.image_url)}" alt="" loading="lazy" width="40" height="40">`
4473                    : '<span class="map-ph" aria-hidden="true"></span>';
4474                return `<li>
4475                    <button type="button" class="map-row${added ? ' is-added' : ''}" data-jan="${escapeHtml(p.jan)}"
4476                        data-name="${escapeHtml(p.name || p.jan)}" data-img="${escapeHtml(p.image_url || '')}"
4477                        ${added ? 'disabled' : ''}>
4478                        <span class="map-thumb">${img}</span>
4479                        <span class="map-body">
4480                            <span class="map-name">${escapeHtml(p.name || p.jan)}</span>
4481                            <span class="map-jan">${escapeHtml(p.jan)}</span>
4482                        </span>
4483                        <span class="map-act">${added ? '追加済み' : '追加'}</span>
4484                    </button>
4485                </li>`;
4486            }).join('') + '</ul>';
4487            host.querySelectorAll('.map-row:not([disabled])').forEach(row => {
4488                row.addEventListener('click', () => {
4489                    const { jan, name, img } = row.dataset;
4490                    const exist = comparisonItems.find(i => i.jan === jan);
4491                    if (exist) exist.quantity = Math.min(999, (exist.quantity || 0) + 1);
4492                    else comparisonItems.push({ jan, name, quantity: 1, image_url: img });
4493                    addToMatome(jan, name, 1, img);
4494                    comparisonModified = true;
4495                    renderComparisonSummary();
4496                    row.classList.add('is-added');
4497                    row.disabled = true;
4498                    row.querySelector('.map-act').textContent = '追加済み';
4499                    showCopiedToast(`「${name}」を追加しました。「再比較」を押すと価格を取り込みます`);
4500                });
4501            });
4502        }
4503
4504        function bindMatomeAddPanel() {
4505            const input = document.getElementById('matomeAddInput');
4506            const close = document.getElementById('matomeAddClose');
4507            if (!input || input.dataset.bound) return;
4508            input.dataset.bound = '1';
4509            close.addEventListener('click', () => toggleMatomeAddPanel(false));
4510            input.addEventListener('keydown', e => { if (e.key === 'Escape') toggleMatomeAddPanel(false); });
4511            input.addEventListener('input', () => {
4512                const q = input.value.trim();
4513                clearTimeout(_mapTimer);
4514                const host = document.getElementById('matomeAddResults');
4515                if (q.length < 2) {
4516                    host.innerHTML = '<p class="map-hint">2文字以上入力してください。</p>';
4517                    return;
4518                }
4519                const seq = ++_mapSeq;
4520                _mapTimer = setTimeout(async () => {
4521                    host.innerHTML = '<p class="map-hint">検索中…</p>';
4522                    try {
4523                        const data = await apiCall(`/api/search?q=${encodeURIComponent(q)}&limit=20&offset=0`);
4524                        if (seq !== _mapSeq) return;   // 打ち直された分は捨てる
4525                        renderMatomeAddResults((data && data.results) || [], q);
4526                    } catch (err) {
4527                        if (seq === _mapSeq) host.innerHTML = '<p class="map-hint">検索に失敗しました。時間をおいて再度お試しください。</p>';
4528                    }
4529                }, 250);
4530            });
4531        }
4532
4533
4534        function updateComparisonItemQty(jan, quantity) {
4535            const item = comparisonItems.find(i => i.jan === jan);
4536            if (item) {
4537                item.quantity = quantity; comparisonModified = true;
4538                const el = document.querySelector(`.matome-summary-item[data-jan="${jan}"]`);
4539                if (el) quantity === 0 ? el.classList.add('zero-qty') : el.classList.remove('zero-qty');
4540                document.getElementById('matomeRecompareBtn').disabled = false;
4541                const activeItems = comparisonItems.filter(i => i.quantity > 0);
4542                document.getElementById('matomeResultSummary').textContent = `${activeItems.length}種類 合計${activeItems.reduce((sum, i) => sum + i.quantity, 0)}個を比較中`;
4543                document.getElementById('matomeItemsCount').textContent = `${activeItems.length}種類`;
4544            }
4545        }
4546
4547        // 全店のカードを縦に並べると、上の分割案と同じ情報が 31 回繰り返されて読み取れない。
4548        // 「どの店に出すか」は分割案パネル側(1店まで=各店の単独案を順位で選べる)に一本化した。
4549        function renderComparisonStoreCards(storeResults) {
4550            const host = document.getElementById('matomeStoreResults');
4551            if (host) host.innerHTML = '';
4552            if (window.KX_SHIPPING_RULES && window.KX_SHIPPING_RULES.stores) return;
4553            renderComparisonStoreCardsLegacy(storeResults);
4554        }
4555
4556        // 送料ルールを読めず分割案パネルを出せない時だけ使う従来表示
4557        function renderComparisonStoreCardsLegacy(storeResults) {
4558            const storeUrls = Object.assign({ '買取一丁目': 'https://www.1-chome.com/', '買取商店': 'https://www.kaitorishouten-co.jp/', '森森買取': 'https://www.morimori-kaitori.jp/', '買取ルデヤ': 'https://kaitori-rudeya.com/', '買取wiki': 'https://kaitori.wiki/', 'モバイル一番': 'https://www.mobile-ichiban.com/', '家電市場': 'https://www.kaden-ichiba.com/', '買取ホムラ': 'https://kaitori-homura.com/', 'ハチ買取': 'https://hachiden.com/', '買取けんさく君': 'https://www.kaitorikensakukun.com/' }, window.KX_STORE_URLS || {});
4559            document.getElementById('matomeStoreResults').innerHTML = storeResults.map((store, index) => {
4560                const isFirst = index === 0;
4561                let bestTotal = 0;
4562                store.items.forEach(item => {
4563                    if (item.quantity > 0 && item.bestPrice > 0) bestTotal += item.bestPrice * item.quantity;
4564                });
4565                const extraIfBest = bestTotal - store.total;
4566                return `
4567                    <div class="matome-store-card ${isFirst ? 'first' : ''}">
4568                        <div class="matome-store-card-header">
4569                            <div class="matome-store-card-rank">
4570                                <span class="matome-store-rank-badge">${isFirst ? '1' : (index + 1)}</span>
4571                                <a href="${storeUrls[store.name] || '#'}" target="_blank" ${kxStoreLinkAttrs(storeUrls[store.name] || '#')} class="matome-store-name-link">${escapeHtml(store.name)}</a>
4572                            </div>
4573                            <div class="matome-store-total">
4574                                <div class="matome-store-total-label">合計買取価格</div>
4575                                <div class="matome-store-total-value">Â¥${store.total.toLocaleString()}</div>
4576                                ${extraIfBest > 0 ? `<div class="matome-store-best-extra">商品別に最高値で +¥${extraIfBest.toLocaleString()}</div>` : ''}
4577                            </div>
4578                        </div>
4579                        ${(store.unavailableCount > 0 || store.staleCount > 0) ? `<div class="matome-store-warning">
4580                            <svg class="ui-icon" style="width:14px;height:14px;display:inline;vertical-align:middle;margin-right:4px;" viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
4581                            ${[store.unavailableCount > 0 ? `${store.unavailableCount}個は買取不可` : '', store.staleCount > 0 ? `${store.staleCount}個は買取停止中の可能性(合計に含めず)` : ''].filter(Boolean).join('・')}
4582                        </div>` : ''}
4583                        <table class="matome-store-items-table">
4584                            ${store.items.filter(item => item.quantity > 0).map(item => {
4585                                const isBest = item.unitPrice > 0 && item.unitPrice >= item.bestPrice;
4586                                const diff = (item.unitPrice > 0 && !isBest && item.bestPrice > 0) ? (item.bestPrice - item.unitPrice) * item.quantity : 0;
4587                                const bestHint = (item.unitPrice > 0 && !isBest && item.bestPrice > 0)
4588                                    ? `<span class="matome-item-best-hint">
4588他店最高 ¥${item.bestPrice.toLocaleString()} (${escapeHtml(item.bestStore)})</span>`
4589                                    : '';
4590                                const imgCell = item.image
4591                                    ? `<img src="${escapeHtml(item.image)}" alt="" loading="lazy" width="48" height="48">`
4592                                    : '<span class="matome-img-ph" aria-hidden="true"></span>';
4593                                return `
4594                                <tr class="matome-store-item">
4595                                    <td class="matome-store-item-img"><a href="/dp/${escapeHtml(item.jan)}" tabindex="-1" aria-hidden="true">${imgCell}</a></td>
4596                                    <td class="matome-store-item-name"><a href="/dp/${escapeHtml(item.jan)}" style="text-decoration:none;color:inherit;">${escapeHtml(item.name)}</a>${bestHint}</td>
4597                                    <td class="matome-store-item-copy-jan"><button type="button" class="matome-jan-copy-btn" onclick="copyToClipboard('${escapeHtml(item.jan)}','JANをコピーしました')" title="JANをコピー">${escapeHtml(item.jan)}</button></td>
4598                                    <td class="matome-store-item-qty">${item.unitPrice > 0 ? `@¥${item.unitPrice.toLocaleString()} × ${item.quantity}` : `× ${item.quantity}`}</td>
4599                                    <td class="matome-store-item-price ${item.unitPrice > 0 ? '' : 'unavailable'}">${item.unitPrice > 0 ? `¥${item.subtotal.toLocaleString()}` : (item.stale ? '買取停止中?' : '買取不可')}${diff > 0 ? `<span class="matome-item-diff">+¥${diff.toLocaleString()}</span>` : ''}</td>
4600                                </tr>`;
4601                            }).join('')}
4602                        </table>
4603                        ${extraIfBest > 0 ? `<div class="matome-store-extra-summary"><span>商品別に最高値で売却した場合</span><span class="matome-store-extra-amount">+¥${extraIfBest.toLocaleString()}</span></div>` : ''}
4604                    </div>
4605                `;
4606            }).join('');
4607        }
4608
4609        // まとめ比較中は「買取Xとは?」以下の紹介を畳み、コミュニティ導線だけ残す。
4610        // 元の display を覚えておいて戻す(決め打ちで '' にすると別の指定を壊す)
4611        function toggleAboutForMatome(hide) {
4612            const about = document.getElementById('aboutSection');
4613            if (!about) return;
4614            // 残すのは全ページ共通の CTA(_cta.html={{CTA}} で注入される .cta-wrap)だけ。
4615            // ここで独自の SNS ブロックを書くと全站と見た目がずれる
4616            const keep = about.querySelector('.cta-wrap');
4617            const keepTop = keep ? [...about.children].find(c => c.contains(keep)) : null;
4618            [...about.children].forEach(el => {
4619                if (hide) {
4620                    if (el === keepTop) return;
4621                    if (el.dataset.matomeHidden === undefined) el.dataset.matomeHidden = el.style.display;
4622                    el.style.display = 'none';
4623                } else if (el.dataset.matomeHidden !== undefined) {
4624                    el.style.display = el.dataset.matomeHidden;
4625                    delete el.dataset.matomeHidden;
4626                }
4627            });
4628        }
4629
4630        function goBackFromMatomeComparison() {
4631            document.getElementById('matomeResultSection').style.display = 'none';
4632            // まとめ FAB を角の席から本来の位置(席 +68px)へ戻す。外し忘れると
4633            // トップに戻った時に DC/X FAB と同じ席で重なる
4634            document.body.classList.remove('kx-matome-compare');
4635            toggleMatomeAddPanel(false);
4636            // 隠していた「値動き」「人気メニュー」セクションを復帰
4637            const kxQuickSection = document.querySelector('.kx-quick-section');
4638            if (kxQuickSection) kxQuickSection.style.display = '';
4639            toggleAboutForMatome(false);
4640            const heroWrap = document.querySelector('.hero-wrap');
4641            if (heroWrap) heroWrap.style.display = '';
4642            document.querySelectorAll('.discord-fab, .x-fab').forEach(el => { el.style.display = ''; });
4643            // URLからmatome=compareを削除
4644            const u = new URL(location.href); u.searchParams.delete('matome');
4645            history.replaceState(null, '', u.search ? u.toString() : u.pathname);
4646            // 「トップへ」=どこから比較に入ってもトップに戻す(May 指示 2026-08-14)。
4647            // 直前が商品ページでもそこには戻さない
4648            elements.productSection.classList.remove('active');
4649            elements.searchSection.style.display = 'block';
4650            window.scrollTo({ top: 0, behavior: 'smooth' });
4651            updateMatomeUI(); previousViewState = null;
4652        }
4653
4654        // ==================== お気に入り機能 (Favorites) ====================
4655        function getFavoritesFromStorage() {
4656            try {
4657                const data = localStorage.getItem(FAVORITES_KEY);
4658                return data ? JSON.parse(data) : { items: [], displayMode: 'normal', updatedAt: null };
4659            } catch (err) { return { items: [], displayMode: 'normal', updatedAt: null }; }
4660        }
4661
4662        function saveFavoritesToStorage(favs) { favs.updatedAt = Date.now(); localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs)); }
4663
4664        function isFavorite(jan) { return getFavoritesFromStorage().items.some(item => item.jan === jan); }
4665
4666        function updateFavoriteStarUI(jan) {
4667            const starBtn = document.getElementById('favoriteStarBtn');
4668            if (!starBtn) return;
4669            const isFav = isFavorite(jan);
4670            starBtn.querySelector('.star-empty').style.display = isFav ? 'none' : 'inline';
4671            starBtn.querySelector('.star-filled').style.display = isFav ? 'inline' : 'none';
4672        }
4673
4674        function toggleFavorite() {
4675            if (!currentProductData || !currentJan) return;
4676            let favs = getFavoritesFromStorage();
4677            const wasAdded = !isFavorite(currentJan);
4678            if (!wasAdded) favs.items = favs.items.filter(item => item.jan !== currentJan);
4679            else if (favs.items.length < MAX_FAVORITES) favs.items.push({ jan: currentJan, name: currentProductData.name, image_url: currentProductData.image_url, addedAt: Date.now() });
4680            else { alert(`最大${MAX_FAVORITES}件までです`); return; }
4681
4682            saveFavoritesToStorage(favs); updateFavoriteStarUI(currentJan);
4683            // サーバー同期(ログイン済みの場合)
4684            try {
4685                fetch('/api/favorites', {
4686                    method: wasAdded ? 'POST' : 'DELETE',
4687                    headers: {'Content-Type': 'application/json'},
4688                    body: JSON.stringify({jan: currentJan})
4689                }).catch(()=>{});
4690            } catch(e) {}
4691        }
4692
4693        // ==================== 事件绑定与初始化 ====================
4694        function bindEvents() {
4695            elements.searchInput.addEventListener('input', () => { syncEnterHint(); debouncedSearch(); });
4696            elements.searchInput.addEventListener('keypress', (e) => {
4697                if (e.key === 'Enter') {
4698                    const v = (elements.searchInput.value || '').trim();
4699                    if (v.length < 2) return;
4700                    // 1件だけヒット → その商品へ / 2件以上 → 検索一覧ページへ
4701                    if (v === _dropdownQuery && _dropdownResults.length === 1) {
4702                        hideDropdown(); currentFrom = 'search'; _updateBackBtn('search'); showProduct(_dropdownResults[0].jan);
4703                    } else {
4704                        window.location.href = '/search?q=' + encodeURIComponent(v);
4705                    }
4706                }
4707            });
4708            elements.searchInput.addEventListener('focus', () => { if (elements.searchInput.value.trim().length >= 2) debouncedSearch(); });
4709            document.addEventListener('click', (e) => { if (!elements.searchInput.contains(e.target) && !elements.searchDropdown.contains(e.target)) hideDropdown(); });
4710
4711            elements.backBtn.addEventListener('click', () => goBack(false));
4712            // コピーは共通の jan-copy.js(.kx-jan-copy への委譲)が受け持つ。
4713            // ここで二重に登録すると 1 タップで 2 回コピー+トーストが 2 つ出る。
4714
4715            // Inline search on product page
4716            (function(){
4717                const inp = document.getElementById('inlineSearchInput');
4718                const dd = document.getElementById('inlineSearchDropdown');
4719                if (!inp || !dd) return;
4720                let timer;
4721                var _inlineResults = [];
4722                var _inlineQuery = '';
4723                var _inlineHasMore = true;
4724                var _inlineLoading = false;
4725                var _INLINE_PAGE = 30;
4726                function _renderInlineBody(){
4727                    var html = _inlineResults.map(function(r){
4728                        var img = r.image_url
4729                            ? '<img src="' + escapeHtml(r.image_url) + '" loading="lazy" alt="" style="width:36px;height:36px;object-fit:contain;border-radius:4px;background:#fff;flex-shrink:0;">'
4730                            : '<div style="width:36px;height:36px;border-radius:4px;background:var(--border);flex-shrink:0;"></div>';
4731                        return '<a href="/dp/' + r.jan + '" style="display:flex;align-items:center;gap:10px;">' + img + '<div style="flex:1;min-width:0;"><div style="font-weight:500;">
4731' + escapeHtml(r.name).substring(0,60) + '</div><div style="font-size:0.75rem;color:var(--text-muted);">JAN: ' + r.jan + '</div></div></a>';
4732                    }).join('');
4733                    if (_inlineHasMore) {
4734                        var label = _inlineLoading ? '読み込み中...' : 'さらに読み込む (+' + _INLINE_PAGE + ')';
4735                        html += '<div id="inlineLoadMore" style="padding:10px;text-align:center;cursor:pointer;color:var(--primary);font-weight:600;font-size:0.85rem;border-top:1px solid var(--border);">' + label + '</div>';
4736                    }
4737                    html += _searchAllRow(_inlineQuery, _inlineResults.length !== 1);
4738                    dd.innerHTML = html;
4739                    dd.classList.add('active');
4740                    var moreBtn = document.getElementById('inlineLoadMore');
4741                    if (moreBtn) {
4742                        moreBtn.addEventListener('click', async function(e){
4743                            e.stopPropagation();
4744                            if (_inlineLoading || !_inlineHasMore) return;
4745                            _inlineLoading = true;
4746                            _renderInlineBody();
4747                            try {
4748                                const token = (typeof CONFIG !== 'undefined' && CONFIG.API_TOKEN) ? CONFIG.API_TOKEN : '';
4749                                const offset = _inlineResults.length;
4750                                const res = await fetch('/api/search?q=' + encodeURIComponent(_inlineQuery) + '&limit=' + _INLINE_PAGE + '&offset=' + offset, {headers: {'X-API-Token': token}});
4751                                const data = await res.json();
4752                                var more = (data && data.results) || [];
4753                                _inlineResults = _inlineResults.concat(more);
4754                                _inlineHasMore = more.length >= _INLINE_PAGE;
4755                            } catch(err) { _inlineHasMore = false; }
4756                            _inlineLoading = false;
4757                            _renderInlineBody();
4758                        });
4759                    }
4760                }
4761                inp.addEventListener('input', function(){
4762                    clearTimeout(timer);
4763                    const q = inp.value.trim();
4764                    if (q.length < 2) { dd.classList.remove('active'); return; }
4765                    timer = setTimeout(async function(){
4766                        try {
4767                            const token = (typeof CONFIG !== 'undefined' && CONFIG.API_TOKEN) ? CONFIG.API_TOKEN : '';
4768                            _inlineQuery = q;
4769                            const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=' + _INLINE_PAGE + '&offset=0', {headers: {'X-API-Token': token}});
4770                            const data = await res.json();
4771                            if (!data.results || !data.results.length) { dd.innerHTML = '<div style="padding:12px 16px;text-align:center;color:var(--text-muted);font-size:0.88rem;"><div style="margin-bottom:6px;">「' + escapeHtml(q) + '」の検索結果はありません</div><div style="font-size:0.8rem;">商品名の一部・JAN・ASINで検索してみてください</div></div>' + _searchAllRow(q); dd.classList.add('active'); return; }
4772                            _inlineResults = data.results;
4773                            _inlineHasMore = _inlineResults.length >= _INLINE_PAGE;
4774                            _inlineLoading = false;
4775                            _renderInlineBody();
4776                        } catch(e) { dd.classList.remove('active'); }
4777                    }, 300);
4778                });
4779                inp.addEventListener('keypress', function(e){ if (e.key === 'Enter') { e.preventDefault(); var v=(inp.value||'').trim(); if(v.length<2) return; if(v===_inlineQuery && _inlineResults.length===1){ showProduct(_inlineResults[0].jan); } else { window.location.href='/search?q='+encodeURIComponent(v); } }});
4780                document.addEventListener('click', function(e){ if (!inp.contains(e.target) && !dd.contains(e.target)) dd.classList.remove('active'); });
4781            })();
4782
4783            document.getElementById('carouselPrev').addEventListener('click', carouselPrevPage);
4784            document.getElementById('carouselNext').addEventListener('click', carouselNextPage);
4785            document.getElementById('profitableToggle').addEventListener('click', toggleProfitableContent);
4786            document.getElementById('clearHistoryBtn').addEventListener('click', clearHistory);
4787
4788            // Category tab + collapse
4789            const _tabSearchBtn = document.getElementById('tabSearchCount');
4790            const _tabCatBtn = document.getElementById('tabCategory');
4791            const _popCollapseBtn = document.getElementById('popularCollapseBtn');
4792            const _hisCollapseBtn = document.getElementById('historyCollapseBtn');
4793            if (_tabSearchBtn) _tabSearchBtn.addEventListener('click', () => switchPopularTab('search_count'));
4794            if (_tabCatBtn) _tabCatBtn.addEventListener('click', () => switchPopularTab('category'));
4795            if (_popCollapseBtn) _popCollapseBtn.addEventListener('click', () => toggleCollapse('popularBody', 'popularCollapseBtn', 'kaitorix_popular_collapsed'));
4796            if (_hisCollapseBtn) _hisCollapseBtn.addEventListener('click', () => toggleCollapse('historyBody', 'historyCollapseBtn', 'kaitorix_history_collapsed'));
4797
4798            const qtyInput = document.getElementById('qtyInput');
4799            document.getElementById('qtyMinus').addEventListener('click', () => { qtyInput.value = Math.max(1, (parseInt(qtyInput.value)||1) - 1); });
4800            document.getElementById('qtyPlus').addEventListener('click', () => { qtyInput.value = Math.min(999, (parseInt(qtyInput.value)||1) + 1); });
4801            document.getElementById('matomeAddBtn').addEventListener('click', () => {
4802                if(!currentProductData) return;
4803                const res = addToMatome(currentProductData.jan, currentProductData.name, parseInt(qtyInput.value)||1, currentProductData.image_url);
4804                const fb = document.getElementById('matomeAddFeedback');
4805                fb.textContent = res.message; fb.style.color = res.success ? 'var(--success)' : 'var(--danger)';
4806                if(res.success) { qtyInput.value = 1; updateMatomeUI(); }
4807                setTimeout(() => fb.textContent = '', 3000);
4808            });
4809
4810            // PC: profit calculator
4811            const profitInput = document.getElementById('profitCalcInput');
4812            if (profitInput) {
4813                profitInput.addEventListener('input', () => {
4814                    const cost = parseInt(profitInput.value) || 0;
4815                    const resultEl = document.getElementById('profitCalcResult');
4816                    if (!currentProductData || !currentProductData.prices || currentProductData.prices.length === 0 || cost <= 0) {
4817                        resultEl.textContent = '利益 ¥---';
4818                        resultEl.className = 'profit-calc-result zero';
4819                        return;
4820                    }
4821                    // 🔴 素の最高値で計算する。条件付きの額(30点以上・未開封 等)で
4822                    // 利益を出すと、条件を満たさない人に届かない数字を見せる事になる。
4823                    const _pr = currentProductData.prices || [];
4824                    const maxPrice = rawMaxPrice(_pr) || deobfuscatePrice(_pr[0].price);
4825                    const profit = maxPrice - cost;
4826                    if (profit > 0) {
4827                        resultEl.textContent = `利益 +¥${profit.toLocaleString()}`;
4828                        resultEl.className = 'profit-calc-result positive';
4829                    } else if (profit < 0) {
4830                        resultEl.textContent = `損失 -¥${Math.abs(profit).toLocaleString()}`;
4831                        resultEl.className = 'profit-calc-result negative';
4832                    } else {
4833                        resultEl.textContent = '利益 ±¥0';
4834                        resultEl.className = 'profit-calc-result zero';
4835                    }
4836                });
4837            }
4838
4839            // PC: matome qty buttons + add button
4840            const qtyInputPc = document.querySelector('.qty-input-pc');
4841            document.querySelectorAll('.qty-btn-pc').forEach(btn => {
4842                btn.addEventListener('click', () => {
4843                    if (!qtyInputPc) return;
4844                    if (btn.dataset.action === 'minus') qtyInputPc.value = Math.max(1, (parseInt(qtyInputPc.value)||1) - 1);
4845                    else qtyInputPc.value = Math.min(999, (parseInt(qtyInputPc.value)||1) + 1);
4846                });
4847            });
4848            const matomeAddBtnPc = document.getElementById('matomeAddBtnPc');
4849            if (matomeAddBtnPc) {
4850                matomeAddBtnPc.addEventListener('click', () => {
4851                    if(!currentProductData) return;
4852                    const res = addToMatome(currentProductData.jan, currentProductData.name, parseInt(qtyInputPc.value)||1, currentProductData.image_url);
4853                    if(res.success) { qtyInputPc.value = 1; updateMatomeUI(); }
4854                });
4855            }
4856
4857            document.getElementById('matomeExpandBtn').addEventListener('click', expandMatomeBar);
4858            document.getElementById('matomeCollapseBtn').addEventListener('click', collapseMatomeBar);
4859            document.getElementById('matomeCompareBtn').addEventListener('click', startMatomeComparison);
4860            document.getElementById('matomeCompareBtnExpanded').addEventListener('click', startMatomeComparison);
4861            document.getElementById('matomeClearBtn').addEventListener('click', clearMatome);
4862            document.getElementById('matomeBackBtn').addEventListener('click', goBackFromMatomeComparison);
4863
4864            // 期間切替: コンパクトセレクト(ブラシで任意範囲も選択可)
4865            const chartRangeSel = document.getElementById('chartRangeSel');
4866            if (chartRangeSel) {
4867                chartRangeSel.addEventListener('change', () => {
4868                    const d = parseInt(chartRangeSel.value);
4869                    if (d > 0 && currentHistoryData) {
4870                        _saveChartPeriod(d);   // ユーザー選択を記憶(次回・他商品でも既定に)
4871                        renderChart(currentHistoryData, d);
4872                    }
4873                });
4874            }
4875
4876            document.getElementById('favoriteStarBtn').addEventListener('click', toggleFavorite);
4877
4878            // Share button
4879            const shareBtn = document.getElementById('shareBtn');
4880            const shareMenu = document.getElementById('shareMenu');
4881            shareBtn.addEventListener('click', (e) => {
4882                e.stopPropagation();
4883                shareMenu.classList.toggle('active');
4884            });
4885            document.addEventListener('click', () => shareMenu.classList.remove('active'));
4886            shareMenu.addEventListener('click', (e) => e.stopPropagation());
4887
4888            function getShareText() {
4889                if (!currentProductData) return { text: '', url: '' };
4890                const p = currentProductData;
4891                const url = `https://kaitorix.app/dp/${p.jan}`;
4892                const prices = p.prices || [];
4893                let lines = [`📦 ${p.name}`, ''];
4894                if (prices.length > 0) {
4895                    lines.push('【買取価格一覧】');
4896                    prices.forEach((s, i) => {
4897                        const prefix = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : ' ';
4898                        // 🔴 キャンペーン価は条件とセットでしか外へ出さない。共有文は
4899                        // X/LINE/クリップボードへそのまま貼られ、チップの注記は付いて
4900                        // 行かないので、ここで注記を文字として同行させる。
4901                        const _c = s.campaign;
4902                        const _cond = (_c && _c.text) ? `(${_c.text}/条件あり)` : '';
4903                        lines.push(`${prefix} ${s.store}:¥${deobfuscatePrice(s.price).toLocaleString()}${_cond}`);
4904                    });
4905                    lines.push('');
4906                }
4907                lines.push(`▶ 買取X で最新価格をチェック`);
4908                return { text: lines.join('\n'), url };
4909            }
4910
4911            document.getElementById('shareX').addEventListener('click', () => {
4912                const { text, url } = getShareText();
4913                if (!text) return;
4914                window.open(`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`, '_blank');
4915                shareMenu.classList.remove('active');
4916            });
4917            document.getElementById('shareLINE').addEventListener('click', () => {
4918                const { text, url } = getShareText();
4919                if (!text) return;
4920                window.open(`https://line.me/R/share?text=${encodeURIComponent(text + '\n' + url)}`, '_blank');
4921                shareMenu.classList.remove('active');
4922            });
4923            document.getElementById('shareCopy').addEventListener('click', () => {
4924                const { text, url } = getShareText();
4925                if (!text) return;
4926                copyToClipboard(text + '\n' + url, 'リンクをコピーしました');
4927                shareMenu.classList.remove('active');
4928            });
4929        }
4930
4931        // ホーム(検索トップ)専用セクションの遅延ロード。
4932        // 商品ページ(/dp/{jan} 等)で初回ロードすると init はこれらをスキップするため、
4933        // 後で goBack でトップに戻った時に「人気/利益商品/最近見た」が空のままになる
4934        // (履歴は localStorage 同期ロードなので常に出る)。冪等にして両経路から呼ぶ。
4935        let _homeSectionsLoaded = false;
4936        function ensureHomeSectionsLoaded() {
4937            if (_homeSectionsLoaded) return;
4938            _homeSectionsLoaded = true;
4939            loadProfitableProducts();
4940            loadDealTicker();
4941            loadPopularSearches();
4942            loadRecentViewed();
4943        }
4944
4945        function init() {
4946            bindEvents();
4947            const _isProductPage = /^\/(?:dp|product)\/\d{7,14}$/.test(window.location.pathname)
4948                || new URLSearchParams(window.location.search).has('jan');
4949            if (!_isProductPage) {
4950                ensureHomeSectionsLoaded();
4951            }
4952            loadProfitPreview();
4953            loadHistory();
4954            restoreCollapsedState();
4955            updateMatomeUI();
4956
4957            const pathMatch = window.location.pathname.match(/^\/(?:dp|product)\/(\d{7,14})$/);
4958            const urlParams = new URLSearchParams(window.location.search);
4959            const urlJan = urlParams.get('jan');
4960            const urlQuery = urlParams.get('q');
4961            const fromVal = urlParams.get('from');
4962            if (fromVal && _backConfig[fromVal]) {
4963                currentFrom = fromVal;
4964                _updateBackBtn(fromVal);
4965                // from_url: 戻り先の具体的 URL(listing.html から付与)
4966                const fromUrlVal = urlParams.get('from_url');
4967                if (fromUrlVal) {
4968                    try {
4969                        // from_url は /ranking/price-up 等の絶対パス
4970                        const decoded = decodeURIComponent(fromUrlVal);
4971                        if (decoded.startsWith('/')) currentFromUrl = decoded;
4972                    } catch(e) {}
4973                }
4974                // from/from_url 以外の query params を保存(source 等)
4975                const backParams = new URLSearchParams(urlParams);
4976                backParams.delete('from');
4977                backParams.delete('from_url');
4978                currentFromQuery = backParams.toString();
4979            }
4980
4981            const urlMatome = decodeMatomFromUrl();
4982            if (urlMatome) {
4983                // URL からまとめ復元して自動比較
4984                saveMatomeToStorage({ items: [], updatedAt: Date.now() });
4985                (async function() {
4986                    // 共有 URL のまとめは件数が多い(=相手も同じ罠を踏む)ので一括で取る
4987                    const got = await fetchProductsBatch(urlMatome.map(it => it.jan));
4988                    urlMatome.forEach(function(item, i) {
4989                        const d = got[i];
4990                        addToMatome(item.jan, (d && d.name) || item.jan, item.quantity,
4991                                    (d && d.image_url) || '');
4992                    });
4993                    updateMatomeUI();
4994                    if (new URLSearchParams(location.search).get('matome') === 'compare') {
4995                        startMatomeComparison();
4996                    }
4997                })();
4998            } else if (urlParams.get('matome') === 'compare') {
4999                setTimeout(() => startMatomeComparison(), 300);
5000            } else if (pathMatch) { _isInitialLoad = true; showProduct(pathMatch[1], true); }
5001            else if (urlJan) { _isInitialLoad = true; showProduct(urlJan, true); }
5002            else if (urlQuery) {
5003                elements.searchInput.value = urlQuery;
5004                syncEnterHint(); // value をプログラムから入れた場合は input イベントが飛ばない
5005                // 1件のみマッチなら直接商品ページへ
5006                apiCall('/api/search?q=' + encodeURIComponent(urlQuery) + '&limit=2').then(function(data) {
5007                    if (data.results && data.results.length === 1) {
5008                        _isInitialLoad = true;
5009                        showProduct(data.results[0].jan, true);
5010                    } else {
5011                        searchDropdown(urlQuery);
5012                    }
5013                }).catch(function() { searchDropdown(urlQuery); });
5014            }
5015            else elements.searchInput.focus();
5016
5017            // GA4: 外部アフィリエイトリンククリック追跡
5018            document.addEventListener('click', function(e) {
5019                var link = e.target.closest('a[href*="amazon.co.jp"], a[href*="rakuten.co.jp"], a[href*="shopping.yahoo.co.jp"], a[href*="mottoku.app"]');
5020                if (link && window.gtag) {
5021                    gtag('event', 'click', {
5022                        event_category: 'affiliate',
5023                        event_label: link.hostname,
5024                        transport_type: 'beacon'
5025                    });
5026                }
5027            });
5028
5029            trackPageView();
5030            window.addEventListener('popstate', () => {
5031                const m = window.location.pathname.match(/^\/(?:dp|product)\/(\d{7,14})$/);
5032                if (m) {
5033                    // URL に from= があれば復元
5034                    const pp = new URLSearchParams(window.location.search);
5035                    const pf = pp.get('from');
5036                    if (pf && _backConfig[pf]) {
5037                        currentFrom = pf; _updateBackBtn(pf);
5038                        const pfUrl = pp.get('from_url');
5039                        if (pfUrl) {
5040                            try {
5041                                const decoded = decodeURIComponent(pfUrl);
5042                                if (decoded.startsWith('/')) currentFromUrl = decoded;
5043                            } catch(e) {}
5044                        } else { currentFromUrl = null; }
5045                        const bp = new URLSearchParams(pp); bp.delete('from'); bp.delete('from_url');
5046                        currentFromQuery = bp.toString();
5047                    }
5048                    showProduct(m[1]);
5049                } else {
5050                    currentFrom = null;
5051                    _updateBackBtn(null);
5052                    goBack(true);
5053                }
5054            });
5055        }
5056
5057        // Amazon/Deal クリック統計
5058        document.addEventListener('click', function(e) {
5059            var a = e.target.closest('a[href*="amazon.co.jp"], a[href*="kaitorix.app/deal/"]');
5060            if (!a) return;
5061            var href = a.href || '';
5062            var asin = '';
5063            var m = href.match(/\/dp\/([A-Z0-9]{10})/i) || href.match(/\/deal\/([A-Z0-9]{10})/i);
5064            if (m) asin = m[1].toUpperCase();
5065            navigator.sendBeacon('/api/deal-click', JSON.stringify({r: document.referrer || location.origin, d: document.referrer || '', a: asin, s: 'web'}));
5066        });
5067
5068        document.addEventListener('DOMContentLoaded', init);
5069
5070        // Hero particles
5071        document.addEventListener('DOMContentLoaded', function() {
5072            const c = document.getElementById('heroParticlesMain');
5073            if (!c) return;
5074            for (let i = 0; i < 15; i++) {
5075                const s = document.createElement('span');
5076                const sz = Math.random() * 5 + 2;
5077                s.style.width = sz + 'px';
5078                s.style.height = sz + 'px';
5079                s.style.left = Math.random() * 100 + '%';
5080                s.style.bottom = (Math.random() * 60 + 10) + '%';
5081                s.style.animationDuration = (Math.random() * 4 + 3) + 's';
5082                s.style.animationDelay = (Math.random() * 5) + 's';
5083                c.appendChild(s);
5084            }
5085        });
5086    })();
5087
5088
5089    // Service Worker 登録(PWA インストール用)
5090    if ('serviceWorker' in navigator) {
5091      navigator.serviceWorker.register('/sw.js');
5092    }
5093
5094    // Barcode Scanner — native BarcodeDetector (Android) + zxing-wasm engine (iOS/その他)
5095    // iOS Safari は BarcodeDetector 非対応のため、自ホストの zxing-wasm(WebAssembly)で全端末を高精度カバー。
5096    (function(){
5097        var scanBtn = document.getElementById('barcodeScanBtn');
5098        var overlay = document.getElementById('scannerOverlay');
5099        var video = document.getElementById('scannerVideo');
5100        var closeBtn = document.getElementById('scannerClose');
5101        var canvas = document.getElementById('scannerCanvas');
5102        if (!scanBtn || !video || !canvas) return;
5103        // 追加トリガー(/dp インライン検索行)と連続スキャン UI(無ければ従来動作のまま)
5104        var inlineScanBtn = document.getElementById('inlineScanBtn');
5105        var modeToggle = document.getElementById('scanModeToggle');
5106        var scoutList = document.getElementById('scoutList');
5107        var scoutCount = document.getElementById('scoutCount');
5108        var scoutClear = document.getElementById('scoutClear');
5109        var sheet = document.getElementById('scoutSheet');
5110        var sheetFrame = document.getElementById('scoutSheetFrame');
5111        var sheetBack = document.getElementById('scoutSheetBack');
5112        var sheetExit = document.getElementById('scoutSheetExit');
5113
5114        var ZXING_BASE = '/js/zxing/';
5115        var ZXING_WASM_V = '310';                 // wasm キャッシュ用バージョン(zxing-wasm 3.1.0)
5116        var ZXING_FORMATS = ['EAN-13', 'EAN-8', 'UPC-A', 'UPC-E'];   // JAN/UPC のみ
5117        var ZXING_OPTS = { formats: ZXING_FORMATS, tryHarder: true, maxNumberOfSymbols: 1 };
5118
5119        var stream = null;
5120        var scanning = false;
5121        var rafId = null;
5122        var detector = null;          // native BarcodeDetector instance(利用可能時)
5123        var zxingRead = null;         // zxing-wasm の readBarcodes(遅延ロード)
5124        var useNative = false;
5125        var confirmBuffer = [];
5126        var CONFIRM_COUNT = 2;        // 連続同一コードで確定(checksum 検証済なので 2 で十分)
5127
5128        // ---- カメラストリーム再利用: 閉じても即 stop せず猶予内の再オープンで再権限要求を回避 ----
5129        var releaseTimer = null;
5130        var STREAM_GRACE_MS = 90000;  // 猶予(この間カメラは点いたまま。プライバシーとのバランス)
5131
5132        // ---- 連続スキャン(仕入れ)モード状態 ----
5133        var mode = 'single';
5134        try { if (localStorage.getItem('kaitorix_scan_mode') === 'scout') mode = 'scout'; } catch (e) {}
5135        var scoutHistory = loadScout();   // [{jan, name?, img?, p?, ts}] 新しい順・最大100件
5136        // 復帰直後の即再スキャン暴発対策:
5137        //   scanCooldownUntil = シートから戻った後この時刻まで全検出を無視(手の移動猶予)
5138        //   suppress = {code: 最終目撃ts} 直前にシート表示したコードは画面内に映り続ける限り再発火しない
5139        var scanCooldownUntil = 0;
5140        var suppress = {};
5141        var lastScanCode = '';
5142        var nameTimer = null;
5143        var sheetJan = '';
5144        var lastSnapTs = 0;
5145        // シート開閉直後のゴースト click 対策(touchend 後の遅延 click がシート消失で
5146        // 下層の履歴リスト等に着弾 → 意図せず再オープンするのを 600ms 抑止)
5147        var sheetMovedAt = 0;
5148        function sheetJustMoved() { return Date.now() - sheetMovedAt < 600; }
5149
5150        // EAN-8 / UPC-A / EAN-13(=JAN) の桁数のみ受理(誤検出ガード。チェックデジットは reader 側で検証済)
5151        function isValidCode(code) {
5152            if (window.KXBarcode && window.KXBarcode.isValidCode) return window.KXBarcode.isValidCode(code);
5153            return /^(\d{8}|\d{12,13})$/.test(code);
5154        }
5155
5156        function onDetected(raw) {
5157            var code = (raw || '').trim();
5158            if (!isValidCode(code)) { confirmBuffer = []; return; }
5159            confirmBuffer.push(code);
5160            if (confirmBuffer.length > CONFIRM_COUNT) confirmBuffer.shift();
5161            if (confirmBuffer.length === CONFIRM_COUNT &&
5162                confirmBuffer.every(function(c){ return c === code; })) {
5163                if (mode === 'scout') {
5164                    var now = Date.now();
5165                    // シートから戻った直後は全検出を無視(カメラがまだ棚/バーコード面を向いたまま
5166                    // → 何かしら即ヒットして勝手にシートが再オープンする暴発の防止)
5167                    if (now < scanCooldownUntil) {
5168                        if (suppress[code] !== undefined) suppress[code] = now;   // 目撃継続を記録
5169                        confirmBuffer = [];
5170                        return;
5171                    }
5172                    // 直前にシート表示したコード: 画面内に映り続ける限り発火しない(目撃ごとに ts 更新)。
5173                    // 2.5 秒画面外に出てから再度映った場合のã
5173¿ã€Œæ„å›³çš„な再スキャン」として許可。
5174                    if (suppress[code] !== undefined) {
5175                        if (now - suppress[code] < 2500) { suppress[code] = now; confirmBuffer = []; return; }
5176                        delete suppress[code];
5177                    }
5178                    if (navigator.vibrate) { try { navigator.vibrate(80); } catch (e) {} }
5179                    lastScanCode = code;
5180                    pauseLoop();          // カメラは生かしたまま検出ループだけ止める
5181                    scoutAdd(code);
5182                    openSheet(code);
5183                    return;
5184                }
5185                if (navigator.vibrate) { try { navigator.vibrate(80); } catch (e) {} }   // 命中時の触覚フィードバック
5186                closeScanner();
5187                window.location.href = '/dp/' + code;
5188            }
5189        }
5190
5191        // 検出ループのみ停止(ストリームは維持)
5192        function pauseLoop() {
5193            scanning = false;
5194            if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
5195            confirmBuffer = [];
5196        }
5197
5198        function streamLive() {
5199            try {
5200                return !!stream && stream.getVideoTracks().some(function(t){ return t.readyState === 'live'; });
5201            } catch (e) { return false; }
5202        }
5203
5204        // カメラを実際に解放(猶予タイマー満了・ページ離脱時のみ)
5205        function releaseStream() {
5206            if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null; }
5207            if (stream) { try { stream.getTracks().forEach(function(t){ t.stop(); }); } catch (e) {} stream = null; }
5208            video.srcObject = null;
5209        }
5210
5211        // オーバーレイを閉じる(ストリームは STREAM_GRACE_MS だけ温存 → 再オープン即時)
5212        function closeScanner() {
5213            pauseLoop();
5214            overlay.classList.remove('active');
5215            document.body.classList.remove('kx-scan-lock');
5216            closeSheet(false);
5217            if (releaseTimer) clearTimeout(releaseTimer);
5218            releaseTimer = setTimeout(releaseStream, STREAM_GRACE_MS);
5219        }
5220
5221        // ==================== 連続スキャン(仕入れ)モード ====================
5222        function loadScout() {
5223            try {
5224                var a = JSON.parse(localStorage.getItem('kaitorix_scan_history') || '[]');
5225                return Array.isArray(a) ? a : [];
5226            } catch (e) { return []; }
5227        }
5228        function saveScout() {
5229            try { localStorage.setItem('kaitorix_scan_history', JSON.stringify(scoutHistory.slice(0, 100))); } catch (e) {}
5230        }
5231        function api(ep) {
5232            return (window.KX_CORE && window.KX_CORE.apiCall)
5233                ? window.KX_CORE.apiCall(ep)
5234                : Promise.reject(new Error('KX_CORE unavailable'));
5235        }
5236
5237        function setMode(m) {
5238            mode = (m === 'scout') ? 'scout' : 'single';
5239            try { localStorage.setItem('kaitorix_scan_mode', mode); } catch (e) {}
5240            applyMode();
5241        }
5242        function applyMode() {
5243            overlay.classList.toggle('scout', mode === 'scout');
5244            if (modeToggle) {
5245                modeToggle.querySelectorAll('.scan-mode-btn').forEach(function(b){
5246                    var on = b.getAttribute('data-mode') === mode;
5247                    b.classList.toggle('is-active', on);
5248                    b.setAttribute('aria-selected', on ? 'true' : 'false');
5249                });
5250            }
5251            if (mode === 'scout' && overlay.classList.contains('active')) {
5252                renderScout();
5253                // 履歴の価格を一括リフレッシュ(30秒スロットル、先頭24件)
5254                var now = Date.now();
5255                if (scoutHistory.length && now - lastSnapTs > 30000) {
5256                    lastSnapTs = now;
5257                    fetchSnap(scoutHistory.map(function(e){ return e.jan; }));
5258                }
5259            }
5260        }
5261
5262        function scoutAdd(code) {
5263            var entry = null;
5264            for (var i = 0; i < scoutHistory.length; i++) {
5265                if (scoutHistory[i].jan === code) { entry = scoutHistory.splice(i, 1)[0]; break; }
5266            }
5267            if (!entry) entry = { jan: code };
5268            entry.ts = Date.now();
5269            scoutHistory.unshift(entry);
5270            if (scoutHistory.length > 100) scoutHistory.length = 100;
5271            saveScout();
5272            renderScout();
5273            fetchSnap([code]);
5274        }
5275
5276        // /api/price-snap で最高買取価格+画像を補完(KX_CORE 経由必須: 裸 fetch は本番で token 弾き)
5277        function fetchSnap(jans) {
5278            if (!jans || !jans.length) return;
5279            api('/api/price-snap?jans=' + jans.slice(0, 24).join(',')).then(function(d){
5280                if (!d) return;
5281                var dirty = false;
5282                scoutHistory.forEach(function(e){
5283                    var s = d[e.jan];
5284                    if (s) {
5285                        if (e.p !== s.p) { e.p = s.p || 0; dirty = true; }
5286                        if (s.img && !e.img) { e.img = s.img; dirty = true; }
5287                    } else if (jans.indexOf(e.jan) >= 0 && e.p === undefined) {
5288                        e.p = 0; dirty = true;   // DB 未登録 → 「価格なし」表示に確定
5289                    }
5290                });
5291                if (dirty) { saveScout(); renderScout(); }
5292            }).catch(function(){});
5293        }
5294
5295        function fmtScoutTime(ts) {
5296            var d = new Date(ts);
5297            return (d.getMonth() + 1) + '/' + d.getDate() + ' ' +
5298                ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
5299        }
5300
5301        function renderScout() {
5302            if (!scoutList) return;
5303            if (scoutCount) scoutCount.textContent = scoutHistory.length ? '(' + scoutHistory.length + ')' : '';
5304            scoutList.textContent = '';
5305            if (!scoutHistory.length) {
5306                var empty = document.createElement('div');
5307                empty.className = 'scout-empty';
5308                empty.textContent = 'スキャンした商品がここに追加されます';
5309                scoutList.appendChild(empty);
5310                return;
5311            }
5312            scoutHistory.forEach(function(e){
5313                var row = document.createElement('div');
5314                row.className = 'scout-item';
5315                row.setAttribute('data-jan', e.jan);
5316                row.setAttribute('role', 'button');
5317                row.tabIndex = 0;
5318                if (e.img) {
5319                    var im = document.createElement('img');
5320                    im.className = 'scout-item-img'; im.src = e.img; im.alt = ''; im.loading = 'lazy';
5321                    row.appendChild(im);
5322                } else {
5323                    var ph = document.createElement('div');
5324                    ph.className = 'scout-item-imgph';
5325                    ph.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" width="20" height="20"><path d="M3 7V5a2 2 0 0 1 2-2h2"></path><path d="M17 3h2a2 2 0 0 1 2 2v2"></path><path d="M21 17v2a2 2 0 0 1-2 2h-2"></path><path d="M7 21H5a2 2 0 0 1-2-2v-2"></path><line x1="7" y1="12" x2="17" y2="12"></line></svg>';
5326                    row.appendChild(ph);
5327                }
5328                var main = document.createElement('div');
5329                main.className = 'scout-item-main';
5330                var nm = document.createElement('div');
5331                nm.className = 'scout-item-name';
5332                nm.textContent = e.name || e.jan;
5333                main.appendChild(nm);
5334                var sub = document.createElement('div');
5335                sub.className = 'scout-item-sub';
5336                sub.textContent = (e.name ? 'JAN: ' + e.jan + ' ・ ' : '') + fmtScoutTime(e.ts);
5337                main.appendChild(sub);
5338                row.appendChild(main);
5339                var pr = document.createElement('div');
5340                if (e.p > 0) {
5341                    pr.className = 'scout-item-price';
5342                    pr.textContent = 'Â¥' + Number(e.p).toLocaleString();
5343                } else {
5344                    pr.className = 'scout-item-price none';
5345                    pr.textContent = (e.p === 0) ? '価格なし' : '';
5346                }
5347                row.appendChild(pr);
5348                var del = document.createElement('button');
5349                del.type = 'button';
5350                del.className = 'scout-item-del';
5351                del.setAttribute('data-del', e.jan);
5352                del.setAttribute('aria-label', '履歴から削除');
5353                del.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
5354                row.appendChild(del);
5355                scoutList.appendChild(row);
5356            });
5357        }
5358
5359        // ---- 商品詳細シート(同源 /dp を iframe 表示) ----
5360        function openSheet(jan) {
5361            if (!sheet || !sheetFrame) { window.location.href = '/dp/' + jan; return; }
5362            sheetJan = jan;
5363            if (sheetFrame.getAttribute('data-jan') !== jan) {
5364                sheetFrame.setAttribute('data-jan', jan);
5365                sheetFrame.src = '/dp/' + jan;
5366            } else {
5367                stripSheetChrome();   // 同一商品の再表示: 読込済みをそのまま使う
5368            }
5369            sheet.classList.add('open');
5370            sheet.setAttribute('aria-hidden', 'false');
5371            sheetMovedAt = Date.now();
5372        }
5373        function closeSheet(resume) {
5374            if (!sheet) return;
5375            var wasOpen = sheet.classList.contains('open');
5376            sheet.classList.remove('open');
5377            sheet.setAttribute('aria-hidden', 'true');
5378            if (wasOpen) sheetMovedAt = Date.now();
5379            if (nameTimer) { clearInterval(nameTimer); nameTimer = null; }
5380            if (resume && wasOpen && overlay.classList.contains('active') && mode === 'scout') resumeScan();
5381        }
5382        function resumeScan() {
5383            if (scanning) return;
5384            // 復帰暴発ガード: 1.5s 全検出無視 + 直前のシート対象コードを抑制リストに種まき
5385            var now = Date.now();
5386            scanCooldownUntil = now + 1500;
5387            if (lastScanCode) suppress[lastScanCode] = now;
5388            if (sheetJan) suppress[sheetJan] = now;
5389            if (!streamLive()) { startScan(); return; }   // 猶予切れ等で死んでいたら再取得
5390            scanning = true;
5391            confirmBuffer = [];
5392            (useNative ? nativeTick : zxingTick)();
5393        }
5394        // iframe 内のサイト chrome(ツールバー/タブバー/バナー等)を非表示にして商品情報に集中させる
5395        function stripSheetChrome() {
5396            try {
5397                var d = sheetFrame.contentDocument;
5398                if (!d || d.getElementById('kxScoutStrip')) return;
5399                var st = d.createElement('style');
5400                st.id = 'kxScoutStrip';
5401                st.textContent = '.sticky-toolbar,#kxTabbar,.kx-tabbar,#dpMiniSticky,#kxDealTicker,#heroCarousel,.hero-dots,.discord-fab,.x-fab,#discordFab,footer,#inlineScanBtn,#barcodeScanBtn{display:none!important}';
5402                (d.head || d.documentElement).appendChild(st);
5403            } catch (e) {}
5404        }
5405        // 商品名は iframe の /dp が描画したものを拾う(追加 API 消費ゼロ・レート制限に優しい)
5406        function pollSheetName() {
5407            if (nameTimer) clearInterval(nameTimer);
5408            var jan = sheetJan, tries = 0;
5409            var entry = null;
5410            for (var i = 0; i < scoutHistory.length; i++) {
5411                if (scoutHistory[i].jan === jan) { entry = scoutHistory[i]; break; }
5412            }
5413            if (!entry || entry.name) return;
5414            nameTimer = setInterval(function(){
5415                tries++;
5416                try {
5417                    var el = sheetFrame.contentDocument && sheetFrame.contentDocument.getElementById('productName');
5418                    var t = el ? (el.textContent || '').trim() : '';
5419                    if (t && t !== '-') {
5420                        entry.name = t;
5421                        saveScout(); renderScout();
5422                        clearInterval(nameTimer); nameTimer = null;
5423                        return;
5424                    }
5425                } catch (e) {}
5426                if (tries > 25) { clearInterval(nameTimer); nameTimer = null; }
5427            }, 400);
5428        }
5429
5430        // ROI は 1 種類では足りない(2026-08-08)。
5431        // Amazon のデバイス箱(Kindle 等)の UPC-A は **縦に潰れている**:実測 1618x258px=
5432        // 幅高比 6.3:1(標準 UPC-A は 1.47:1、つまり高ã
5432•が標準の 2 割強しかない)。
5433        // 全バーを横切れる走査線が極端に少ないため、少し傾いただけで解けなくなる。
5434        // May の実写真で「寄り具合 × ROI」18 通りを本番同一の zxing で回した実測:
5435        //   中央82%x60% だけ = 6 通り中 1 ヒット / 下記 3 種を回すと 3 ヒット(3 倍)
5436        //   ※ 近づき過ぎは左右の guard bar が枠外に出て**どの ROI でも解けない**=
5437        //     「横いっぱいに写す・寄り過ぎない」という案内も要る(UI 側)。
5438        // 3 種を**同一フレームで全部試すのではなくフレーム毎に回す**(iOS の発熱・電池対策。
5439        // 30fps なら 3 種を約 100ms で一巡し、体感は変わらない)。
5440        // 値の正本は /js/barcode-core.js(search.html のスキャナと共有)。
5441        // 🔴 フォールバック必須: core が読めなくてもスキャナを死なせない。
5442        var ROI_VARIANTS = (window.KXBarcode && window.KXBarcode.ROI_VARIANTS) || [
5443            { w: 0.82, h: 0.60 },   // 従来(普通の距離で効く)
5444            { w: 1.00, h: 1.00 },   // 全画面(引き気味で効く。実測でここだけ当たる取景がある)
5445            { w: 0.95, h: 0.72 }    // 広め(やや寄りで効く)
5446        ];
5447        var roiIdx = 0;
5448
5449        // ガイド枠中央の ROI を切り出して ImageData を返す(対象を大きく写しノイズを減らす)
5450        function grabROI(variant) {
5451            var vw = video.videoWidth, vh = video.videoHeight;
5452            if (!vw || !vh) return null;
5453            var v = ROI_VARIANTS[variant || 0] || ROI_VARIANTS[0];
5454            var cw = Math.max(1, Math.floor(vw * v.w));
5455            var ch = Math.max(1, Math.floor(vh * v.h));
5456            var sx = Math.floor((vw - cw) / 2), sy = Math.floor((vh - ch) / 2);
5457            canvas.width = cw; canvas.height = ch;
5458            var ctx = canvas.getContext('2d', { willReadFrequently: true });
5459            ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
5460            return ctx.getImageData(0, 0, cw, ch);
5461        }
5462
5463        // ---- ネイティブ BarcodeDetector ループ(Chrome Android)。解決後に次フレームを予約し重複呼び出しを防ぐ ----
5464        function nativeTick() {
5465            if (!scanning) return;
5466            if (video.readyState < 2) { rafId = requestAnimationFrame(nativeTick); return; }
5467            detector.detect(video).then(function(codes){
5468                if (codes && codes.length) onDetected(codes[0].rawValue);
5469            }).catch(function(){}).then(function(){
5470                if (scanning) rafId = requestAnimationFrame(nativeTick);
5471            });
5472        }
5473
5474        // ---- zxing-wasm ループ(iOS Safari ほか)。ROI の ImageData を WASM へ直接渡す ----
5475        function zxingTick() {
5476            if (!scanning) return;
5477            // フレーム毎に ROI 変種を回す(潰れたバーコード対策。上の ROI_VARIANTS 参照)
5478            var useIdx = roiIdx;
5479            roiIdx = (roiIdx + 1) % ROI_VARIANTS.length;
5480            var img = (video.readyState >= 2) ? grabROI(useIdx) : null;
5481            if (!img) { rafId = requestAnimationFrame(zxingTick); return; }
5482            zxingRead(img, ZXING_OPTS).then(function(results){
5483                if (results && results.length) {
5484                    for (var i = 0; i < results.length; i++) {
5485                        if (results[i] && results[i].text) { onDetected(results[i].text); break; }
5486                    }
5487                }
5488            }).catch(function(){}).then(function(){
5489                if (scanning) rafId = requestAnimationFrame(zxingTick);
5490            });
5491        }
5492
5493        // 連続オートフォーカスが使えるなら適用(近距離のバーコードがボケにくくなる)
5494        function applyTrackEnhancements() {
5495            try {
5496                var track = stream.getVideoTracks()[0];
5497                if (!track || !track.getCapabilities) return;
5498                var caps = track.getCapabilities();
5499                if (caps.focusMode && caps.focusMode.indexOf('continuous') >= 0) {
5500                    track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }).catch(function(){});
5501                }
5502            } catch (e) { /* 非対応端末は無視 */ }
5503        }
5504
5505        // getUserMedia は 3 段フォールバック+各 8 秒タイムアウト(端末差で固まらないように)
5506        function getCameraStream() {
5507            var withTimeout = function(constraints) {
5508                return Promise.race([
5509                    navigator.mediaDevices.getUserMedia(constraints),
5510                    new Promise(function(_, rej){ setTimeout(function(){ rej(new Error('timeout')); }, 8000); })
5511                ]);
5512            };
5513            return withTimeout({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false })
5514                .catch(function(){ return withTimeout({ video: { facingMode: 'environment' }, audio: false }); })
5515                .catch(function(){ return withTimeout({ video: true, audio: false }); });
5516        }
5517
5518        function startScan() {
5519            if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null; }
5520            var reuse = streamLive();   // 猶予内の再オープン → getUserMedia を再要求しない
5521            (reuse ? Promise.resolve(stream) : getCameraStream()).then(function(s) {
5522                stream = s;
5523                if (video.srcObject !== stream) video.srcObject = stream;
5524                if (video.play) { video.play().catch(function(){}); }
5525                overlay.classList.add('active');
5526                document.body.classList.add('kx-scan-lock');
5527                applyMode();
5528                scanning = true;
5529                confirmBuffer = [];
5530                if (!reuse) applyTrackEnhancements();
5531                var startLoop = function() { if (scanning) (useNative ? nativeTick : zxingTick)(); };
5532                if (video.readyState >= 2) startLoop();
5533                else video.addEventListener('loadeddata', startLoop, { once: true });
5534            }).catch(function() {
5535                alert('カメラを起動できませんでした。カメラの権限を確認してください。');
5536            });
5537        }
5538
5539        // zxing-wasm を遅延 import(クリック時のみ)。wasm は locateFile で自ホスト先に固定(CDN を踏まない)
5540        function loadZxing() {
5541            useNative = false;
5542            CONFIRM_COUNT = 2;
5543            if (zxingRead) { startScan(); return; }
5544            import(ZXING_BASE + 'reader/index.js').then(function(mod){
5545                if (mod.prepareZXingModule) {
5546                    mod.prepareZXingModule({
5547                        overrides: {
5548                            locateFile: function(path, prefix){
5549                                return /\.wasm$/.test(path) ? (ZXING_BASE + 'zxing_reader.wasm?v=' + ZXING_WASM_V) : (prefix + path);
5550                            }
5551                        }
5552                    });
5553                }
5554                zxingRead = mod.readBarcodes;
5555                startScan();
5556            }).catch(function(){
5557                alert('スキャナーの読み込みに失敗しました。通信環境をご確認ください。');
5558            });
5559        }
5560
5561        function begin() {
5562            // Android Chrome 等はネイティブ BarcodeDetector を優先(即時・ダウンロード不要)。
5563            // iOS Safari は BarcodeDetector 非対応 → zxing-wasm にフォールバック。
5564            if ('BarcodeDetector' in window && window.BarcodeDetector.getSupportedFormats) {
5565                window.BarcodeDetector.getSupportedFormats().then(function(supported){
5566                    var want = ['ean_13', 'ean_8', 'upc_a', 'upc_e'];
5567                    var fmts = want.filter(function(f){ return supported.indexOf(f) >= 0; });
5568                    if (fmts.length) {
5569                        detector = new window.BarcodeDetector({ formats: fmts });
5570                        useNative = true;
5571                        CONFIRM_COUNT = 2;
5572                        startScan();
5573                    } else {
5574                        loadZxing();
5575                    }
5576                }).catch(loadZxing);
5577                return;
5578            }
5579            loadZxing();
5580        }
5581
5582        scanBtn.addEventListener('click', begin);
5583        if (inlineScanBtn) inlineScanBtn.addEventListener('click', begin);
5584        closeBtn.addEventListener('click', function(){ if (!sheetJustMoved()) closeScanner(); });
5585
5586        // モード切替ピル
5587        if (modeToggle) modeToggle.addEventListener('click', function(ev){
5588            var b = ev.target.closest('[data-mode]');
5589            if (b) setMode(b.getAttribute('data-mode'));
5590        });
5591
5592        // 履歴リスト: 行タップ=シート再表示 / ×=個別削除(委譲)
5593        if (scoutList) {
5594            scoutList.addEventListener('click', function(ev){
5595                if (sheetJustMoved()) return;   // シートを閉じた直後のゴースト click で再オープンしない
5596                var del = ev.target.closest('[data-del]');
5597                if (del) {
5598                    ev.stopPropagation();
5599                    var j = del.getAttribute('data-del');
5600                    scoutHistory = scoutHistory.filter(function(e){ return e.jan !== j; });
5601                    saveScout(); renderScout();
5602                    return;
5603                }
5604                var row = ev.target.closest('.scout-item');
5605                if (row) { pauseLoop(); openSheet(row.getAttribute('data-jan')); }
5606            });
5607            scoutList.addEventListener('keydown', function(ev){
5608                if (ev.key !== 'Enter' && ev.key !== ' ') return;
5609                var row = ev.target.closest && ev.target.closest('.scout-item');
5610                if (row) { ev.preventDefault(); pauseLoop(); openSheet(row.getAttribute('data-jan')); }
5611            });
5612        }
5613        if (scoutClear) scoutClear.addEventListener('click', function(){
5614            if (!scoutHistory.length) return;
5615            if (!confirm('スキャン履歴をすべて削除しますか?')) return;
5616            scoutHistory = [];
5617            saveScout(); renderScout();
5618        });
5619
5620        // シート: 閉じたら即スキャン再開(カメラは生きたまま)/終了=連続スキャンごと退出
5621        // 開いた直後 600ms も抑止(履歴行タップのゴースト click がシート底部バーに着弾する逆方向対策)
5622        if (sheetBack) sheetBack.addEventListener('click', function(){ if (!sheetJustMoved()) closeSheet(true); });
5623        if (sheetExit) sheetExit.addEventListener('click', function(){ if (!sheetJustMoved()) closeScanner(); });
5624        if (sheetFrame) sheetFrame.addEventListener('load', function(){
5625            stripSheetChrome();
5626            // iframe 内で別商品へ遷移した場合に追従(同一商品再表示の判定がズレないように)
5627            try {
5628                var m = (sheetFrame.contentWindow.location.pathname || '').match(/^\/dp\/(\d+)/);
5629                if (m) sheetFrame.setAttribute('data-jan', m[1]);
5630            } catch (e) {}
5631            pollSheetName();
5632        });
5633
5634        document.addEventListener('keydown', function(ev){
5635            if (ev.key !== 'Escape') return;
5636            if (sheet && sheet.classList.contains('open')) closeSheet(true);
5637            else if (overlay.classList.contains('active')) closeScanner();
5638        });
5639
5640        // ページ離脱・バックグラウンド化でカメラを確実に解放
5641        window.addEventListener('pagehide', releaseStream);
5642        document.addEventListener('visibilitychange', function(){
5643            if (document.hidden && !overlay.classList.contains('active')) releaseStream();
5644        });
5645
5646        applyMode();   // 保存済みモードをトグル表示へ反映
5647
5648        // ==================== 印 注記ツールチップ(viewport クランプ + タップ対応) ====================
5649        // 旧 CSS .store-mark:hover::after はチップ右端でビューポート外に見切れ、モバイルのタップでも残留した。
5650        // body 直下の単一要素を position:fixed で配置し、左右を画面内にクランプ、上に収まらなければ下へ反転ã
5650™ã‚‹ã€‚
5651        (function initStoreMarkTip() {
5652            let tipEl = null;
5653            let activeMark = null;
5654            const isTouch = window.matchMedia('(hover: none), (pointer: coarse)').matches;
5655
5656            function ensureTip() {
5657                if (tipEl) return tipEl;
5658                tipEl = document.createElement('div');
5659                tipEl.className = 'store-mark-tip';
5660                tipEl.setAttribute('role', 'tooltip');
5661                document.body.appendChild(tipEl);
5662                return tipEl;
5663            }
5664
5665            function hideTip() {
5666                if (tipEl) tipEl.classList.remove('visible');
5667                activeMark = null;
5668            }
5669
5670            function showTip(mark) {
5671                const text = mark.getAttribute('data-tip');
5672                if (!text) return;
5673                const tip = ensureTip();
5674                tip.textContent = text;
5675                activeMark = mark;
5676                // 計測のため一旦表示(左上に置いて offsetWidth/Height を取得)
5677                tip.style.left = '0px';
5678                tip.style.top = '0px';
5679                tip.classList.add('visible');
5680                const margin = 12;
5681                const vw = document.documentElement.clientWidth;
5682                const vh = document.documentElement.clientHeight;
5683                const r = mark.getBoundingClientRect();
5684                const tw = tip.offsetWidth;
5685                const th = tip.offsetHeight;
5686                // 水平: チップ中心に合わせ、左右マージン内にクランプ(画面外に出さない)
5687                let left = r.left + r.width / 2 - tw / 2;
5688                left = Math.max(margin, Math.min(left, vw - tw - margin));
5689                // 垂直: 既定はチップ上。収まらなければ下へ反転
5690                let top = r.top - th - 8;
5691                let below = false;
5692                if (top < margin) { top = r.bottom + 8; below = true; }
5693                if (below && top + th > vh - margin) top = Math.max(margin, vh - th - margin);
5694                tip.style.left = Math.round(left) + 'px';
5695                tip.style.top = Math.round(top) + 'px';
5696                tip.classList.toggle('below', below);
5697                // 矢印はチップ中心を指す(tooltip 左端基準・両端 12px でクランプ)
5698                let arrowX = r.left + r.width / 2 - left;
5699                arrowX = Math.max(12, Math.min(arrowX, tw - 12));
5700                tip.style.setProperty('--arrow-x', Math.round(arrowX) + 'px');
5701            }
5702
5703            // PC: hover で表示(デリゲーション。mouseenter は bubble しないため mouseover を使う)
5704            document.addEventListener('mouseover', function(e) {
5705                if (isTouch) return;
5706                const mark = e.target.closest && e.target.closest('.store-mark');
5707                if (mark) showTip(mark);
5708            });
5709            document.addEventListener('mouseout', function(e) {
5710                if (isTouch) return;
5711                const mark = e.target.closest && e.target.closest('.store-mark');
5712                if (mark && mark === activeMark) {
5713                    const to = e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.store-mark');
5714                    if (to !== mark) hideTip();
5715                }
5716            });
5717            // モバイル/タップ: トグル表示。外側タップで閉じる
5718            document.addEventListener('click', function(e) {
5719                const mark = e.target.closest && e.target.closest('.store-mark');
5720                if (mark) {
5721                    e.stopPropagation();
5722                    if (mark === activeMark) hideTip();
5723                    else showTip(mark);
5724                } else {
5725                    hideTip();
5726                }
5727            });
5728            document.addEventListener('keydown', function(e) { if (e.key === 'Escape') hideTip(); });
5729            window.addEventListener('scroll', hideTip, true);
5730            window.addEventListener('resize', hideTip);
5731        })();
5732    })();
5733

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.