/** * 学園祭ゲストページ(スマホのゲスト。紙のプリペイドカードの代わり) * * 依存: /js/customDialog.js / /js/createQR.js / ../js/realtime.js(無くても動く) * ../mypage/js/account-history.js(履歴の表。マイページと共用) * * ★ ログインは Cookie(HttpOnly・当日の終わりまで)。この JS はログインの鍵を持たない。 * ★ QRは使い捨て・端末ごとに1枚(→ GakuensaiWebGuest::issueQr)。お店の支払いも、本部窓口の * チャージ・返金も同じQRを見せる。使われたら「完了」を出し、次のQRはボタンで出す * (すぐ作り直すと完了の表示が消えてしまう。保護者ページと同じ)。 * ★ 2台目のログインは、開いている端末に「許可しますか?」を出す。合図は account_changed * (VPS の許可リストに既にある)。受けた端末は state を取り直して、待っているログインを見つける。 * ソケットを取りこぼしても、画面を開いている間の合図(heartbeat・20秒おき)でも拾う。 */ (function () { 'use strict'; const $ = (id) => document.getElementById(id); const yen = (n) => Number(n || 0).toLocaleString('ja-JP'); const HEARTBEAT_MS = 20000; // 「画面を開いている」の合図(サーバーは45秒以内を開いているとみなす) let me = null; // ログイン中の state let qrGeneration = 0; let qrFlight = null; let qr = null; // { token, remain, timer, poll } let wait = null; // 許可待ち { request_id, secret, remain, timer, poll, room } let watchingRoom = null; let heartbeatTimer = null; const asked = {}; // 確認を出したログインの request_id // ------------------------------------------------------------ // API // ------------------------------------------------------------ async function api(op, payload) { const res = await fetch('../api/guest.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(Object.assign({ op: op }, payload || {})) }); const json = await res.json(); // ログインが切れた(ほかの端末からログアウトさせられた・日付が変わった) if (json && json.ok === false && json.error === 'unauthorized' && op !== 'state' && op !== 'issue_qr') { loggedOut(json.message); } return json; } /** 通信の失敗を1箇所で知らせる。成功した応答だけを返す(失敗は null) */ async function call(op, payload, failText) { let json; try { json = await api(op, payload); } catch (e) { console.error(e); alertDialog('通信できませんでした。電波の良い場所でもう一度お試しください。', 3000, 'warn'); return null; } if (!json.ok) { if (json.error !== 'unauthorized') alertDialog(json.message || failText || 'できませんでした', 4000, 'warn'); return null; } return json; } function fail(message) { $('loading').classList.add('hidden'); $('error').classList.remove('hidden'); $('error').textContent = message; } // ------------------------------------------------------------ // 起動 // ------------------------------------------------------------ async function boot() { let json; try { json = await api('state', { visible: document.visibilityState === 'visible' }); } catch (e) { console.error(e); return fail('通信できませんでした。電波の良い場所でもう一度お試しください。'); } if (!json.ok) return fail(json.message || '表示できませんでした。'); $('loading').classList.add('hidden'); if (json.logged_in) showApp(json); else showAuth(json); } // ------------------------------------------------------------ // ログイン前 // ------------------------------------------------------------ /** 販売状況のページを閉じているときはリンクを出さない(→ 全体設定 dashboard_enabled。値が無ければ出す) */ function toggleDashLinks(open) { document.querySelectorAll('.dash-link').forEach((a) => a.classList.toggle('hidden', open === false)); } function showAuth(json) { stopApp(); $('app').classList.add('hidden'); $('auth').classList.remove('hidden'); document.querySelectorAll('.event-name').forEach((p) => { p.textContent = json.event_name || ''; }); toggleDashLinks(json.dashboard_open); // 学園祭が終わった後:上の案内を、残高の確認と事務室での返金の案内に替える // 返金の期限は全体設定「返金受付の期限(表示用)」(refund_deadline)。空欄なら期限を書かない if (json.ended) { const intro = document.querySelector('.card.intro'); intro.innerHTML = '

学園祭は終了しました。ご来場ありがとうございました。

'; const hint = document.createElement('p'); hint.className = 'hint'; hint.textContent = '登録済みの方は、ログインすると残高と利用履歴を確かめられます。' + (json.refund_deadline ? '残高が残っている方は、土佐塾中学・高等学校 事務室で ' + json.refund_deadline + ' まで返金対応しております。' : '残高が残っている方は、土佐塾中学・高等学校 事務室までお問い合わせください。'); intro.appendChild(hint); } // 新規登録を止めている(全体設定、または学園祭の終了後):「はじめての方」は押せなくし、ログインを開く const canRegister = json.open !== false; const regTab = document.querySelector('[data-auth="register"]'); regTab.disabled = !canRegister; if (!canRegister) { $('registerForm').innerHTML = json.ended ? '

学園祭は終了しました。新しい登録はできません。

' : '

ただいま新しい登録を受け付けていません。' + '学園祭本部で紙のプリペイドカードをお求めください。

'; } setAuthTab(canRegister ? 'register' : 'login'); } function setAuthTab(name) { document.querySelectorAll('[data-auth]').forEach((b) => b.classList.toggle('active', b.dataset.auth === name)); document.querySelectorAll('[data-auth-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.authPanel !== name)); $('waitBox').classList.add('hidden'); document.querySelector('.auth-tabs').classList.remove('hidden'); } document.querySelectorAll('[data-auth]').forEach((b) => { b.addEventListener('click', () => setAuthTab(b.dataset.auth)); }); // PIN の欄は数字だけ・4桁まで(全角数字も半角にそろえる) ['regPin', 'regPin2', 'loginPin'].forEach((id) => { $(id).addEventListener('input', () => { const el = $(id); const v = el.value.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)).replace(/\D/g, '').slice(0, 4); if (el.value !== v) el.value = v; }); }); $('registerForm').addEventListener('submit', async (ev) => { ev.preventDefault(); const name = $('regName').value.trim(); const pin = $('regPin').value; // 文字数はサーバーと同じ数え方(サロゲートペアの絵文字も1文字) const len = Array.from(name).length; if (len < 4 || len > 16) return alertDialog('アカウント名は4〜16文字で決めてください', 3000, 'warn'); if (!/^\d{4}$/.test(pin)) return alertDialog('PINは数字4桁で決めてください', 3000, 'warn'); // PIN が使えないときは2つの欄とも空にする(消して入れ直す手間を省く。依頼元の要望) const clearPins = () => { $('regPin').value = ''; $('regPin2').value = ''; }; if (isWeakPin(pin)) { clearPins(); return alertDialog(WEAK_PIN_TEXT, 4000, 'warn'); } if (pin !== $('regPin2').value) { clearPins(); return alertDialog('確認のPINが一致しません。もう一度入れてください', 3000, 'warn'); } const ok = await customConfirm( '次の内容で登録します。

アカウント名:' + escapeHtml(name) + '
' + 'PIN:' + pin + '

' + '別のスマホで開くときに必要です。必ず控えてください。' ); if (!ok) return; const json = await call('register', { name: name, pin: pin }, '登録できませんでした'); if (!json) return; alertDialog('登録しました。本部窓口でQRコードを見せてチャージしてください。', 4000); boot(); }); $('loginForm').addEventListener('submit', async (ev) => { ev.preventDefault(); const name = $('loginName').value.trim(); const pin = $('loginPin').value; if (!name || !/^\d{4}$/.test(pin)) return alertDialog('アカウント名とPIN(数字4桁)を入れてください', 3000, 'warn'); // この端末が前に出した許可待ちが残っていれば先に取り下げる(残すと、ログイン後に自分宛ての確認が出る) await cancelOwnRequests(); const json = await call('login', { name: name, pin: pin }, 'ログインできませんでした'); if (!json) return; $('loginPin').value = ''; if (json.pending) return startWait(json); boot(); }); // ---- 許可を待つ ---- function startWait(json) { stopWait(); wait = { request_id: json.request_id, secret: json.secret, room: json.notify_room, remain: json.wait_seconds || 120, timer: null, poll: null }; rememberOwnRequest(json.request_id, json.secret); document.querySelectorAll('[data-auth-panel]').forEach((p) => p.classList.add('hidden')); document.querySelector('.auth-tabs').classList.add('hidden'); $('waitBox').classList.remove('hidden'); // 開いている端末へ「確認して」の合図(取りこぼしても向こうは20秒おきに自分で確かめる) if (typeof gsRT !== 'undefined' && wait.room) gsRT.notify(wait.room, 'account_changed'); waitTick(); wait.timer = setInterval(waitTick, 1000); wait.poll = setInterval(waitCheck, 2000); } function waitTick() { if (!wait) return; const m = Math.floor(wait.remain / 60); const s = wait.remain % 60; $('waitTimer').textContent = 'あと ' + m + ':' + String(s).padStart(2, '0'); wait.remain = Math.max(0, wait.remain - 1); } async function waitCheck() { if (!wait) return; const w = wait; let json; try { json = await api('login_wait', { request_id: w.request_id, secret: w.secret }); } catch (e) { return; // 通信の揺れは次回に任せる } if (wait !== w || !json.ok) return; if (json.status === 'approved') { stopWait(); boot(); } else if (json.status === 'denied') { stopWait(); setAuthTab('login'); alertDialog('ほかの端末でログインが許可されませんでした。', 4000, 'warn'); } else if (json.status === 'expired') { stopWait(); setAuthTab('login'); alertDialog('時間内に許可されませんでした。もう一度ログインしてください。', 4000, 'warn'); } } function stopWait() { if (!wait) return; clearInterval(wait.timer); clearInterval(wait.poll); wait = null; } $('waitCancel').addEventListener('click', () => { // 待っている相手の端末に「取りやめた」を知らせる(確認のダイアログを閉じてもらう) const room = wait && wait.room; stopWait(); setAuthTab('login'); cancelOwnRequests().then(() => { // サーバーの許可待ちも取り下げてから合図する if (typeof gsRT !== 'undefined' && room) gsRT.notify(room, 'account_changed'); }); }); // ---- この端末が出した許可待ち ---- // ★ 取り下げずに残ると、この端末がその直後にログインしたとき、自分の許可待ちに // 「新しい端末がログインしようとしています」が出てしまう(2026-09-25 実機)。 // 画面を閉じても取り下げられるよう localStorage に控える(使えなければこの画面の間だけ) const OWN_KEY = 'gs2026_guest_requests'; let ownMemory = []; function ownRequests() { let list = ownMemory; try { list = JSON.parse(localStorage.getItem(OWN_KEY) || '[]') || []; } catch (e) { /* 使えなければメモリの控え */ } const limit = Date.now() - 10 * 60 * 1000; // 許可待ちは2分で切れるので、10分より古い控えは捨てる return list.filter((r) => r && r.id && r.at > limit); } function saveOwnRequests(list) { ownMemory = list; try { localStorage.setItem(OWN_KEY, JSON.stringify(list)); } catch (e) { /* メモリの控えだけ */ } } function rememberOwnRequest(id, secret) { saveOwnRequests(ownRequests().concat([{ id: id, secret: secret, at: Date.now() }]).slice(-10)); } function isOwnRequest(id) { return ownRequests().some((r) => r.id === id); } async function cancelOwnRequests() { const list = ownRequests(); for (const r of list) { try { await api('login_cancel', { request_id: r.id, secret: r.secret }); } catch (e) { /* 次の機会に */ } } } // ------------------------------------------------------------ // ログイン中 // ------------------------------------------------------------ function showApp(json) { $('auth').classList.add('hidden'); $('app').classList.remove('hidden'); render(json); watch(json.room); startHeartbeat(); loadDevices(); if (!qr && json.card_status === 'active') issueQr(); } function stopApp() { stopQr(); if (shownRequest) closeRequestDialog(); clearInterval(heartbeatTimer); heartbeatTimer = null; if (watchingRoom && typeof gsRT !== 'undefined') gsRT.leave(watchingRoom); watchingRoom = null; me = null; } /** 全体設定の「ゲストページを閉じる日時」を過ぎた(api/guest.php の page_closed):開いている画面も中身を消して案内だけにする */ function closePage(message) { stopApp(); $('app').classList.add('hidden'); $('auth').classList.add('hidden'); fail(message || '学園祭ゲストページの公開は終了しました。'); } function loggedOut(message) { if (!me) return; stopApp(); alertDialog(message || 'ログインが切れました。もう一度ログインしてください。', 4000, 'warn'); boot(); } function render(json) { me = json; document.querySelectorAll('.event-name').forEach((p) => { p.textContent = json.settings.event_name; }); toggleDashLinks(json.settings.dashboard_open); $('guestName').textContent = json.name + ' さん'; $('cardNo').textContent = 'No. ' + json.card_id; // 停止の正本は guest_cards.status(紙のカードと同じ) const status = json.card_status; $('stoppedBadge').textContent = status === 'refunded' ? '返金済み(利用できません)' : '利用停止中'; $('stoppedBadge').classList.toggle('hidden', status === 'active'); const m = json.money; $('balance').textContent = yen(m.balance); $('deposited').textContent = yen(m.deposited); $('spent').textContent = yen(m.spent); if (status !== 'active') { showQrUnavailable(status === 'refunded' ? '返金済みのため、QRコードは表示できません。' : '利用を止めています。学園祭本部にお問い合わせください。'); } else if (!qr && !$('qrUnavailable').classList.contains('hidden')) { // 本部で利用を再開した・練習データの削除で返金が消えた → QRを出し直す $('qrUnavailable').classList.add('hidden'); issueQr(); } $('zeroHint').classList.toggle('hidden', !(status === 'active' && m.balance <= 0)); gsRenderAccountHistory($('historyTable'), json.history, { guest: true }); $('refundNote').textContent = json.settings.refund_deadline ? '残高の返金は本部窓口で ' + json.settings.refund_deadline + ' まで承ります。このQRコードをお見せください。' : '残高の返金は本部窓口で承ります。このQRコードをお見せください。'; $('lockedNote').classList.toggle('hidden', !json.locked); $('unlockLogin').classList.toggle('hidden', !json.locked); renderNotices(json.notices || []); askRequests(json.requests || []); } /** 画面を閉じずに数字・履歴・確認だけを取り直す(QRには触れない) */ let refreshing = false; let refreshAgain = false; async function refresh() { if (!me) return; if (refreshing) { refreshAgain = true; return; } refreshing = true; try { const json = await api('state', { visible: document.visibilityState === 'visible' }); if (json.ok && json.logged_in) render(json); else if (json.ok && !json.logged_in) loggedOut('ログインが切れました。もう一度ログインしてください。'); else if (!json.ok && json.error === 'page_closed') closePage(json.message); } catch (e) { // 通信の揺れは次の合図に任せる } finally { refreshing = false; if (refreshAgain) { refreshAgain = false; refresh(); } } } // ---- リアルタイム(→ ../js/realtime.js) ---- /** * この口座で決済・入出金・取消があったら、すぐ取り直す。ほかの端末のログインの確認もこの合図で来る。 * ルームに入るのは最初の1回だけ。 */ function watch(room) { if (!room || watchingRoom || typeof gsRT === 'undefined') return; watchingRoom = room; gsRT.join(room, { account_changed: () => { if (qr) checkUsed(); refresh(); loadDevices(); } }); } function startHeartbeat() { clearInterval(heartbeatTimer); heartbeat(); heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS); } /** 画面を開いている合図。見えていないときは送らない(=開いている端末に数えない) */ async function heartbeat() { if (!me || document.visibilityState !== 'visible') return; let json; try { json = await api('heartbeat'); } catch (e) { return; } if (json.ok) askRequests(json.requests || []); } /** * 画面を閉じた・ほかのアプリへ移った・スリープしたら、すぐ「開いていない端末」にする。 * ★ 送らないと最後の合図から45秒は開いている扱いのままで、その間のログインがこの端末の許可待ちになる。 * keepalive はページが閉じても送り切るための指定 */ function sendAway() { if (!me) return; try { fetch('../api/guest.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', keepalive: true, body: JSON.stringify({ op: 'away' }) }).catch(() => {}); } catch (e) { /* 送れなくても45秒で開いていない扱いになる */ } } window.addEventListener('pagehide', sendAway); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') return sendAway(); if (!me) return; heartbeat(); refresh(); // 画面に戻ってきたとき、期限切れのQRを作り直す if (!qr && !$('qrBox').classList.contains('hidden')) issueQr(); }); // ---- ほかの端末のログイン ---- /** * 許可を待っているログインの確認。**サーバーの「いま待っている一覧」に合わせて出し入れする。** * - 一覧に来た → 1件ずつ出す(この端末が出したもの・もう答えたものは出さない) * - 表示中のものが一覧から消えた(相手が「やめる」・時間切れ・別の端末が答えた)→ 閉じる * ★ 以前は customConfirm で出しっぱなしだったため、相手が「やめる」を押しても残り、 * もう一度ログインを試すと確認が2つ重なった(2026-09-25 実機)。 * 一覧は state・heartbeat(20秒おき)・合図(account_changed)のたびに届く */ let shownRequest = null; // 表示中の request_id let requestBusy = false; // 許可・拒否を送っている間 function askRequests(requests) { const waiting = requests.filter((r) => !asked[r.request_id] && !isOwnRequest(r.request_id)); const dialog = $('requestDialog'); if (shownRequest && !requestBusy && !waiting.some((r) => r.request_id === shownRequest)) { closeRequestDialog(); alertDialog('ログインの確認は終わりました(相手の端末で取りやめた・時間切れ・ほかの端末で答えた)。', 3500); } if (shownRequest || !waiting.length) return; const r = waiting[0]; shownRequest = r.request_id; $('requestText').textContent = '端末:' + (r.device || '不明') + '(' + String(r.created_at).slice(11, 16) + ')'; if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open', ''); } function closeRequestDialog() { const dialog = $('requestDialog'); if (typeof dialog.close === 'function' && dialog.open) dialog.close(); else dialog.removeAttribute('open'); shownRequest = null; } async function answerRequest(approve) { const id = shownRequest; if (!id || requestBusy) return; requestBusy = true; asked[id] = true; const json = await call(approve ? 'approve_login' : 'deny_login', { request_id: id }); requestBusy = false; closeRequestDialog(); if (json) { alertDialog(approve ? 'ログインを許可しました' : 'ログインを断りました。心当たりが無ければPINを変えてください。', 3500, approve ? undefined : 'warn'); loadDevices(); askRequests(json.requests || []); // ほかにも待っていれば続けて出す } } $('requestApprove').addEventListener('click', () => answerRequest(true)); $('requestDeny').addEventListener('click', () => answerRequest(false)); // Esc で閉じても答えたことにしない(相手は待ち続ける)。次の一覧でまた出す $('requestDialog').addEventListener('cancel', (ev) => ev.preventDefault()); function renderNotices(list) { if (!list.length) return $('noticeBox').classList.add('hidden'); const last = list[list.length - 1]; $('noticeText').textContent = String(last.created_at).slice(11, 16) + ' に「' + (last.device || '不明な端末') + '」から、確認なしでログインがありました' + (list.length > 1 ? '(ほか ' + (list.length - 1) + ' 件)' : '') + '。' + 'ほかに開いている端末が無かったため、そのまま入っています。'; $('noticeBox').classList.remove('hidden'); } $('noticeOk').addEventListener('click', async () => { if (await call('ack_notices')) $('noticeBox').classList.add('hidden'); }); $('noticeKick').addEventListener('click', async () => { if (!(await kickOthers())) return; await call('ack_notices'); $('noticeBox').classList.add('hidden'); alertDialog('ほかの端末をログアウトさせました。
PINを知られている可能性があるので、「PINを変える」から変更してください。', 6000); }); // ---- 端末の一覧 ---- async function loadDevices() { if (!me) return; let json; try { json = await api('devices'); } catch (e) { return; } if (!json.ok) return; const list = $('deviceList'); list.innerHTML = ''; json.devices.forEach((d) => { const li = document.createElement('li'); const left = document.createElement('span'); const name = document.createElement('span'); name.className = 'device-name'; name.textContent = d.device || '不明な端末'; left.appendChild(name); if (d.this) { const tag = document.createElement('span'); tag.className = 'this'; tag.textContent = 'この端末'; left.appendChild(tag); } const meta = document.createElement('span'); meta.className = 'device-meta' + (d.online ? ' online' : ''); meta.textContent = (d.online ? '開いています・' : '') + String(d.created_at).slice(11, 16) + ' から'; li.append(left, meta); list.appendChild(li); }); $('kickOthers').classList.toggle('hidden', json.devices.length < 2); } /** この端末以外をログアウト。ログアウトさせた端末は合図を受けて画面を閉じる */ async function kickOthers() { const ok = await customConfirm('この端末以外をすべてログアウトさせます。
ご家族の端末もログアウトします。'); if (!ok) return false; const json = await call('logout_others'); if (!json) return false; if (typeof gsRT !== 'undefined' && me) gsRT.notify(me.room, 'account_changed'); loadDevices(); return true; } $('kickOthers').addEventListener('click', async () => { if (await kickOthers()) alertDialog('ほかの端末をログアウトさせました', 3000); }); $('changePin').addEventListener('click', async () => { const pin = await customPrompt('新しいPIN(数字4桁)を入れてください。
1111・1234・1212 のような推測されやすいPINは使えません。'); if (!pin) return; // 2回目を入れさせる前に、形と弱さを確かめる(サーバーと同じ判定) if (!/^\d{4}$/.test(String(pin).trim())) return alertDialog('PINは数字4桁で入れてください', 3000, 'warn'); if (isWeakPin(String(pin).trim())) return alertDialog(WEAK_PIN_TEXT, 4000, 'warn'); const pin2 = await customPrompt('確認のため、もう一度入れてください。'); if (!pin2) return; if (String(pin).trim() !== String(pin2).trim()) return alertDialog('PINが一致しません。もう一度やり直してください', 3000, 'warn'); if (await call('change_pin', { pin: String(pin).trim() }, 'PINを変えられませんでした')) { alertDialog('PINを変えました。ご家族にも新しいPINを伝えてください。', 4000); refresh(); } }); $('unlockLogin').addEventListener('click', async () => { const ok = await customConfirm('新しい端末からのログインの停止を解除します。
心当たりの無い失敗なら、先に「PINを変える」をおすすめします。'); if (ok && await call('unlock')) refresh(); }); $('logout').addEventListener('click', async () => { const ok = await customConfirm( 'この端末からログアウトします。
' + '残高は消えません。もう一度開くときは、アカウント名とPINが必要です。' ); if (!ok) return; if (await call('logout')) { stopApp(); boot(); } }); // ------------------------------------------------------------ // 使い捨てQR // ------------------------------------------------------------ function showQrUnavailable(text) { stopQr(); $('qrBox').classList.add('hidden'); $('qrDone').classList.add('hidden'); $('renewQr').classList.add('hidden'); $('qrUnavailable').textContent = text; $('qrUnavailable').classList.remove('hidden'); } async function issueQr() { if (qrFlight && qrFlight.generation === qrGeneration) return; stopQr(); const flight = { generation: qrGeneration }; qrFlight = flight; let json; try { json = await api('issue_qr'); } catch (e) { if (flight.generation !== qrGeneration) return; console.error(e); return alertDialog('通信できませんでした。もう一度お試しください。', 3000, 'warn'); } finally { if (qrFlight === flight) qrFlight = null; } if (flight.generation !== qrGeneration || !me) return; if (!json.ok) { if (json.error === 'unauthorized') loggedOut(json.message); else showQrUnavailable(json.message || 'QRコードを表示できませんでした。'); return; } $('qrUnavailable').classList.add('hidden'); $('qrDone').classList.add('hidden'); $('qrBox').classList.remove('hidden'); $('renewQr').textContent = 'QRコードを作り直す'; $('renewQr').classList.remove('hidden'); const box = $('qrImage'); box.innerHTML = ''; box.appendChild(createQRcode(json.payload)); qr = { token: json.token, remain: json.expires_in, timer: null, poll: null }; tick(); qr.timer = setInterval(tick, 1000); // ★ 使われたことの検知は主にソケットの合図(account_changed → checkUsed)。 // これは取りこぼし・通信サーバー未配備のときの保険(保護者ページと同じ間隔) const rtOk = typeof gsRT !== 'undefined' && gsRT.available(); qr.poll = setInterval(checkUsed, rtOk ? 15000 : 4000); } function stopQr() { qrGeneration++; if (!qr) return; clearInterval(qr.timer); clearInterval(qr.poll); qr = null; } function tick() { if (!qr) return; qr.remain = Math.max(0, qr.remain - 1); const m = Math.floor(qr.remain / 60); const s = qr.remain % 60; $('qrTimer').textContent = '有効期限まで ' + m + ':' + String(s).padStart(2, '0'); if (qr.remain <= 0) { // 期限切れ。画面を見ているときだけ自動で作り直す if (document.visibilityState === 'visible') issueQr(); else { stopQr(); $('qrTimer').textContent = '期限切れです。作り直してください。'; } } } const DONE_HEAD = { purchase: 'お支払いが完了しました', charge: 'チャージしました', refund: '返金しました', transfer: 'カードの残高をまとめました' }; /** 表示中のQRが使われたか確かめる。使われたら「完了」を出す */ async function checkUsed() { if (!qr || document.visibilityState !== 'visible') return; const token = qr.token; let json; try { json = await api('qr_status', { token: token }); } catch (e) { return; } if (!qr || qr.token !== token || !json.ok) return; qr.remain = json.remain; // サーバーの残り秒数に合わせ直す if (json.used) { stopQr(); const p = json.paid || {}; $('qrBox').classList.add('hidden'); $('qrDone').classList.remove('hidden'); $('qrDoneHead').textContent = DONE_HEAD[p.kind] || '完了しました'; $('qrDoneAmount').textContent = p.total !== undefined ? yen(p.total) + '円' : ''; $('qrDoneShop').textContent = p.kind === 'purchase' ? (p.shop || '') : (p.balance !== null && p.balance !== undefined ? '残高 ' + yen(p.balance) + '円' : ''); // ★ 次のQRはボタンで出す。ここで作り直すと「完了」がすぐ消える $('renewQr').textContent = '次のQRコードを表示する'; refresh(); } } $('renewQr').addEventListener('click', issueQr); // ------------------------------------------------------------ /** * 推測されやすいPIN。★ サーバーの GakuensaiWebGuest::isWeakPin() と同じ判定にする * (確認の画面まで進んでから断られないよう、先に画面で弾く。最終判定はサーバー)。 * 同じ数字(1111)・連番(1234 / 9876、9→0 もつながる)・くり返し(1212)・対称(1221) */ const WEAK_PIN_TEXT = 'このPINは推測されやすいので使えません(同じ数字・連続した数字・くり返しなど)。別の4桁にしてください'; function isWeakPin(pin) { const d = String(pin).split('').map(Number); if (d.length !== 4) return false; if (d.every((x) => x === d[0])) return true; let up = true; let down = true; for (let i = 1; i < 4; i++) { if (d[i] !== (d[i - 1] + 1) % 10) up = false; if (d[i] !== (d[i - 1] + 9) % 10) down = false; } if (up || down) return true; if (d[0] === d[2] && d[1] === d[3]) return true; // 1212 if (d[0] === d[3] && d[1] === d[2]) return true; // 1221 return false; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } boot(); })();