PageSourceSearch

https://ojiya-chijimi.jp/wp-content/plugins/iono-items/iono-items.js?ver=1.33

js ojiya-chijimi.jp collected 2026-10-02 20:10:24 UTC 52,227 bytes, 1,771 lines download raw bytes

1/**
2 * IONO Items JS
3 * Version: 2.2.0
4 *
5 * - 初回表示はPHP
6 * - 初期条件は data-initial-json で保持
7 * - フォームにない初期条件を消さない
8 * - フォームで触った項目だけ activeParams に上書き
9 * - more: Ajaxでもっと見る
10 * - pager: URLパラメータ付きリンクでページ移動
11 * - 検索後URLを書き換えて共有可能にする
12 * - 戻るボタンでHTML/スクロール/currentPage/activeParams復元
13 */
14
15(function () {
16  'use strict';
17
18  const DEFAULT_API = 'https://ojiya-chijimi.jp/wp-json/iono/v1/items';
19  const DEBUG = false;
20
21  const STATE_KEY = 'iono_items_state_v20';
22  const STATE_EXPIRE = 30 * 60 * 1000;
23
24  let currentPage = 1;
25  let isLoading = false;
26  let searchTimer = null;
27
28  let initialParams = {};
29  let activeParams = {};
30  let dirtyFields = {};
31
32  // 現在DOMに表示されている条件のURL。pushState/popstate判定に使う。
33  let renderedUrl = location.href;
34
35  if ('scrollRestoration' in history) {
36    history.scrollRestoration = 'manual';
37  }
38
39  function debug(message, data) {
40    if (!DEBUG) return;
41
42    let box = document.getElementById('ionoDebugBox');
43
44    if (!box) {
45      box = document.createElement('div');
46      box.id = 'ionoDebugBox';
47      box.style.cssText = [
48        'margin:15px 0',
49        'padding:12px',
50        'border:2px solid #d00',
51        'background:#fff5f5',
52        'color:#111',
53        'font-size:13px',
54        'line-height:1.5',
55        'white-space:pre-wrap',
56        'overflow:auto',
57        'max-height:500px',
58        'z-index:99999'
59      ].join(';');
60
61      const searchBox = document.getElementById('ionoSearchBox');
62
63      if (searchBox) {
64        searchBox.insertAdjacentElement('afterend', box);
65      } else {
66        document.body.prepend(box);
67      }
68    }
69
70    box.textContent =
71      '[' + new Date().toLocaleTimeString() + '] ' + message + '\n\n' +
72      (data !== undefined ? JSON.stringify(data, null, 2) : '');
73  }
74
75  function esc(str) {
76    return String(str ?? '').replace(/[&<>"']/g, function (m) {
77      return {
78        '&': '&amp;',
79        '<': '&lt;',
80        '>': '&gt;',
81        '"': '&quot;',
82        "'": '&#039;'
83      }[m];
84    });
85  }
86
87  function clone(obj) {
88    return JSON.parse(JSON.stringify(obj || {}));
89  }
90
91  const PUBLIC_TO_INTERNAL = {
92    icat: 'cat',
93    str: 'keyword',
94    icolor: 'colors',
95    pmin: 'min_price',
96    pmax: 'max_price',
97    psize: 'limit',
98    npage: 'paged',
99    iclass: 'class_name',
100    class_name: 'class_name',
101    exclude_codes: 'exclude_codes',
102    tax: 'tax_mode',
103    taxrate: 'tax_rate',
104    fields: 'display_fields'
105  };
106
107  const INTERNAL_TO_PUBLIC = {
108    cat: 'icat',
109    keyword: 'str',
110    colors: 'icolor',
111    min_price: 'pmin',
112    max_price: 'pmax',
113    orderby: 'orderby',
114    limit: 'psize',
115    tag: 'tag',
116    class_name: 'iclass',
117    exclude_codes: 'exclude_codes',
118    tax_mode: 'tax',
119    tax_rate: 'taxrate',
120    display_fields: 'fields'
121  };
122
123  function publicNameToInternal(name) {
124    return PUBLIC_TO_INTERNAL[name] || name;
125  }
126
127  function normalizeOrderby(value) {
128    value = String(value || '').trim();
129    if (value === 'sales' || value === 'sales_order' || value === '人気順' || value === '人気順' || value === '人気順') return 'sales_amount';
130    return value;
131  }
132
133  function publicOrderby(value) {
134    value = normalizeOrderby(value);
135    if (value === 'sales_amount') return 'sales';
136    return value;
137  }
138
139
140  function normalizeParams(params) {
141    const out = {};
142
143    Object.keys(params || {}).forEach(function (key) {
144      const internalKey = publicNameToInternal(key);
145      out[internalKey] = params[key];
146    });
147
148    if (out.category && !out.cat) out.cat = out.category;
149    if (out.s && !out.keyword) out.keyword = out.s;
150    if (out.orderby) out.orderby = normalizeOrderby(out.orderby);
151
152    return out;
153  }
154
155  function isSalesOrder(params) {
156    params = normalizeParams(params || {});
157    return ['sales_amount', 'sales_qty'].includes(normalizeOrderby(params.orderby || ''));
158  }
159
160  function isSalesRankingFirstPage(params) {
161    params = normalizeParams(params || {});
162    const page = parseInt(params.paged || params.npage || 1, 10) || 1;
163    return isSalesOrder(params) && page === 1;
164  }
165
166  function getSalesRankStart(params) {
167    params = normalizeParams(params || {});
168
169    if (!isSalesOrder(params)) return 0;
170
171    const page = parseInt(params.paged || params.npage || 1, 10) || 1;
172    if (page <= 1) return 1;
173
174    const limit = Math.max(1, parseInt(params.limit || params.psize || 24, 10) || 24);
175    return 11 + ((page - 2) * limit);
176  }
177
178  function renderRankingItems(items) {
179    if (!Array.isArray(items) || !items.length) {
180      return '<div class="iono-items-empty">商品が見つかりませんでした。</div>';
181    }
182
183    return [
184      '<section class="iono-ranking-section" aria-label="人気商品ランキング">',
185        '<div class="iono-ranking-heading">',
186          '<span class="iono-ranking-heading-sub">BEST SELLERS</span>',
187          '<div class="iono-ranking-title">人気ランキング TOP10</div>',
188        '</div>',
189        '<div class="iono-ranking-list">',
190          items.map(function (item, index) {
191            const rank = index + 1;
192            const mainImage = item.ranking_image || item.image || '';
193            const subImage = item.ranking_hover_image || '';
194            const hasSubImage = !!subImage && subImage !== mainImage;
195
196            let priceHtml = '';
197            if (item.price_label) {
198              if (item.is_sale && item.regular_price_label) {
199                priceHtml = '<div class="iono-item-price iono-item-price-sale">' +
200                  '<span class="iono-item-regular-price">' + esc(item.regular_price_label) + '</span>' +
201                  '<span class="iono-item-sale-price">' + esc(item.price_label) + '</span>' +
202                '</div>';
203              } else {
204                priceHtml = '<div class="iono-item-price">' + esc(item.price_label) + '</div>';
205              }
206            }
207
208            return [
209              '<a class="iono-ranking-card iono-ranking-rank-' + rank + '" href="' + esc(item.url) + '">',
210                '<div class="iono-ranking-rank" aria-label="人気ランキング第' + rank + '位">',
211                  '<span class="iono-ranking-rank-number">' + rank + '</span>',
212                  '<span class="iono-ranking-rank-unit">位</span>',
213                '</div>',
214                '<div class="iono-ranking-images ' + (hasSubImage ? 'has-sub-image' : 'is-single-image') + '">',
215                  mainImage ? '<div class="iono-ranking-image iono-ranking-image-main"><img src="' + esc(mainImage) + '" alt="' + esc(item.name) + '" loading="' + (rank === 1 ? 'eager' : 'lazy') + '"></div>' : '',
216                  hasSubImage ? '<div class="iono-ranking-image iono-ranking-image-sub"><img src="' + esc(subImage) + '" alt="' + esc(item.name) + ' 別画像" loading="lazy"></div>' : '',
217                '</div>',
218                '<div class="iono-ranking-body">',
219                  '<div class="iono-ranking-label">人気ランキング 第' + rank + '位</div>',
220                  item.code ? '<div class="iono-item-code">' + esc(item.code) + '</div>' : '',
221                  '<div class="iono-ranking-name">' + esc(item.name) + '</div>',
222                  priceHtml,
223                  Array.isArray(item.display_fields) && item.display_fields.length ? '<div class="iono-ranking-meta-list">' + item.display_fields.map(function (field) {
224                    return '<div class="iono-item-meta iono-item-meta-' + esc(field.key || '') + '">' + esc(field.label || '') + ':' + esc(field.value || '') + '</div>';
225                  }).join('') + '</div>' : '',
226                  '<span class="iono-ranking-detail">商品詳細を見る</span>',
227                '</div>',
228              '</a>'
229            ].join('');
230          }).join(''),
231        '</div>',
232      '</section>'
233    ].join('');
234  }
235
236  function renderItems(items, params) {
237    if (!Array.isArray(items) || !items.length) {
238      return '<div class="iono-items-empty">商品が見つかりませんでした。</div>';
239    }
240
241    if (isSalesRankingFirstPage(params)) {
242      return renderRankingItems(items);
243    }
244
245    const columns = Math.max(1, parseInt((params && params.columns) || 4, 10) || 4);
246    const rankStart = getSalesRankStart(params);
247    const showNormalRanks = rankStart > 10;
248
249    return [
250      '<div class="iono-items-grid iono-cols-' + columns + (showNormalRanks ? ' iono-items-ranked-grid' : '') + '">',
251      items.map(function (item, index) {
252        const rank = showNormalRanks ? rankStart + index : 0;
253
254        return [
255          '<a class="iono-item-card' + (rank ? ' iono-item-card-ranked' : '') + '" href="' + esc(item.url) + '">',
256            rank ? '<div class="iono-item-rank-badge" aria-label="人気ランキング第' + rank + '位"><span class="iono-item-rank-number">' + rank + '</span><span class="iono-item-rank-unit">位</span></div>' : '',
257            '<div class="iono-item-image">',
258              item.image ? [
259                '<img',
260                  ' src="' + esc(item.image) + '"',
261                  ' data-main="' + esc(item.image) + '"',
262                  ' data-hover="' + esc(item.hover_image || item.image) + '"',
263                  ' alt="' + esc(item.name) + '"',
264                  ' loading="lazy"',
265                '>'
266              ].join('') : '',
267            '</div>',
268            '<div class="iono-item-body">',
269              item.code ? '<div class="iono-item-code">' + esc(item.code) + '</div>' : '',
270              '<div class="iono-item-name">' + esc(item.name) + '</div>',
271              item.price_label ? (
272                item.is_sale && item.regular_price_label
273                  ? '<div class="iono-item-price iono-item-price-sale">' +
274                      '<span class="iono-item-regular-price">' + esc(item.regular_price_label) + '</span>' +
275                      '<span class="iono-item-sale-price">' + esc(item.price_label) + '</span>' +
276                    '</div>'
277                  : '<div class="iono-item-price">' + esc(item.price_label) + '</div>'
278              ) : '',
279              Array.isArray(item.display_fields) ? item.display_fields.map(function (field) {
280                return '<div class="iono-item-meta iono-item-meta-' + esc(field.key || '') + '">' + esc(field.label || '') + ':' + esc(field.value || '') + '</div>';
281              }).join('') : '',
282            '</div>',
283          '</a>'
284        ].join('');
285      }).join(''),
286      '</div>'
287    ].join('');
288  }
289
290  function bindHover(root) {
291    if (!root) return;
292
293    root.querySelectorAll('img[data-hover]').forEach(function (img) {
294      img.onmouseenter = function () {
295        if (img.dataset.hover) {
296          img.src = img.dataset.hover;
297        }
298      };
299
300      img.onmouseleave = function () {
301        if (img.dataset.main) {
302          img.src = img.dataset.main;
303        }
304      };
305    });
306  }
307
308  function resetHoverImages(root) {
309    if (!root) return;
310
311    root.querySelectorAll('img[data-main]').forEach(function (img) {
312      if (img.dataset.main) {
313        img.src = img.dataset.main;
314      }
315    });
316  }
317
318  function parseInitialParams(box) {
319    let params = {};
320
321    if (box.dataset.initialJson) {
322      try {
323        params = JSON.parse(box.dataset.initialJson);
324      } catch (e) {
325        debug('data-initial-json がJSONとして読めません', {
326          initialJson: box.dataset.initialJson,
327          error: e.message
328        });
329        params = {};
330      }
331    }
332
333    delete params.return;
334    delete params.debug_html;
335
336    if (!params.orderby) params.orderby = 'recommend';
337    if (!params.order) params.order = 'DESC';
338    if (!params.limit) params.limit = 24;
339    if (!params.columns) params.columns = 4;
340    if (!params.format) params.format = 'html';
341    if (!params.tax_mode) params.tax_mode = 'tax_in';
342
343    return normalizeParams(params);
344  }
345
346  function getFormState(box) {
347    const state = {};
348
349    if (!box) return state;
350
351    box.querySelectorAll('input, select, textarea').forEach(function (el) {
352      if (!el.name) return;
353
354      if (el.type === 'checkbox') {
355        if (!state[el.name]) state[el.name] = [];
356        if (el.checked) state[el.name].push(el.value);
357        return;
358      }
359
360      if (el.type === 'radio') {
361        if (el.checked) state[el.name] = el.value;
362        return;
363      }
364
365      state[el.name] = publicNameToInternal(el.name) === 'orderby' ? publicOrderby(el.value) : el.value;
366    });
367
368    return state;
369  }
370
371  function restoreFormState(box, formState) {
372    if (!box || !formState) return;
373
374    box.querySelectorAll('input, select, textarea').forEach(function (el) {
375      if (!el.name) return;
376
377      let value = formState[el.name];
378
379      if (value === undefined) return;
380      if (publicNameToInternal(el.name) === 'orderby') value = publicOrderby(value);
381
382      if (el.type === 'checkbox') {
383        el.checked = Array.isArray(value)
384          ? value.includes(el.value)
385          : String(value) === String(el.value);
386        return;
387      }
388
389      if (el.type === 'radio') {
390        el.checked = String(value) === String(el.value);
391        return;
392      }
393
394      el.value = value;
395    });
396  }
397
398  function getFieldValue(box, name) {
399    const publicName = name;
400    const internalName = publicNameToInternal(name);
401    let els = Array.from(box.querySelectorAll('[name="' + publicName + '"]'));
402
403    if (!els.length && publicName !== internalName) {
404      els = Array.from(box.querySelectorAll('[name="' + internalName + '"]'));
405    }
406
407    if (!els.length) return '';
408
409    const first = els[0];
410
411    if (first.type === 'checkbox') {
412      return els
413        .filter(function (el) {
414          return el.checked;
415        })
416        .map(function (el) {
417          return el.value;
418        })
419        .join(',');
420    }
421
422    if (first.type === 'radio') {
423      const checked = els.find(function (el) {
424        return el.checked;
425      });
426
427      return checked ? checked.value : '';
428    }
429
430    return first.value;
431  }
432
433  function markFieldDirtyAndUpdate(box, name) {
434    if (!name) return;
435
436    const internalName = publicNameToInternal(name);
437
438    dirtyFields[internalName] = true;
439
440    const value = getFieldValue(box, name);
441
442    /**
443     * 触った項目だけ上書き。
444     * フォームに存在しない初期条件は activeParams に残る。
445     */
446    activeParams[internalName] = internalName === 'orderby' ? normalizeOrderby(value) : value;
447
448    if (!activeParams.orderby) activeParams.orderby = 'recommend';
449    if (!activeParams.order) activeParams.order = 'DESC';
450    if (!activeParams.limit) activeParams.limit = 24;
451    if (!activeParams.columns) activeParams.columns = 4;
452    if (!activeParams.format) activeParams.format = 'html';
453    if (!activeParams.tax_mode) activeParams.tax_mode = 'tax_in';
454  }
455
456  function buildRequestParams() {
457    const params = normalizeParams(clone(activeParams));
458
459    params.paged = currentPage;
460    params.format = params.format || 'html';
461
462    delete params.return;
463    delete params.debug_html;
464
465    return params;
466  }
467
468  function cleanParamsForUrl(params) {
469    const clean = {};
470    const normalized = normalizeParams(params || {});
471
472    Object.keys(INTERNAL_TO_PUBLIC).forEach(function (key) {
473      let val = normalized[key];
474      if (val === undefined || val === null || val === '') return;
475
476      // 税込10%はデフォルト扱いなのでURLには出さない。
477      if (key === 'tax_mode') {
478        const tv = String(val).trim();
479        if (tv === '' || tv === 'tax_in') return;
480      }
481      if (key === 'tax_rate') {
482        const rv = String(val).trim();
483        const rn = parseFloat(rv);
484        if (rv === '' || rv === '0.10' || rv === '0.1' || rv === '10' || Math.abs(rn - 0.10) < 0.000001 || Math.abs(rn - 10) < 0.000001) return;
485      }
486
487      if (key === 'orderby') val = publicOrderby(val);
488      clean[INTERNAL_TO_PUBLIC[key]] = val;
489    });
490
491    return clean;
492  }
493
494  function buildListUrl(params, pageNo, navMode) {
495    const url = new URL(location.href);
496  
497    const removeKeys = [
498      'icat', 'str', 'icolor', 'pmin', 'pmax', 'orderby', 'psize', 'npage', 'tag', 'iclass', 'class_name', 'exclude_codes', 'tax', 'taxrate', 'fields',
499      'cat', 'category', 'codes', 'exclude_codes', 'keyword', 'colors', 'materials',
500      'min_price', 'max_price', 'order', 'limit', 'columns',
501      'include_desc', 'page_no', 'paged', 'nav_mode',
502      'iono_cat', 'iono_category', 'iono_codes', 'iono_exclude_codes', 'iono_tag', 'iono_keyword',
503      'iono_colors', 'iono_materials', 'iono_min_price', 'iono_max_price',
504      'iono_orderby', 'iono_order', 'iono_limit', 'iono_columns',
505      'iono_include_desc', 'iono_page', 'iono_nav'
506    ];
507  
508    removeKeys.forEach(function (key) {
509      url.searchParams.delete(key);
510    });
511  
512    const clean = cleanParamsForUrl(params);
513  
514    Object.keys(clean).forEach(function (key) {
515      url.searchParams.set(key, clean[key]);
516    });
517  
518    url.searchParams.set('npage', String(pageNo || 1));
519
520    // 税込10%は内部デフォルト扱い。ページャー/URL同期では絶対に出さない。
521    ['tax', 'taxrate', 'tax_mode', 'tax_rate'].forEach(function (key) {
522      url.searchParams.delete(key);
523    });
524  
525    return url.toString();
526  }  
527
528  
529
530  function getPagerElements() {
531    const pagers = Array.from(document.querySelectorAll('.ionoPager'));
532
533    /**
534     * 旧HTML互換。id=ionoPager が残っていても動くようにする。
535     */
536    const oldPager = document.getElementById('ionoPager');
537    if (oldPager && !pagers.includes(oldPager)) {
538      pagers.push(oldPager);
539    }
540
541    return pagers;
542  }
543
544  function clearPagers() {
545    getPagerElements().forEach(function (pager) {
546      pager.innerHTML = '';
547    });
548  }
549
550  const IONO_HISTORY_PARAM_KEYS = [
551    'icat', 'str', 'icolor', 'pmin', 'pmax', 'orderby', 'psize', 'npage',
552    'tag', 'iclass', 'class_name', 'exclude_codes', 'tax', 'taxrate', 'fields',
553    'cat', 'category', 'codes', 'keyword', 'colors', 'materials',
554    'min_price', 'max_price', 'order', 'limit', 'columns',
555    'include_desc', 'page_no', 'paged', 'nav_mode'
556  ];
557
558  function hasIonoListUrlParams(urlValue) {
559    let url;
560
561    try {
562      url = urlValue instanceof URL
563        ? urlValue
564        : new URL(urlValue || location.href, location.href);
565    } catch (e) {
566      return false;
567    }
568
569    return Array.from(url.searchParams.keys()).some(function (key) {
570      return key.indexOf('iono_') === 0 || IONO_HISTORY_PARAM_KEYS.includes(key);
571    });
572  }
573
574  function mergedHistoryState(extra) {
575    const current = history.state && typeof history.state === 'object'
576      ? history.state
577      : {};
578
579    return Object.assign({}, current, extra || {});
580  }
581
582  /**
583   * パラメーターなしカテゴリを履歴の基点として印を付ける。
584   * URLを直接開いた場合は、そのURLをフィルター済みの履歴として扱う。
585   */
586  function initializeIonoHistoryEntry() {
587    if (!history || !history.replaceState) return;
588
589    renderedUrl = location.href;
590
591    const hasParams = hasIonoListUrlParams(location.href);
592    const current = history.state && typeof history.state === 'object'
593      ? history.state
594      : {};
595
596    if (hasParams) {
597      if (!current.ionoItemsFiltered) {
598        history.replaceState(mergedHistoryState({
599          ionoItemsFiltered: true,
600          ionoItemsBase: false,
601          ionoItemsHasBase: false
602        }), '', location.href);
603      }
604      return;
605    }
606
607    if (!current.ionoItemsBase) {
608      history.replaceState(mergedHistoryState({
609        ionoItemsBase: true,
610        ionoItemsFiltered: false,
611        ionoItemsHasBase: false
612      }), '', location.href);
613    }
614  }
615
616  /**
617   * 最初の条件変更だけpushStateし、以後の変更はreplaceStateする。
618   * これにより「戻る」1回でパラメーターなしカテゴリへ戻れる。
619   */
620  function updateUrl(params, pageNo, navMode) {
621    if (!history || !history.replaceState) return;
622
623    const newUrl = buildListUrl(params, pageNo, navMode);
624
625    if (newUrl === location.href) {
626      renderedUrl = newUrl;
627      return;
628    }
629
630    const current = history.state && typeof history.state === 'object'
631      ? history.state
632      : {};
633
634    const shouldPush = !!history.pushState &&
635      !hasIonoListUrlParams(location.href) &&
636      !current.ionoItemsFiltered;
637
638    const nextState = mergedHistoryState({
639      ionoItemsFiltered: true,
640      ionoItemsBase: false,
641      ionoItemsHasBase: shouldPush || !!current.ionoItemsHasBase
642    });
643
644    if (shouldPush) {
645      history.pushState(nextState, '', newUrl);
646    } else {
647      history.replaceState(nextState, '', newUrl);
648    }
649
650    renderedUrl = newUrl;
651  }
652
653  function clearIonoUrlParams() {
654    if (!history || !history.replaceState) return;
655
656    const url = new URL(location.href);
657    Array.from(url.searchParams.keys()).forEach(function (key) {
658      if (key.indexOf('iono_') === 0 || IONO_HISTORY_PARAM_KEYS.includes(key)) {
659        url.searchParams.delete(key);
660      }
661    });
662
663    const cleanUrl = url.toString();
664
665    history.replaceState(mergedHistoryState({
666      ionoItemsBase: true,
667      ionoItemsFiltered: false,
668      ionoItemsHasBase: false
669    }), '', cleanUrl);
670
671    renderedUrl = cleanUrl;
672  }
673
674  function renderPager(params, pageNo, data, navMode) {
675    const pagers = getPagerElements();
676    if (!pagers.length) return;
677
678    const totalPages = data && data.max_pages
679      ? parseInt(data.max_pages, 10)
680      : 0;
681
682    if (!totalPages || totalPages <= 1) {
683      clearPagers();
684      return;
685    }
686
687    pageNo = parseInt(pageNo || 1, 10) || 1;
688
689    let html = '<div class="iono-pager">';
690
691    if (pageNo > 1) {
692      html += '<a class="iono-page-link iono-page-prev" href="' + esc(buildListUrl(params, pageNo - 1, navMode)) + '">前へ</a>';
693    }
694
695    const maxLinks = 9;
696    let start = Math.max(1, pageNo - 4);
697    let end = Math.min(totalPages, start + maxLinks - 1);
698
699    if (end - start + 1 < maxLinks) {
700      start = Math.max(1, end - maxLinks + 1);
701    }
702
703    if (start > 1) {
704      html += '<a class="iono-page-link" href="' + esc(buildListUrl(params, 1, navMode)) + '">1</a>';
705      if (start > 2) {
706        html += '<span class="iono-page-dots">...</span>';
707      }
708    }
709
710    for (let i = start; i <= end; i++) {
711      if (i === pageNo) {
712        html += '<span class="iono-page-current">' + i + '</span>';
713      } else {
714        html += '<a class="iono-page-link" href="' + esc(buildListUrl(params, i, navMode)) + '">' + i + '</a>';
715      }
716    }
717
718    if (end < totalPages) {
719      if (end < totalPages - 1) {
720        html += '<span class="iono-page-dots">...</span>';
721      }
722
723      html += '<a class="iono-page-link" href="' + esc(buildListUrl(params, totalPages, navMode)) + '">' + totalPages + '</a>';
724    }
725
726    if (pageNo < totalPages) {
727      html += '<a class="iono-page-link iono-page-next" href="' + esc(buildListUrl(params, pageNo + 1, navMode)) + '">次へ</a>';
728    }
729
730    html += '</div>';
731
732    pagers.forEach(function (pager) {
733      pager.innerHTML = html;
734    });
735  }
736
737  function savePageState(reason) {
738    const box = document.getElementById('ionoSearchBox');
739    const result = document.getElementById('ionoSearchResult');
740
741    if (!box || !result) return;
742
743    resetHoverImages(result);
744
745    const state = {
746      pageUrl: location.href.split('#')[0],
747      time: Date.now(),
748      reason: reason || '',
749      scrollY: window.scrollY || window.pageYOffset || 0,
750      resultHtml: result.innerHTML,
751      formState: getFormState(box),
752      currentPage: currentPage,
753      initialParams: initialParams,
754      activeParams: activeParams,
755      dirtyFields: dirtyFields
756    };
757
758    try {
759      sessionStorage.setItem(STATE_KEY, JSON.stringify(state));
760
761      debug('ページ状態を保存しました', {
762        reason: reason,
763        currentPage: currentPage,
764        scrollY: state.scrollY,
765        activeParams: activeParams,
766        dirtyFields: dirtyFields,
767        htmlLength: state.resultHtml.length
768      });
769    } catch (e) {
770      debug('ページ状態の保存に失敗しました', {
771        message: e.message
772      });
773    }
774  }
775
776  function loadPageState() {
777    try {
778      const raw = sessionStorage.getItem(STATE_KEY);
779      if (!raw) return null;
780
781      const state = JSON.parse(raw);
782
783      if (!state.time || Date.now() - state.time > STATE_EXPIRE) {
784        sessionStorage.removeItem(STATE_KEY);
785        return null;
786      }
787
788      if (state.pageUrl !== location.href.split('#')[0]) {
789        return null;
790      }
791
792      return state;
793    } catch (e) {
794      return null;
795    }
796  }
797
798  function isBackForward(event) {
799    if (event && event.persisted) return true;
800
801    const nav = performance.getEntriesByType
802      ? performance.getEntriesByType('navigation')[0]
803      : null;
804
805    if (nav && nav.type === 'back_forward') return true;
806
807    if (performance.navigation && performance.navigation.type === 2) return true;
808
809    return false;
810  }
811
812  function restoreScroll(y) {
813    y = parseInt(y || 0, 10);
814
815    function run() {
816      window.scrollTo(0, y);
817    }
818
819    run();
820    setTimeout(run, 50);
821    setTimeout(run, 150);
822    setTimeout(run, 350);
823    setTimeout(run, 700);
824  }
825
826  function restorePageState(state) {
827    const box = document.getElementById('ionoSearchBox');
828    const result = document.getElementById('ionoSearchResult');
829
830    if (!box || !result || !state) return false;
831
832    restoreFormState(box, state.formState);
833    updateRankingButton(box);
834
835    result.innerHTML = state.resultHtml || '';
836    resetHoverImages(result);
837
838    initialParams = clone(state.initialParams || initialParams);
839    activeParams = clone(state.activeParams || initialParams);
840    dirtyFields = clone(state.dirtyFields || {});
841    currentPage = parseInt(state.currentPage || 1, 10) || 1;
842
843    bindHover(result);
844    restoreScroll(state.scrollY);
845
846    debug('戻る状態を復元しました', {
847      currentPage: currentPage,
848      activeParams: activeParams,
849      dirtyFields: dirtyFields,
850      scrollY: state.scrollY,
851      htmlLength: result.innerHTML.length
852    });
853
854    return true;
855  }
856
857  async function fetchItems(api, params) {
858    const query = new URLSearchParams();
859    const normalized = normalizeParams(params || {});
860    const publicParams = cleanParamsForUrl(normalized);
861
862    Object.keys(publicParams).forEach(function (key) {
863      const val = publicParams[key];
864      if (val === undefined || val === null || val === '') return;
865      query.set(key, val);
866    });
867
868    /**
869     * 公開URLには出さないが、ショートコード等で与えられた隠し条件はAjaxでも維持する。
870     */
871    ['codes', 'exclude_codes', 'materials', 'order', 'columns', 'include_desc'].forEach(function (key) {
872      const val = normalized[key];
873      if (val === undefined || val === null || val === '') return;
874      query.set(key, val);
875    });
876
877    const page = normalized.paged || normalized.npage || 1;
878    query.set('npage', page);
879
880    if (!query.has('format')) {
881      query.set('format', 'html');
882    }
883
884    const url = api + '?' + query.toString();
885
886    debug('APIリクエスト開始', {
887      url: url,
888      params: normalized
889    });
890
891    const requestUrl = new URL(url, location.href);
892    const isSameOrigin = requestUrl.origin === location.origin;
893
894    const res = await fetch(url, {
895      method: 'GET',
896      mode: 'cors',
897      // 会員限定セール判定にWelcartのログインCookieが必要。
898      // 同一ドメインのAPIだけCookieを送り、外部APIでは従来どおり送らない。
899      credentials: isSameOrigin ? 'same-origin' : 'omit'
900    });
901
902    if (!res.ok) {
903      throw new Error('API error: ' + res.status);
904    }
905
906    const text = await res.text();
907
908    let data;
909
910    try {
911      data = JSON.parse(text);
912    } catch (e) {
913      debug('JSONとして読めません', {
914        responseText: text.slice(0, 2000)
915      });
916      throw e;
917    }
918
919    return data;
920  }
921
922  function syncFormWithParams(box, params) {
923    box.querySelectorAll('input, select, textarea').forEach(function (el) {
924      if (!el.name) return;
925
926      const internalName = publicNameToInternal(el.name);
927      let value = params[internalName];
928
929      if (value === undefined) return;
930      if (internalName === 'orderby') value = publicOrderby(value);
931
932      if (el.type === 'checkbox') {
933        const values = String(value).split(',');
934        el.checked = values.includes(el.value);
935        return;
936      }
937
938      if (el.type === 'radio') {
939        el.checked = String(value) === String(el.value);
940        return;
941      }
942
943      el.value = value;
944    });
945  }
946
947  function updateRankingButton(box) {
948    if (!box) return;
949
950    const button = box.querySelector('.iono-ranking-button');
951    if (!button) return;
952
953    const select = box.querySelector('select[name="orderby"]');
954    const value = select ? select.value : (activeParams.orderby || '');
955    const isSales = normalizeOrderby(value) === 'sales_amount';
956
957    button.classList.toggle('is-hidden', isSales);
958  }
959
960
961  function initIonoItems(pageShowEvent) {
962    const box = document.getElementById('ionoSearchBox');
963    const result = document.getElementById('ionoSearchResult');
964    const status = document.getElementById('ionoSearchStatus');
965
966    function setStatusText(text) {
967      if (!status) return;
968      status.textContent = text || '';
969      if (text) {
970        status.hidden = false;
971      } else {
972        status.hidden = true;
973      }
974    }
975    const moreBtn = document.getElementById('ionoMoreBtn');
976    const pagers = getPagerElements();
977
978    if (!box || !result) {
979      debug('必要な要素がありません', {
980        hasSearchBox: !!box,
981        hasResult: !!result
982      });
983      return;
984    }
985
986    if (box.dataset.ionoInitialized === '1') {
987      return;
988    }
989
990    box.dataset.ionoInitialized = '1';
991
992    initializeIonoHistoryEntry();
993
994    const api = box.dataset.api || DEFAULT_API;
995    const navMode = box.dataset.navMode || 'more';
996    const skipInitialAjax = box.dataset.skipInitialAjax === '1';
997
998    const submitBtn = box.querySelector('.iono-search-submit');
999    const resetBtn = box.querySelector('.iono-search-reset');
1000    const rankingBtn = box.querySelector('.iono-ranking-button');
1001
1002    initialParams = parseInitialParams(box);
1003    activeParams = clone(initialParams);
1004    dirtyFields = {};
1005
1006    syncFormWithParams(box, activeParams);
1007    updateRankingButton(box);
1008
1009    if (navMode === 'pager') {
1010      if (moreBtn) moreBtn.style.display = 'none';
1011    } else {
1012      clearPagers();
1013    }
1014
1015    async function runSearch(options = {}) {
1016      if (isLoading) return;
1017
1018      isLoading = true;
1019
1020      const append = !!options.append;
1021      const params = buildRequestParams();
1022
1023      if (status) {
1024        setStatusText(append ? '追加読み込み中...' : '検索中...');
1025      }
1026
1027      result.classList.add('is-loading');
1028
1029      debug(append ? 'もっと見る開始' : '検索開始', {
1030        navMode: navMode,
1031        append: append,
1032        currentPage: currentPage,
1033        activeParams: activeParams,
1034        dirtyFields: dirtyFields,
1035        requestParams: params
1036      });
1037
1038      try {
1039        const data = await fetchItems(api, params);
1040
1041        if (!append) {
1042          if (data.format === 'html') {
1043            result.innerHTML = data.html || '';
1044          } else {
1045            result.innerHTML = renderItems(data.items || [], params);
1046          }
1047        } else {
1048          const temp = document.createElement('div');
1049
1050          if (data.format === 'html') {
1051            temp.innerHTML = data.html || '';
1052          } else {
1053            temp.innerHTML = renderItems(data.items || [], params);
1054          }
1055
1056          const newCards = temp.querySelectorAll('.iono-item-card');
1057
1058          let grid = result.querySelector('.iono-items-grid');
1059
1060          if (!grid) {
1061            const columns = Math.max(1, parseInt(activeParams.columns || 4, 10) || 4);
1062            grid = document.createElement('div');
1063            grid.className = 'iono-items-grid iono-cols-' + columns + ' iono-items-after-ranking';
1064            result.appendChild(grid);
1065          }
1066
1067          newCards.forEach(function (card) {
1068            grid.appendChild(card);
1069          });
1070
1071          if (newCards.length === 0 && moreBtn) {
1072            moreBtn.style.display = 'none';
1073          }
1074        }
1075
1076        bindHover(result);
1077
1078        if (navMode === 'pager') {
1079          renderPager(activeParams, currentPage, data, navMode);
1080          if (moreBtn) moreBtn.style.display = 'none';
1081        } else {
1082          clearPagers();
1083          if (moreBtn) moreBtn.style.display = '';
1084        }
1085
1086        if (!append && options.updateUrl) {
1087          updateUrl(activeParams, currentPage, navMode);
1088        }
1089
1090        if (status) {
1091          setStatusText('');
1092        }
1093
1094        savePageState(append ? 'more_loaded' : 'search_loaded');
1095
1096        debug('表示更新完了', {
1097          currentPage: currentPage,
1098          navMode: navMode,
1099          foundPosts: data.found_posts,
1100          maxPages: data.max_pages,
1101          cardCount: result.querySelectorAll('.iono-item-card').length,
1102          htmlLength: result.innerHTML.length
1103        });
1104
1105      } catch (e) {
1106        console.error(e);
1107
1108        if (status) {
1109          setStatusText('商品を取得できませんでした。');
1110        }
1111
1112        debug('検索エラー', {
1113          message: e.message,
1114          stack: e.stack
1115        });
1116
1117      } finally {
1118        result.classList.remove('is-loading');
1119        isLoading = false;
1120      }
1121    }
1122
1123    function searchFromFirstPageByField(name) {
1124      markFieldDirtyAndUpdate(box, name);
1125      updateRankingButton(box);
1126
1127      currentPage = 1;
1128
1129      if (moreBtn && navMode === 'more') {
1130        moreBtn.style.display = '';
1131      }
1132
1133      runSearch({
1134        updateUrl: true
1135      });
1136    }
1137
1138    result.addEventListener('click', function (e) {
1139      const card = e.target.closest('.iono-item-card, .iono-ranking-card');
1140      if (!card) return;
1141
1142      savePageState('item_click');
1143    });
1144
1145    if (rankingBtn) {
1146      rankingBtn.addEventListener('click', function () {
1147        const orderbySelect = box.querySelector('select[name="orderby"]');
1148        if (!orderbySelect) return;
1149
1150        orderbySelect.value = 'sales';
1151        searchFromFirstPageByField(orderbySelect.name);
1152      });
1153    }
1154
1155    if (submitBtn) {
1156      submitBtn.addEventListener('click', function () {
1157        Object.keys(dirtyFields).forEach(function (name) {
1158          markFieldDirtyAndUpdate(box, name);
1159        });
1160
1161        currentPage = 1;
1162
1163        if (moreBtn && navMode === 'more') {
1164          moreBtn.style.display = '';
1165        }
1166
1167        runSearch({
1168          updateUrl: true
1169        });
1170      });
1171    }
1172
1173    box.querySelectorAll('select').forEach(function (el) {
1174      el.addEventListener('change', function () {
1175        searchFromFirstPageByField(el.name);
1176      });
1177    });
1178
1179    box.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(function (el) {
1180      el.addEventListener('change', function () {
1181        searchFromFirstPageByField(el.name);
1182      });
1183    });
1184
1185    box.querySelectorAll('input[type="search"], input[type="text"], input[type="number"], input
1185:not([type])').forEach(function (el) {
1186      el.addEventListener('input', function () {
1187        dirtyFields[el.name] = true;
1188
1189        clearTimeout(searchTimer);
1190
1191        searchTimer = setTimeout(function () {
1192          searchFromFirstPageByField(el.name);
1193        }, 350);
1194      });
1195    });
1196
1197    if (resetBtn) {
1198      resetBtn.addEventListener('click', function () {
1199        sessionStorage.removeItem(STATE_KEY);
1200
1201        const currentHistoryState = history.state && typeof history.state === 'object'
1202          ? history.state
1203          : {};
1204
1205        // このページ内で追加したフィルター履歴なら、重複した空URLを作らず基点へ戻る。
1206        if (currentHistoryState.ionoItemsFiltered && currentHistoryState.ionoItemsHasBase) {
1207          history.back();
1208          return;
1209        }
1210
1211        dirtyFields = {};
1212        activeParams = clone(initialParams);
1213        currentPage = 1;
1214
1215        syncFormWithParams(box, activeParams);
1216        updateRankingButton(box);
1217
1218        if (moreBtn && navMode === 'more') {
1219          moreBtn.style.display = '';
1220        }
1221
1222        clearIonoUrlParams();
1223
1224        runSearch({
1225          updateUrl: false
1226        });
1227      });
1228    }
1229
1230    if (moreBtn) {
1231      moreBtn.addEventListener('click', function () {
1232        if (navMode !== 'more') return;
1233
1234        currentPage++;
1235
1236        runSearch({
1237          append: true
1238        });
1239      });
1240    }
1241
1242    const savedState = loadPageState();
1243    const isBack = isBackForward(pageShowEvent);
1244
1245    if (isBack && savedState && savedState.resultHtml) {
1246      restorePageState(savedState);
1247    } else {
1248      resetHoverImages(result);
1249      bindHover(result);
1250
1251      currentPage = parseInt(initialParams.paged || initialParams.page_no || 1, 10) || 1;
1252      delete activeParams.paged;
1253      delete activeParams.page_no;
1254
1255      if (navMode === 'pager' && !skipInitialAjax) {
1256        /**
1257         * 初回PHP表示でもページャーをJS生成したい場合だけAjaxを1回叩く。
1258         * data-skip-initial-ajax="1" の場合はPHP側で出したページャーを使う。
1259         */
1260        runSearch({
1261          updateUrl: false
1262        });
1263      } else {
1264        savePageState('initial_php_display');
1265
1266        debug('初期化OK。初回PHP表示を維持します。', {
1267          navMode: navMode,
1268          skipInitialAjax: skipInitialAjax,
1269          initialCardCount: result.querySelectorAll('.iono-item-card').length,
1270          currentPage: currentPage,
1271          initialParams: initialParams,
1272          activeParams: activeParams,
1273          dirtyFields: dirtyFields
1274        });
1275      }
1276    }
1277  }
1278
1279
1280
1281  /**
1282   * =========================================================
1283   * Public Function API
1284   * 既存の自動初期化は残したまま、JSから関数でも使えるようにする。
1285   * =========================================================
1286   *
1287   * 単発:
1288   * IonoItems.render('#target', { cat: 498, orderby: 'code_asc' });
1289   *
1290   * ページャー付き:
1291   * const list = IonoItems.create({
1292   *   target: '#target',
1293   *   pager: ['#pagerTop', '#pagerBottom'],
1294   *   params: { cat: 498, orderby: 'code_asc', limit: 24 }
1295   * });
1296   * list.load();
1297   */
1298
1299  function ionoApiToElement(target) {
1300    if (!target) return null;
1301    if (typeof target === 'string') return document.querySelector(target);
1302    if (target.nodeType === 1) return target;
1303    return null;
1304  }
1305
1306  function ionoApiToElements(targets) {
1307    if (!targets) return [];
1308    if (!Array.isArray(targets)) targets = [targets];
1309    return targets.map(ionoApiToElement).filter(Boolean);
1310  }
1311
1312  function ionoApiNormalizeParams(params) {
1313    return Object.assign({
1314      orderby: 'recommend',
1315      order: 'DESC',
1316      limit: 24,
1317      columns: 4,
1318      paged: 1,
1319      format: 'html',
1320      tax_mode: 'tax_in'
1321    }, normalizeParams(params || {}));
1322  }
1323
1324  function ionoApiResetHoverImages(root) {
1325    if (!root) return;
1326
1327    root.querySelectorAll('img[data-main]').forEach(function (img) {
1328      if (img.dataset.main) img.src = img.dataset.main;
1329    });
1330  }
1331
1332  function ionoApiMakePagerHtml(params, data) {
1333    const totalPages = parseInt(data && data.max_pages ? data.max_pages : 0, 10);
1334    const currentPage = parseInt(params.paged || 1, 10) || 1;
1335
1336    if (!totalPages || totalPages <= 1) return '';
1337
1338    const maxLinks = 9;
1339    let start = Math.max(1, currentPage - 4);
1340    let end = Math.min(totalPages, start + maxLinks - 1);
1341
1342    if (end - start + 1 < maxLinks) {
1343      start = Math.max(1, end - maxLinks + 1);
1344    }
1345
1346    let html = '<div class="iono-pager">';
1347
1348    if (currentPage > 1) {
1349      html += '<button type="button" class="iono-page-link iono-page-prev" data-page="' + (currentPage - 1) + '">前へ</button>';
1350    } else {
1351      html += '<span class="iono-page-disabled iono-page-prev">前へ</span>';
1352    }
1353
1354    if (start > 1) {
1355      html += '<button type="button" class="iono-page-link" data-page="1">1</button>';
1356      if (start > 2) html += '<span class="iono-page-dots">...</span>';
1357    }
1358
1359    for (let i = start; i <= end; i++) {
1360      if (i === currentPage) {
1361        html += '<span class="iono-page-current">' + i + '</span>';
1362      } else {
1363        html += '<button type="button" class="iono-page-link" data-page="' + i + '">' + i + '</button>';
1364      }
1365    }
1366
1367    if (end < totalPages) {
1368      if (end < totalPages - 1) html += '<span class="iono-page-dots">...</span>';
1369      html += '<button type="button" class="iono-page-link" data-page="' + totalPages + '">' + totalPages + '</button>';
1370    }
1371
1372    if (currentPage < totalPages) {
1373      html += '<button type="button" class="iono-page-link iono-page-next" data-page="' + (currentPage + 1) + '">次へ</button>';
1374    } else {
1375      html += '<span class="iono-page-disabled iono-page-next">次へ</span>';
1376    }
1377
1378    html += '</div>';
1379    return html;
1380  }
1381
1382  function ionoApiCreate(options) {
1383    options = options || {};
1384
1385    const target = ionoApiToElement(options.target);
1386    const pagerTargets = ionoApiToElements(options.pager);
1387    const api = options.api || DEFAULT_API;
1388
1389    let params = ionoApiNormalizeParams(options.params || {});
1390    let lastData = null;
1391    let isApiLoading = false;
1392
1393    function drawPager(data, instance) {
1394      if (!pagerTargets.length) return;
1395
1396      const html = ionoApiMakePagerHtml(params, data);
1397
1398      pagerTargets.forEach(function (pager) {
1399        pager.innerHTML = html;
1400        pager.onclick = function (e) {
1401          const btn = e.target.closest('[data-page]');
1402          if (!btn) return;
1403
1404          const page = parseInt(btn.dataset.page || 1, 10) || 1;
1405          instance.setParams({ paged: page }, { scroll: true });
1406        };
1407      });
1408    }
1409
1410    async function load(loadOptions) {
1411      loadOptions = loadOptions || {};
1412
1413      if (!target) {
1414        console.warn('IonoItems: target not found', options.target);
1415        return null;
1416      }
1417
1418      if (isApiLoading) return lastData;
1419
1420      isApiLoading = true;
1421      target.classList.add('is-loading');
1422
1423      if (typeof options.onLoading === 'function') {
1424        options.onLoading(clone(params));
1425      }
1426
1427      try {
1428        const requestParams = ionoApiNormalizeParams(params);
1429        const data = await fetchItems(api, requestParams);
1430
1431        lastData = data;
1432
1433        if (data.format === 'html') {
1434          target.innerHTML = data.html || '';
1435        } else {
1436          target.innerHTML = renderItems(data.items || [], requestParams);
1437        }
1438
1439        ionoApiResetHoverImages(target);
1440        bindHover(target);
1441        drawPager(data, instance);
1442
1443        if (loadOptions.scroll) {
1444          const scrollTarget = options.scrollTarget === false
1445            ? null
1446            : ionoApiToElement(options.scrollTarget) || target;
1447
1448          if (scrollTarget) {
1449            scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
1450          }
1451        }
1452
1453        if (typeof options.onLoaded === 'function') {
1454          options.onLoaded(data, clone(requestParams));
1455        }
1456
1457        return data;
1458      } catch (e) {
1459        console.error(e);
1460
1461        target.innerHTML = '<div class="iono-items-empty">商品を取得できませんでした。</div>';
1462
1463        pagerTargets.forEach(function (pager) {
1464          pager.innerHTML = '';
1465          pager.onclick = null;
1466        });
1467
1468        if (typeof options.onError === 'function') {
1469          options.onError(e, clone(params));
1470        }
1471
1472        return null;
1473      } finally {
1474        target.classList.remove('is-loading');
1475        isApiLoading = false;
1476      }
1477    }
1478
1479    function setParams(newParams, loadOptions) {
1480      params = ionoApiNormalizeParams(Object.assign({}, params, newParams || {}));
1481      return load(loadOptions);
1482    }
1483
1484    function getParams() {
1485      return clone(params);
1486    }
1487
1488    function getData() {
1489      return lastData;
1490    }
1491
1492    function destroy() {
1493      pagerTargets.forEach(function (pager) {
1494        pager.onclick = null;
1495        pager.innerHTML = '';
1496      });
1497      if (target) target.innerHTML = '';
1498    }
1499
1500    const instance = {
1501      load: load,
1502      setParams: setParams,
1503      getParams: getParams,
1504      getData: getData,
1505      destroy: destroy
1506    };
1507
1508    return instance;
1509  }
1510
1511  async function ionoApiRender(target, params, options) {
1512    options = options || {};
1513
1514    const instance = ionoApiCreate(Object.assign({}, options, {
1515      target: target,
1516      params: params || {},
1517      pager: options.pager || null
1518    }));
1519
1520    return await instance.load();
1521  }
1522
1523  function ionoApiBindForm(formTarget, instance, map) {
1524    const form = ionoApiToElement(formTarget);
1525    if (!form || !instance) return;
1526
1527    map = map || {};
1528
1529    function collectParams() {
1530      const collected = {};
1531
1532      form.querySelectorAll('input, select, textarea').forEach(function (el) {
1533        if (!el.name) return;
1534
1535        const key = publicNameToInternal(map[el.name] || el.name);
1536
1537        if (el.type === 'checkbox') {
1538          if (!collected[key]) collected[key] = [];
1539          if (el.checked) collected[key].push(el.value);
1540          return;
1541        }
1542
1543        if (el.type === 'radio') {
1544          if (el.checked) collected[key] = el.value;
1545          return;
1546        }
1547
1548        collected[key] = el.value;
1549      });
1550
1551      Object.keys(collected).forEach(function (key) {
1552        if (Array.isArray(collected[key])) {
1553          collected[key] = collected[key].join(',');
1554        }
1555      });
1556
1557      collected.paged = 1;
1558      return collected;
1559    }
1560
1561    form.addEventListener('submit', function (e) {
1562      e.preventDefault();
1563      instance.setParams(collectParams(), { scroll: true });
1564    });
1565
1566    form.querySelectorAll('select, input[type="checkbox"], input[type="radio"]').forEach(function (el) {
1567      el.addEventListener('change', function () {
1568        instance.setParams(collectParams(), { scroll: true });
1569      });
1570    });
1571
1572    form.querySelectorAll('input[type="search"], input[type="text"], input[type="number"], input:not([type])').forEach(function (el) {
1573      let timer = null;
1574      el.addEventListener('input', function () {
1575        clearTimeout(timer);
1576        timer = setTimeout(function () {
1577          instance.setParams(collectParams(), { scroll: true });
1578        }, 350);
1579      });
1580    });
1581  }
1582
1583
1584
1585  /**
1586   * 簡易商品一覧: 横スクロール操作
1587   * メイン検索とは完全に独立。複数設置OK。
1588   */
1589  function initSimpleCarousels(root) {
1590    const scope = root || document;
1591    bindHover(scope);
1592
1593    scope.querySelectorAll('[data-iono-simple-carousel]').forEach(function (carousel) {
1594      if (carousel.dataset.ionoSimpleInitialized === '1') return;
1595      carousel.dataset.ionoSimpleInitialized = '1';
1596
1597      const scroller = carousel.querySelector('.iono-simple-scroll');
1598      const prev = carousel.querySelector('.iono-simple-nav-prev');
1599      const next = carousel.querySelector('.iono-simple-nav-next');
1600
1601      if (!scroller) return;
1602
1603      function getStep() {
1604        const card = scroller.querySelector('.iono-simple-card');
1605        if (!card) return Math.max(180, Math.floor(scroller.clientWidth * 0.75));
1606        const rect = card.getBoundingClientRect();
1607        return Math.max(160, Math.floor(rect.width * 2.2));
1608      }
1609
1610      function updateNav() {
1611        const canScroll = scroller.scrollWidth > scroller.clientWidth + 4;
1612        carousel.classList.toggle('is-scrollable', canScroll);
1613
1614        if (!canScroll) return;
1615
1616        const maxLeft = scroller.scrollWidth - scroller.clientWidth - 4;
1617        carousel.classList.toggle('is-at-start', scroller.scrollLeft <= 4);
1618        carousel.classList.toggle('is-at-end', scroller.scrollLeft >= maxLeft);
1619      }
1620
1621      if (prev) {
1622        prev.addEventListener('click', function () {
1623          scroller.scrollBy({ left: -getStep(), behavior: 'smooth' });
1624        });
1625      }
1626
1627      if (next) {
1628        next.addEventListener('click', function () {
1629          scroller.scrollBy({ left: getStep(), behavior: 'smooth' });
1630        });
1631      }
1632
1633      let isDown = false;
1634      let startX = 0;
1635      let startLeft = 0;
1636      let isDragging = false;
1637      let suppressClick = false;
1638      const DRAG_THRESHOLD = 12;
1639
1640      scroller.addEventListener('pointerdown', function (e) {
1641        if (e.pointerType === 'mouse' && e.button !== 0) return;
1642        isDown = true;
1643        isDragging = false;
1644        suppressClick = false;
1645        startX = e.clientX;
1646        startLeft = scroller.scrollLeft;
1647      });
1648
1649      scroller.addEventListener('pointermove', function (e) {
1650        if (!isDown) return;
1651
1652        const dx = e.clientX - startX;
1653
1654        // クリック時の微妙なブレではリンク遷移を殺さない。
1655        if (!isDragging && Math.abs(dx) < DRAG_THRESHOLD) return;
1656
1657        if (!isDragging) {
1658          isDragging = true;
1659          suppressClick = true;
1660          scroller.classList.add('is-dragging');
1661          try {
1662            scroller.setPointerCapture && scroller.setPointerCapture(e.pointerId);
1663          } catch (_) {}
1664        }
1665
1666        e.preventDefault();
1667        scroller.scrollLeft = startLeft - dx;
1668      });
1669
1670      function endDrag(e) {
1671        if (!isDown) return;
1672        isDown = false;
1673        isDragging = false;
1674        scroller.classList.remove('is-dragging');
1675        try {
1676          scroller.releasePointerCapture && scroller.releasePointerCapture(e.pointerId);
1677        } catch (_) {}
1678      }
1679
1680      scroller.addEventListener('pointerup', endDrag);
1681      scroller.addEventListener('pointercancel', endDrag);
1682      scroller.addEventListener('mouseleave', function (e) {
1683        if (isDown) endDrag(e);
1684      });
1685
1686      scroller.addEventListener('click', function (e) {
1687        if (!suppressClick) return;
1688        e.preventDefault();
1689        e.stopPropagation();
1690        suppressClick = false;
1691      }, true);
1692
1693      scroller.addEventListener('scroll', updateNav, { passive: true });
1694      window.addEventListener('resize', updateNav);
1695
1696      updateNav();
1697      setTimeout(updateNav, 120);
1698      setTimeout(updateNav, 500);
1699
1700      scroller.querySelectorAll('img').forEach(function (img) {
1701        if (!img.complete) {
1702          img.addEventListener('load', updateNav, { once: true });
1703          img.addEventListener('error', updateNav, { once: true });
1704        }
1705      });
1706    });
1707  }
1708
1709  window.IonoItems = {
1710    create: ionoApiCreate,
1711    render: ionoApiRender,
1712    fetch: fetchItems,
1713    bindForm: ionoApiBindForm,
1714    bindHover: bindHover,
1715    resetHoverImages: ionoApiResetHoverImages,
1716    initSimpleCarousels: initSimpleCarousels
1717  };
1718
1719  if (document.readyState === 'loading') {
1720    document.addEventListener('DOMContentLoaded', function () {
1721      initIonoItems(null);
1722      initSimpleCarousels(document);
1723    });
1724  } else {
1725    initIonoItems(null);
1726    initSimpleCarousels(document);
1727  }
1728
1729  /**
1730   * pushStateで追加した同一ページ内の履歴を戻った場合、
1731   * URLだけでなくPHP初期表示も確実に元へ戻す。
1732   * 商品詳細ページから戻った場合は renderedUrl と一致するため再読込しない。
1733   */
1734  window.addEventListener('popstate', function (event) {
1735    if (location.href === renderedUrl) return;
1736
1737    const state = event.state && typeof event.state === 'object'
1738      ? event.state
1739      : {};
1740
1741    if (state.ionoItemsBase || state.ionoItemsFiltered) {
1742      if (state.ionoItemsBase) {
1743        try {
1744          sessionStorage.removeItem(STATE_KEY);
1745        } catch (e) {
1746          // sessionStorageが使えなくても履歴復元自体は続行する。
1747        }
1748      }
1749
1750      location.reload();
1751    }
1752  });
1753
1754  window.addEventListener('pageshow', function (event) {
1755    const savedState = loadPageState();
1756
1757    if (isBackForward(event) && savedState && savedState.resultHtml) {
1758      const box = document.getElementById('ionoSearchBox');
1759
1760      if (box) {
1761        box.dataset.ionoInitialized = '';
1762      }
1763
1764      initIonoItems(event);
1765    }
1766
1767    resetHoverImages(document.getElementById('ionoSearchResult'));
1768    initSimpleCarousels(document);
1769  });
1770
1771})();

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.