/** * 全店の販売状況(公開・ログイン不要。→ ../api/dashboard.php) * * 依存: ../js/realtime.js(任意。無くても30秒おきに取り直す) * * ★ レジの決済・取消・在庫の変更で `dash_` ルームに合図が来る(ルーム名は API が配る)。 * 全店の合図が集まるので、当日は数秒おきに届く。来るたびに取り直さず、 * MIN_INTERVAL より短い間隔では取り直さない(サーバー側も数秒キャッシュしている)。 * ★ `?signage=1` でサイネージ表示(文字を大きく・自動スクロール・検索欄なし)。 */ (function () { 'use strict'; const $ = (id) => document.getElementById(id); const API = '../api/dashboard.php'; const MIN_INTERVAL = 5000; // 取り直しの最短間隔(ミリ秒) const signage = new URLSearchParams(location.search).get('signage') === '1'; if (signage) document.body.classList.add('signage'); // マイページへ戻るリンク。マイページ(ゲスト・生徒・保護者)から来たときだけ出す。 // ★ マイページのURLにはカードのトークンが付くので、ここでは作らず来た元(referrer)へ戻る (function setupBack() { const back = $('backToMypage'); let from = null; try { from = document.referrer ? new URL(document.referrer) : null; } catch (e) { from = null; } if (signage || !from || from.origin !== location.origin || from.pathname.indexOf('/mypage/') < 0) return; back.href = from.href; back.classList.remove('hidden'); back.addEventListener('click', (e) => { if (history.length > 1) { e.preventDefault(); history.back(); } }); })(); let data = null; let room = null; let lastFetch = 0; let fetching = false; let timer = null; // ------------------------------------------------------------ // 取得 // ------------------------------------------------------------ /** 合図・定期・画面復帰から呼ばれる。短い間隔の呼び出しは1回にまとめる */ function request() { if (timer) return; const wait = Math.max(0, lastFetch + MIN_INTERVAL - Date.now()); timer = setTimeout(() => { timer = null; load(); }, wait); } async function load() { if (fetching) { request(); return; } fetching = true; lastFetch = Date.now(); try { const res = await fetch(API, { method: 'POST', cache: 'no-store' }); const json = await res.json(); if (!json.ok) throw new Error(json.message || 'error'); if (json.closed) { // 公開を止めている:中身を消して案内だけ。定期の取り直しは続ける(再開したら戻る) data = null; showClosed(json); $('offline').classList.add('hidden'); return; } data = json; document.body.classList.remove('dash-closed'); $('closedMessage').classList.add('hidden'); render(); $('offline').classList.add('hidden'); watch(json.room); } catch (e) { console.warn(e); // 表示は前回のまま残し、通信待ちとだけ出す $('offline').classList.remove('hidden'); } finally { fetching = false; } } function watch(name) { if (!name || room === name || typeof gsRT === 'undefined') return; room = name; gsRT.join(room, { sale_changed: request, stock_changed: request }); } // ------------------------------------------------------------ // 描画 // ------------------------------------------------------------ /** * ★ 目的は「いま、どこで何が買えるか」(チャージしたのに買うものが無い、を避ける)。 * 買える店を先に、完売の店は下にまとめて薄く出す。売れた数・売上は出さない */ function render() { $('loading').classList.add('hidden'); $('title').textContent = data.event_name + ' 販売状況'; $('updated').textContent = data.updated_at; $('totalOpen').textContent = data.totals.open; $('totalSoldOut').textContent = data.totals.soldout; // 開催日の前:全店「準備中」。販売中・完売の数と凡例は出さず、当日から見られることを案内する const preparing = !!data.preparing; $('dashTotals').classList.toggle('hidden', preparing); $('dashLegend').classList.toggle('hidden', preparing); $('prepBanner').classList.toggle('hidden', !preparing); if (preparing) { $('prepBanner').textContent = (formatDate(data.event_date) || '当日') + 'の開催当日から、各お店の販売状況(販売中・残りわずか・売り切れ)をここでお知らせします。いまは準備中です。'; } $('openHeading').textContent = preparing ? '出店するお店' : 'いま買えるお店'; const q = signage ? '' : $('filter').value.trim().toLowerCase(); const openCards = []; const closedCards = []; data.shops.forEach((shop) => { const hitShop = !q || (shop.name + shop.group_name + shop.place).toLowerCase().includes(q); const items = hitShop ? shop.items : shop.items.filter((it) => it.name.toLowerCase().includes(q)); if (!hitShop && !items.length) return; (shop.status === 'soldout' ? closedCards : openCards).push(shopCard(shop, items)); }); pack($('openShops'), openCards); pack($('closedShops'), closedCards); const shownOpen = openCards.length; const shownClosed = closedCards.length; $('openHeading').classList.toggle('hidden', !shownOpen); $('closedHeading').classList.toggle('hidden', !shownClosed); $('empty').textContent = q ? '見つかりませんでした' : 'まだ販売の情報はありません'; $('empty').classList.toggle('hidden', shownOpen + shownClosed > 0); } /** 公開を止めているとき(全体設定 dashboard_enabled がオフ)。見出しだけ残し、検索・件数・お店の一覧を隠す */ function showClosed(json) { document.body.classList.add('dash-closed'); $('loading').classList.add('hidden'); $('title').textContent = (json.event_name || '学園祭') + ' 販売状況'; ['dashTotals', 'prepBanner', 'empty', 'openHeading', 'closedHeading'].forEach((id) => $(id).classList.add('hidden')); pack($('openShops'), []); pack($('closedShops'), []); $('closedMessage').classList.remove('hidden'); } // ------------------------------------------------------------ // カードを詰めて並べる(石垣積み) // ------------------------------------------------------------ // // ★ 商品の数で店のカードの高さが大きく違う。表(grid)で並べると、同じ段の一番高いカードに // 合わせて行の高さが決まり、短いカードの下に大きな空きが出た(2026-09-29 依頼元)。 // 列を作り、並び順のまま「いちばん短い列」の下に次のカードを置く。上から左→右の順はおおむね保たれる。 // ★ 列の数は、画面の幅と CSS の変数(--col-min・--gap。サイネージは大きい)から決める。 // 幅が変わったら並べ直す(→ resize)。 function toPx(value) { const v = String(value || '').trim(); if (v.endsWith('rem')) return parseFloat(v) * parseFloat(getComputedStyle(document.documentElement).fontSize); return parseFloat(v) || 0; } function columnCount(wrap) { const cs = getComputedStyle(wrap); const inner = wrap.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); const min = toPx(cs.getPropertyValue('--col-min')) || 300; const gap = toPx(cs.getPropertyValue('--gap')); return Math.max(1, Math.floor((inner + gap) / (min + gap))); } function pack(wrap, cards) { wrap._cards = cards; // 幅が変わったときに並べ直すため // ★ 中身を空にすると一瞬ページが短くなり、ブラウザがスクロール位置を先頭へ詰める // (サイネージの自動スクロールが更新のたびに先頭へ戻る原因)。組み直す間は高さを保つ wrap.style.minHeight = wrap.offsetHeight + 'px'; wrap.innerHTML = ''; if (!cards.length) { wrap.style.minHeight = ''; return; } const n = columnCount(wrap); const gap = toPx(getComputedStyle(wrap).getPropertyValue('--gap')); const cols = []; const heights = []; for (let i = 0; i < n; i++) { cols.push(wrap.appendChild(el('div', 'dash-col'))); heights.push(0); } cards.forEach((card) => { let k = 0; for (let i = 1; i < n; i++) if (heights[i] < heights[k]) k = i; cols[k].appendChild(card); heights[k] += card.offsetHeight + gap; }); wrap.style.minHeight = ''; } let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { [$('openShops'), $('closedShops')].forEach((wrap) => { if (wrap._cards && wrap.children.length !== columnCount(wrap)) pack(wrap, wrap._cards); }); }, 150); }); const STATUS_LABEL = { open: '販売中', few: '残りわずか', soldout: '完売', preparing: '準備中' }; /** '2026-10-03' → '10月3日(土)'。形が違えば空 */ function formatDate(ymd) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(ymd || '')); if (!m) return ''; const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return Number(m[2]) + '月' + Number(m[3]) + '日(' + '日月火水木金土'[d.getDay()] + ')'; } function shopCard(shop, items) { const card = el('section', 'shop is-' + shop.status); const head = el('header', 'shop-head'); const title = el('div', 'shop-title'); title.appendChild(el('h3', 'shop-name', shop.name)); title.appendChild(el('span', 'badge ' + shop.status, STATUS_LABEL[shop.status] || '')); head.appendChild(title); const sub = [shop.group_name !== shop.name ? shop.group_name : '', shop.place].filter(Boolean).join('・'); if (sub) head.appendChild(el('p', 'shop-sub', sub)); card.appendChild(head); // 商品を登録していない店(フリーマーケット形式など)は、店の状態だけ if (!shop.items.length) { card.appendChild(el('p', 'shop-note', shop.status === 'soldout' ? '本日の販売は終了しました' : '商品はお店でご確認ください')); return card; } const list = el('ul', 'items'); items.forEach((it) => { // 店が完売にしたときは、残っている商品も買えない const gone = it.sold_out || shop.status === 'soldout'; const li = el('li', 'item' + (gone ? ' is-soldout' : '')); li.appendChild(el('span', 'item-name', it.name)); li.appendChild(el('span', 'item-price', it.price.toLocaleString('ja-JP') + '円')); if (shop.status === 'preparing') { // 開催日の前は在庫の状態を出さない(商品名と価格だけ) li.appendChild(el('span', '')); } else if (gone) { li.appendChild(el('span', 'badge soldout', '売り切れ')); } else if (it.remaining !== null && it.remaining <= 5) { // 数に限りがあり、残りが少ないときだけ数を出す(多いときは「あり」で十分) li.appendChild(el('span', 'badge few', '残り ' + it.remaining)); } else { li.appendChild(el('span', 'badge open', 'あり')); } list.appendChild(li); }); card.appendChild(list); return card; } function el(tag, className, text) { const e = document.createElement(tag); if (className) e.className = className; if (text !== undefined) e.textContent = text; return e; } // ------------------------------------------------------------ // サイネージの自動スクロール // ------------------------------------------------------------ /** * 下までゆっくりスクロール → しばらく止まる → 先頭に戻る、を繰り返す。 * 画面に収まっているときは動かさない。 * * ★ 別ページの iframe に入れて使うとき(/signage/floor/gakuensai/)は、先頭に戻らず * 一番下で止まった時点で親へ `gs-signage-done` を送る(親が次のスライドへ進める)。 * 親は表示のたびにこのページを読み直すので、毎回先頭から始まる。 * ★ 位置は自前の pos で持つ。データ更新(render)で高さが一瞬変わっても、 * 次のフレームで pos に戻すので、先頭には戻らない。 */ function autoScroll() { const SPEED = 40; // 1秒あたりのピクセル const PAUSE_END = 6000; // 一番下で止まる時間 const PAUSE_TOP = 4000; // 先頭に戻ってから動き出すまで const embedded = window.parent !== window; let pausedUntil = Date.now() + PAUSE_TOP; let prev = performance.now(); let pos = 0; let ending = false; // 一番下に着いて、止まっている間 function finish() { if (embedded) { window.parent.postMessage({ type: 'gs-signage-done' }, location.origin); return; // ending のまま動かさない(親が読み直す) } pos = 0; window.scrollTo(0, 0); ending = false; pausedUntil = Date.now() + PAUSE_TOP; } function step(now) { const dt = (now - prev) / 1000; prev = now; const max = document.documentElement.scrollHeight - window.innerHeight; // ★ 最初のデータが描かれるまでは何もしない。読み込み中は高さが無く、 // 「画面に収まっている=もう終わり」と誤判定して先頭に止まってしまう if (!data) { pausedUntil = Date.now() + PAUSE_TOP; } else if (Date.now() >= pausedUntil && !ending) { if (max > 4) { pos = Math.min(max, pos + SPEED * dt); window.scrollTo(0, pos); } else { pos = max; // 画面に収まっている:動かさず、止まる時間だけ見せる } if (pos >= max) { ending = true; setTimeout(finish, PAUSE_END); } } else if (ending && max > 4) { window.scrollTo(0, max); // 止まっている間は一番下に留める(更新で高さが変わっても) } requestAnimationFrame(step); } requestAnimationFrame(step); } // ------------------------------------------------------------ $('filter').addEventListener('input', () => { if (data) render(); }); // VPS がつながらないときの補い(realtime.js も30秒おきに合図の処理を呼ぶ) setInterval(request, 30000); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'hidden') request(); }); load(); if (signage) autoScroll(); })();