/* * flcart-utils.js — Flannel CART用 汎用ユーティリティ(jQueryベース) * * 含まれる機能: * - Cookie操作(JSON対応) * - モーダル表示(外部HTMLテンプレート読込) * - 画面更新ヘルパー * - Ajax通信ラッパー(CSRF対応、重複リクエスト抑止) * - 汎用ユーティリティ(debounce, throttle) * * 使用例: * FlCart.cookies.setJSON('cartitem', {...}, {days:1}); * const cfg = FlCart.cookies.getJSON('cartitem') || {}; * FlCart.modal.open('/partials/option-modal.html', {onOpen: () => {...}}); * FlCart.ajax.call('priceQuote', { url:'/api/cart/estimate.php', method:'POST', data: cfg }) * .done(res => FlCart.ui.text('#price', res.total_fmt)) * .fail(xhr => FlCart.ui.toast('取得に失敗しました')); */ // ==================== セッション情報管理 ==================== // セッション情報をFlCart.storageから取得(複数の関数から使用可能) function getSessionData(){ let d = FlCart.storage.get('cart_session', { prefer: 'local' }) || { selected: [], selected_pls: [] }; return d; } // カート情報をFlCart.storageから取得(複数の関数から使用可能) function getSessionCartData(){ let d = FlCart.storage.get('cart_items', { prefer: 'local' }) || { cartitem: [], cartitem_pls: [] }; return d; } // セッション情報をFlCart.storageに保存 function setSessionData(data){ FlCart.storage.set('cart_session', data, { ttlSec: 3600, // 1時間 useLocal: true, useCookie: false, cookieDays: 1/12 }); } //カート情報をFlCart.storageに保存 function setSessionCartData(data){ FlCart.storage.set('cart_items', data, { ttlSec: 3600, // 1時間 useLocal: true, useCookie: false, cookieDays: 1/12 }); } // サーバーからセッション情報を取得して更新 function refreshSessionCartData(callback) { FlCart.ajax.call('getSession', { url: '/_functions/cart/ajax_get_session.php', method: 'POST' }).done(function(response) { if (response.success) { setSessionData(response); updateDisplay(response.itemid, response.prod_id, response.prod_name, response.size_id, response.size_name); } else { //console.error('セッション情報の取得に失敗しました'); } }).fail(function(xhr, status, error) { //console.error('Ajax error:', xhr, status, error); }); } function calc_price(){ let res = getSessionData(); let total_price = 0; Object.keys(res.selected).forEach(function(itemid,value) { if( res.selected[itemid].price != '' && res.selected[itemid].price != undefined ){ total_price += res.selected[itemid].price*res.selected[itemid].qty; $.each(res.selected[itemid]['option'], function(key, item){ total_price = total_price + Number(item.opt_price)*Number(res.selected[itemid].qty); }); } }); return total_price; } function calc_fee_amount(){ let res = getSessionData(); let fee_amount = calc_price(); if( fee_amount <= 0 ){ return "-"; }else{ return format_price(fee_amount); } } function calc_fee_small_amount(itemid){ let res = getSessionData(); let fee_amount = 0; if( res.selected[itemid].price != '' && res.selected[itemid].price != undefined ){ fee_amount += res.selected[itemid].price*res.selected[itemid].qty; $.each(res.selected[itemid]['option'], function(key, item){ fee_amount = fee_amount + Number(item.opt_price)*Number(res.selected[itemid].qty); }); }; if( fee_amount <= 0 ){ return "-"; }else{ return format_price(fee_amount); } } function format_price(price){ price = price*(1+0.1); return "¥" + price.toLocaleString(); } // 最短配送日の表示を更新(cart_index 等) function updateShippingDate(senddate, senddateWeek) { if (!senddate) { return; } const week = senddateWeek || ''; const weekHtml = week ? '(' + week + ')' : ''; $('.shipping-details .shipping-date .date').html(senddate + weekHtml); } function applySenddateResponse(res) { if (res && res.senddate) { updateShippingDate(res.senddate, res.senddate_week); } } function fetchShippingDate(callback) { return FlCart.ajax.call('getSenddate', { url: '/_functions/cart/ajax_get_senddate.php', method: 'POST' }).done(function(res) { if (res && res.success) { applySenddateResponse(res); } if (typeof callback === 'function') { callback(res); } }); } (function (global, $) { if (!$) { throw new Error('flcart-utils は jQuery が必要です'); } const FlCart = global.FlCart || {}; /* ======================= Cookie処理 ======================= */ const cookies = { /** Cookieを設定(文字列値) */ set(name, value, opts = {}) { const days = opts.days ?? 1; const path = opts.path ?? '/'; const samesite = opts.samesite ?? 'Lax'; const secure = opts.secure ?? (location.protocol === 'https:'); const domain = opts.domain ? `; domain=${opts.domain}` : ''; let expires = ''; if (typeof days === 'number') { const date = new Date(); date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); expires = `; expires=${date.toUTCString()}`; } const secureAttr = secure ? '; Secure' : ''; document.cookie = `${name}=${encodeURIComponent(value)}${expires}; path=${path}; SameSite=${samesite}${secureAttr}${domain}`; }, /** Cookieの値を取得(存在しない場合はnull) */ get(name) { const target = name + '='; const parts = document.cookie.split(';'); for (let i = 0; i < parts.length; i++) { let c = parts[i].trim(); if (c.indexOf(target) === 0) { return decodeURIComponent(c.substring(target.length)); } } return null; }, /** Cookieを削除 */ del(name, opts = {}) { const path = opts.path ?? '/'; const domain = opts.domain ? `; domain=${opts.domain}` : ''; const secure = opts.secure ?? (location.protocol === 'https:'); const secureAttr = secure ? '; Secure' : ''; document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${path}; SameSite=Lax${secureAttr}${domain}`; }, /** JSONデータをCookieとして保存 */ setJSON(name, obj, opts = {}) { try { const raw = JSON.stringify(obj); if (raw.length > 3500) console.warn(`[FlCart.cookies] ${name} がCookieサイズ制限を超える可能性があります (~3.5KB)`, obj); this.set(name, raw, opts); } catch (e) { console.error('[FlCart.cookies] JSON化に失敗', e); } }, /** JSON Cookieの読み込み */ getJSON(name) { const raw = this.get(name); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { console.error('[FlCart.cookies] JSON解析に失敗', e); return null; } }, /** カート用Cookieをすべて削除 */ removeAll(opts = {}) { const names = ['cart_session', 'cart_items', 'cartitem', 'cartitem_pls']; names.forEach(name => this.del(name, opts)); } }; /* ======================= モーダル ======================= */ const modal = (function(){ let $backdrop = null, $dialog = null, $content = null, lastActive = null; function ensureElements() { if ($backdrop) return; $backdrop = $('').css({ position: 'fixed', inset: 0, display: 'none', background: 'rgba(0,0,0,0.4)', zIndex: 9998 }); $dialog = $('').css({ position: 'fixed', maxWidth: 'min(720px, 92vw)', width: 'auto', maxHeight: '90vh', overflow: 'auto', background: '#fff', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', borderRadius: '16px', padding: '16px', zIndex: 9999, boxShadow: '0 10px 30px rgba(0,0,0,0.25)' }); $content = $('
'); const $close = $('').css({ position: 'absolute', top: 8, right: 12, border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer' }).on('click', close); $dialog.append($close, $content); $('body').append($backdrop, $dialog); // バックドロップクリックやESCキーで閉じる $backdrop.on('click', close); $(document).on('keydown.flcModal', function(e){ if (e.key === 'Escape') close(); }); } function trapFocus(e) { if (!$dialog.is(':visible')) return; const $focusables = $dialog.find('a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])').filter(':visible'); if ($focusables.length === 0) return; const first = $focusables[0]; const last = $focusables[$focusables.length - 1]; if (e.shiftKey && e.target === first && e.key === 'Tab') { e.preventDefault(); last.focus(); } else if (!e.shiftKey && e.target === last && e.key === 'Tab') { e.preventDefault(); first.focus(); } } function open(url, { data, method = 'GET', onOpen, onLoaded, onClose } = {}) { ensureElements(); lastActive = document.activeElement; $backdrop.fadeIn(120); $dialog.show(); $dialog.attr('aria-busy', 'true'); $content.empty().append('
読み込み中…
'); const ajaxOpts = { url, method, data, dataType: 'html' }; const jqxhr = $.ajax(ajaxOpts) .done(function (html) { $content.html(html); $dialog.removeAttr('aria-busy'); if (typeof onLoaded === 'function') onLoaded($dialog[0], $content[0]); setTimeout(() => { const el = $dialog.find('a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])').filter(':visible')[0]; if (el) el.focus(); }, 0); }) .fail(function () { $content.html('
テンプレートの読み込みに失敗しました。
'); $dialog.removeAttr('aria-busy'); }); if (typeof onOpen === 'function') onOpen($dialog[0], $content[0], jqxhr); $(document).on('keydown.flcFocusTrap', function(e){ if (e.key === 'Tab') trapFocus(e); }); return jqxhr; } function set(html) { ensureElements(); $content.html(html); } function close() { if (!$dialog) return; $backdrop.fadeOut(120, () => { $backdrop.hide(); }); $dialog.hide(); $(document).off('keydown.flcFocusTrap'); if (lastActive && typeof lastActive.focus === 'function') { try { lastActive.focus(); } catch (_) {} } $(document).trigger('flc:modal:closed'); } return { open, close, set }; })(); /* ======================= UIヘルパー ======================= */ const ui = { text(sel, value) { $(sel).text(value); }, html(sel, value) { $(sel).html(value); }, val(sel, value) { $(sel).val(value); }, toggle(sel, show) { show ? $(sel).show() : $(sel).hide(); }, disable(sel, on) { $(sel).prop('disabled', !!on); }, cls(sel, className, present) { $(sel).toggleClass(className, present); }, toast(msg) { if (!$('#flc-toast').length) { $('body').append(''); } const $t = $('#flc-toast'); $t.stop(true, true).text(msg).fadeIn(120).delay(1800).fadeOut(200); } }; /* ======================= Ajax通信 ======================= */ const ajax = (function(){ const inflight = new Map(); // 同じキーの通信をキャンセル管理 function csrfHeader() { const token = $('meta[name="csrf"]').attr('content'); return token ? { 'X-CSRF-Token': token } : {}; } function request({ url, method = 'GET', data = null, dataType = 'json', timeout = 15000, headers = {}, processData = true, contentType = undefined }) { const opts = { url, method, data, dataType, timeout, processData }; if (contentType !== undefined) opts.contentType = contentType; return $.ajax(opts); } /** 同一キー通信は前回を中断し、最新のみ有効にする */ function call(key, opts) { const prev = inflight.get(key); if (prev && prev.readyState !== 4) { try { prev.abort(); } catch(_){} } const jqxhr = request(opts); inflight.set(key, jqxhr); jqxhr.always(() => inflight.delete(key)); return jqxhr; } return { request, call }; })(); /* ======================= 汎用関数 ======================= */ const utils = { debounce(fn, wait = 300) { let t = null; return function(...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); }; }, throttle(fn, wait = 300) { let last = 0, timer = null; return function(...args) { const now = Date.now(); const remaining = wait - (now - last); if (remaining <= 0) { last = now; fn.apply(this, args); } else if (!timer) { timer = setTimeout(() => { last = Date.now(); timer = null; fn.apply(this, args); }, remaining); } }; } }; /* ======================= Storage(Cookie + localStorage ハイブリッド) ======================= */ // 目的: // - サーバーと連携したい値は Cookie にも保存(PHP側で $_COOKIE から参照可) // - 長期保持やUI復元は localStorage でも保存 // - TTL(秒)で期限管理し、期限切れは自動削除 // - 可能な限り両方にミラーリングし、取得時は優先順位でフェイルオーバー const storage = (function(){ const PREFIX = 'flc:'; // localStorageキーの接頭辞 function nowSec(){ return Math.floor(Date.now()/1000); } function pack(value, ttlSec){ const payload = { v:1, data:value }; if (typeof ttlSec === 'number' && ttlSec > 0) payload.exp = nowSec() + Math.floor(ttlSec); return JSON.stringify(payload); } function unpack(str){ if (!str) return null; try { const p = JSON.parse(str); if (p && typeof p === 'object') return p; } catch(_){} return null; } function isExpired(p){ return p && typeof p.exp === 'number' && p.exp < nowSec(); } function lsSet(name, value, ttlSec){ try { localStorage.setItem(PREFIX+name, pack(value, ttlSec)); return true; } catch(e){ console.warn('[FlCart.storage] localStorage set 失敗', e); return false; } } function lsGetRaw(name){ try { return localStorage.getItem(PREFIX+name); } catch(e){ return null; } } function lsRemove(name){ try { localStorage.removeItem(PREFIX+name); } catch(e){} } // Cookieは utils の cookies を利用(JSON保存) function ckSet(name, value, ttlSec, cookieOpts){ // ttlSec を days に変換(秒 → 日) const days = (typeof ttlSec === 'number' && ttlSec>0) ? (ttlSec / 86400) : (cookieOpts && typeof cookieOpts.days==='number' ? cookieOpts.days : undefined); const opts = { ...(cookieOpts||{}) }; if (days !== undefined) opts.days = days; FlCart.cookies.setJSON(name, { v:1, data:value, exp: (typeof ttlSec==='number' && ttlSec>0) ? (nowSec()+Math.floor(ttlSec)) : undefined }, opts); return true; } function ckGetRaw(name){ const obj = FlCart.cookies.getJSON(name); return obj ? JSON.stringify(obj) : null; } function ckRemove(name, cookieOpts){ FlCart.cookies.del(name, cookieOpts||{}); } // 公開API function set(name, value, opts={}){ const { ttlSec = 0, // 0 or 未指定で期限なし(localStorage側)。Cookie側はdays未指定ならセッションCookie相当にはならないため、必要なら opts.cookieDays を指定 useLocal = true, useCookie = false, cookieDays, // 明示的にCookieの期限を日数で指定したい場合 cookie: cookieOpts // {path, samesite, secure, domain, days} } = opts; // localStorage let okLocal = true; if (useLocal) okLocal = lsSet(name, value, ttlSec); // Cookie let okCookie = true; if (useCookie){ const co = {...(cookieOpts||{})}; if (typeof cookieDays === 'number') co.days = cookieDays; // 明示優先 okCookie = ckSet(name, value, ttlSec, co); } return okLocal && okCookie; } function get(name, opts={}){ const { prefer = 'local' } = opts; // 'local' | 'cookie' const order = prefer === 'cookie' ? ['cookie','local'] : ['local','cookie']; for (const src of order){ const raw = src==='local' ? lsGetRaw(name) : ckGetRaw(name); const p = unpack(raw); if (!p) continue; if (isExpired(p)) { remove(name, { both:true }); continue; } return p.data; } return null; } function getWithMeta(name, opts={}){ const { prefer = 'local' } = opts; const order = prefer === 'cookie' ? ['cookie','local'] : ['local','cookie']; for (const src of order){ const raw = src==='local' ? lsGetRaw(name) : ckGetRaw(name); const p = unpack(raw); if (!p) continue; if (isExpired(p)) { remove(name, { both:true }); continue; } return { source: src, value: p.data, exp: p.exp||null }; } return null; } function remove(name, opts={}){ const { both = false } = opts; lsRemove(name); if (both) ckRemove(name); } function keys(){ const out = []; try { for (let i=0;i