/**
* 学園祭ゲストページ(スマホのゲスト。紙のプリペイドカードの代わり)
*
* 依存: /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();
})();