PageSourceSearch

https://mens-eminal.jp/reserve/assets/js/new_booking.js?v=...

js mens-eminal.jp collected 2026-09-30 22:32:37 UTC 40,225 bytes, 915 lines download raw bytes

1/**
2 * <フロント側> assets/js/new_booking.js
3 * 役割:API通信・データ管理・バリデーション・イベント制御などのロジック
4 * 依存:view.js (HTML側で先に読み込んであること)
5 */
6
7// ==========================================
8// ⚙️ 設定・定数・ルール定義
9// ==========================================
10//const API_BASE_URL = 'https://epi-reserve.com/reserve/api/reserve';
11//api_client.jsで管理
12
13const urlParams = new URLSearchParams(window.location.search);
14const CURRENT_DEPARTMENT = urlParams.get('department') || 'datsumou';
15let CURRENT_NINZU = urlParams.get('ninzu') || 1;
16const CURRENT_REFMEDIA = urlParams.get('refmedia') || '';
17const CURRENT_DEBUG = urlParams.get('debug') || document.querySelector('input[name="debug"]')?.value || '';
18function getCurrentBrand() {
19  // 1. まず head.php で設定された最強の身分証を確認!
20  if (window.CURRENT_BRAND) {
21    return window.CURRENT_BRAND;
22  }
23
24  // 2. なければ物理タグ(万が一の保険)を確認!
25  const brandInput = document.getElementById('sys-current-brand');
26  if (brandInput && brandInput.value) {
27    return brandInput.value;
28  }
29
30  // 3. どっちもなければ最終手段
31  return 'レディースエミナル';
32}
33const MENU_RULES = {
34  'datsumou': { allowUnderage: true, requireBmi: false },
35  'diet': { allowUnderage: false, requireBmi: true },
36  'default': { allowUnderage: false, requireBmi: false }
37};
38
39let currentSelectedShopName = '';
40
41// ==========================================
42// 🚀 初期化処理
43// ==========================================
44document.addEventListener('DOMContentLoaded', () => {
45  loadShopList();
46  setupNinzuToggle();
47  setupFormEvents();
48  setupBackButtonEvents();
49
50  // モーダルを閉じるイベント(Viewに丸投げ)
51  document.getElementById('js-modal-close')?.addEventListener('click', View.closeModal);
52  document.getElementById('js-modal-btn')?.addEventListener('click', View.closeModal);
53  document.getElementById('js-modal-overlay')?.addEventListener('click', View.closeModal);
54  // ==========================================
55  // 🛡️ ブラウザバック(戻るボタン)の離脱防止
56  // ==========================================
57  // ページ読み込み時に、ブラウザの履歴に「現在のページ」をもう一つダミーとして追加しておく
58  history.pushState(null, null, location.href);
59
60  window.addEventListener('popstate', (e) => {
61    // ユーザーが「戻る」を押してダミー履歴が消費された瞬間にモーダル発動
62    View.showModal({
63      title: '確認',
64      body: '<p style="text-align: center;">入力内容は保存されません。<br>このページから移動してもよろしいですか?</p>',
65      isLocked: true, // 右上の×ボタンは一旦隠し、下の2択を強制する
66      buttonsHTML: `
67        <div style="display: flex; gap: 10px; justify-content: center; width: 100%;">
68          <button type="button" id="btn-leave-page" style="flex: 1; padding: 10px; background: #AAA; color: #fff; border: none; border-radius: 4px; cursor: pointer;">
69            ページを離れる
70          </button>
71          <button type="button" id="btn-cancel-leave" class="btn-primary" style="flex: 1; padding: 10px;">
72            キャンセル
73          </button>
74        </div>
75        `
76    });
77
78    // 「ページを離れる」が押されたら、本当の前のページへ戻す
79    document.getElementById('btn-leave-page').addEventListener('click', () => {
80      history.back();
81    });
82
83    // 「キャンセル」が押されたら、モーダルを閉じて再びダミー履歴をセット(罠の再設置)
84    document.getElementById('btn-cancel-leave').addEventListener('click', () => {
85      history.pushState(null, null, location.href);
86      View.closeModal();
87    });
88  });
89  // =========================================================
90  // 🚀 【特急券システム】
91  // =========================================================
92  const initialMcd = window.AppConfig?.initialMcd || new URLSearchParams(window.location.search).get('mcd') || '';
93
94  if (initialMcd !== '') {
95    console.log(`🚀 特急券検知: 店舗CD [${initialMcd}] のボタン出現を待機中...`);
96
97    const checkInterval = setInterval(() => {
98      // 🎯 【修正】data-id で正確にロックオン!
99      const targetShopBtn = document.querySelector(`.btn-shop[data-id="${initialMcd}"]`);
100
101      if (targetShopBtn) {
102        clearInterval(checkInterval);
103        console.log(`🎯 店舗ボタン発見!自動遷移を実行します。`);
104
105        targetShopBtn.click();
106
107        setTimeout(() => {
108          View.changeSections(['section-step1'], ['section-step2']);
109          View.updateStepIndicator(2);
110          window.scrollTo({ top: 0, behavior: 'smooth' });
111        }, 200);
112      }
113    }, 100);
114
115    setTimeout(() => {
116      clearInterval(checkInterval);
117    }, 5000);
118  }
119  // =========================================================
120});
121
122// ==========================================
123// 🏢 STEP 1: 店舗リストの取得と描画
124// ==========================================
125async function loadShopList() {
126  const listContainer = document.getElementById('shop-list');
127  try {
128    // 万が一の保険として、もし空なら「レディースエミナル」をデフォルトにします。
129    const targetBrand = window.CURRENT_BRAND || 'レディースエミナル';
130
131    // APIへ「私はこのブランドです!」と名札(brandパラメータ)を付けて通信!
132    const json = await callApi('get_shops.php', {
133      department: CURRENT_DEPARTMENT,
134      refmedia: CURRENT_REFMEDIA,
135      brand: targetBrand,
136      debug: CURRENT_DEBUG
137    });
138    if (json.status !== 'success') throw new Error(json.message);
139
140    listContainer.innerHTML = '';
141    const fragment = document.createDocumentFragment();
142    const areas = json.data;
143
144    for (const areaCode in areas) {
145      const area = areas[areaCode];
146      const accordionItem = document.createElement('div');
147      accordionItem.className = 'c-accordion-item';
148
149      const headerBtn = document.createElement('button');
150      headerBtn.type = 'button';
151      headerBtn.className = 'c-accordion-header js-accordion-toggle';
152      headerBtn.innerHTML = `
153                <span class="area-name">${area.area_name}</span>
154                <span class="shop-count">【${area.shops.length}院】 <span class="toggle-mark">+</span></span>
155            `;
156
157      const accordionBody = document.createElement('div');
158      accordionBody.className = 'c-accordion-body';
159      accordionBody.style.display = 'none';
160
161      const btnGroup = document.createElement('div');
162      btnGroup.className = 'shop-grid';
163
164      area.shops.forEach(shop => {
165        const btn = document.createElement('button');
166        btn.type = 'button';
167        btn.className = 'btn-shop';
168        btn.innerHTML = shop.name_html;
169        btn.dataset.id = shop.id;
170        btn.addEventListener('click', (e) => selectShop(shop.id, shop.name_html, e.currentTarget));
171        btnGroup.appendChild(btn);
172      });
173
174      accordionBody.appendChild(btnGroup);
175
176      // 🚀 アコーディオン開閉の見た目は View に任せる
177      headerBtn.addEventListener('click', function () {
178        View.toggleAccordion(this, accordionBody);
179      });
180
181      accordionItem.appendChild(headerBtn);
182      accordionItem.appendChild(accordionBody);
183      fragment.appendChild(accordionItem);
184    }
185    listContainer.appendChild(fragment);
186
187  } catch (error) {
188    console.error('通信エラー:', error);
189    listContainer.innerHTML = '<p style="color:red;">店舗の読み込みに失敗しました。</p>';
190  }
191}
192
193// ==========================================
194// 👥 人数切り替えスイッチの制御
195// ==========================================
196function setupNinzuToggle() {
197  const ninzuBtns = document.querySelectorAll('.btn-ninzu');
198  if (ninzuBtns.length === 0) return;
199
200  ninzuBtns.forEach(btn => {
201    btn.addEventListener('click', (e) => {
202      // ロジック(状態)の更新
203      CURRENT_NINZU = parseInt(e.currentTarget.dataset.ninzu, 10);
204      // 見た目の更新は View に任せる
205      View.updateNinzuButtons(ninzuBtns, e.currentTarget);
206    });
207  });
208}
209
210// ==========================================
211// 🏢 STEP 1 -> STEP 2 (店舗選択時の処理)
212// ==========================================
213function selectShop(shopId, shopName, clickedBtn) {
214  // 🚀 新設したハイライト関数を発動!
215  if (clickedBtn) {
216    View.highlightSelectedShop(clickedBtn);
217  }
218  // 🚀 画面切り替えとステップバーの進行を View に依頼
219  View.changeSections(['step-tenpo', 'step-form', 'step-kakunin'], ['step-date']);
220  View.updateStepIndicator(2);
221
222  let form = document.getElementById('customer-form');
223  if (form) {
224    if (!document.getElementById('hidden-shop-name')) {
225      form.insertAdjacentHTML('beforeend', `<input type="hidden" id="hidden-shop-name" value="">`);
226    }
227    document.getElementById('hidden-shop-name').value = shopName;
228  }
229  currentSelectedShopName = shopName;
230  loadCalendar(shopId, '');
231}
232
233// ==========================================
234// 📅 STEP 2: カレンダーAPIと描画
235// ==========================================
236// <フロント側> assets/js/booking.js の loadCalendar を改修
237
238async function loadCalendar(shopId, targetDate = '') {
239  console.log("🔥 突撃前のID確認! shopId:", shopId);
240  const calendarWrapper = document.getElementById('calendar-wrapper');
241
242  const currentHeight = calendarWrapper.offsetHeight;
243  if (currentHeight > 0) calendarWrapper.style.minHeight = currentHeight + 'px';
244  calendarWrapper.innerHTML = '<div class="loading-spinner">情報を読み込んでいます...</div>';
245
246  try {
247    // カレンダーを開く時にも身分証を取得
248    const targetBrand = getCurrentBrand();
249
250    // ① callApiに渡す荷物(パラメータ)を準備
251    const calParams = {
252      shop_id: shopId,
253      department: CURRENT_DEPARTMENT,
254      ninzu: CURRENT_NINZU,
255      brand: targetBrand,
256      debug: CURRENT_DEBUG
257    };
258    if (targetDate) calParams.date = targetDate; // 日付があれば追加
259
260    // ② toolsサーバーへのURL(店舗詳細)も targetBrand に直す
261    const toolsApiUrl = 'https://eminalclinic.jp/tools/api/get_shop_detail.php';
262    const apiKey = 'eminal_yM6*VqY9lNm';
263    const detailUrl = `${toolsApiUrl}?facility_code=${shopId}&brand=${encodeURIComponent(targetBrand)}&api_key=${apiKey}`;
264
265    console.log("🚨 JSが実際に叩こうとしているURLはこれだ!👉", detailUrl);
266    const calPromise = callApi('get_new_calendar.php', calParams);
267    const detailPromise = fetch(detailUrl)
268      .then(res => res.json())
269      .catch(err => {
270        console.warn('⚠️店舗詳細APIの通信エラー(カレンダーのみで続行します):', err);
271        return { status: 'error', data: {} }; // 失敗時は空データを返す
272      });
273
274    // 両方の処理を待つ(detailPromiseがエラーでも全体は止まらない!)
275    const [calJson, detailJson] = await Promise.all([calPromise, detailPromise]);
276    console.log('🔥toolsからの生データ:', detailJson);
277    if (calJson.status !== 'success') throw new Error(calJson.message);
278
279    // 失敗してもカレンダーは出すためのフォールバック
280    const shopDetailData = detailJson.status === 'success' ? detailJson.data : {};
281
282    // 🚀流し込みを実行
283    renderShopInfoHeader(shopDetailData);
284    // 描画関数へ「カレンダーデータ」と「店舗詳細データ」の両方を渡す
285    calendarWrapper.innerHTML = CalendarView.buildHTML(
286      currentSelectedShopName,
287      calJson.start_date,
288      calJson.calendar,
289      false
290    );
291
292    // イベント付与時に詳細データも渡す
293    attachCalendarEvents(shopId, shopDetailData);
294
295    calendarWrapper.style.minHeight = '';
296
297    if (targetDate !== '') {
298      // スクロール処理 (以前のコードそのまま)
299      setTimeout(() => {
300        const anchor = document.getElementById('calendar-top-anchor');
301        if (anchor) window.scrollTo({ top: anchor.getBoundingClientRect().top + (window.pageYOffset || document.documentElement.scrollTop) - 20, behavior: 'smooth' });
302      }, 50);
303    }
304
305  } catch (error) {
306    console.error('通信エラー:', error);
307    calendarWrapper.innerHTML = '<p class="error-msg">情報の取得に失敗しました。</p>';
308    calendarWrapper.style.minHeight = '';
309  }
310}
311
312function attachCalendarEvents(shopId) {
313  document.querySelectorAll('.btn-nav').forEach(btn => {
314    btn.addEventListener('click', (e) => {
315      const targetBtn = e.currentTarget;
316      if (!targetBtn.disabled) loadCalendar(shopId, targetBtn.dataset.date);
317    });
318  });
319
320  // 🚀 【追加】現在時刻から60分以内の枠(準備が間に合わない枠)を封鎖する!
321  const now = new Date();
322  const thresholdTime = new Date(now.getTime() + 60 * 60 * 1000); // 現在時刻 + 60分
323
324  document.querySelectorAll('.btn-slot.ok').forEach(btn => {
325    const dateStr = btn.dataset.date; // 例: "2026-03-20"
326    const timeStr = btn.dataset.time; // 例: "10:30"
327
328    if (dateStr && timeStr) {
329      // Safari等のパースエラーを防ぐため、ハイフンをスラッシュに変換(2026/03/20 10:30 の形式へ)
330      const slotDateTime = new Date(`${dateStr.replace(/-/g, '/')} ${timeStr}`);
331
332      // 枠の時間が、現在時刻から60分後よりも「前」ならブロック!
333      if (slotDateTime < thresholdTime) {
334        btn.classList.remove('ok');
335        btn.classList.add('ng'); // 予約不可のクラスに変更
336        btn.innerHTML = '受付時間を過ぎています';    // 「◎」などのテキストを予約不可のマーク(ー や ×)に変更
337        btn.disabled = true;     // ボタンを無効化
338        return; // クリックイベントを付けずに次のボタンの処理へスキップ
339      }
340    }
341
342    // 60分以上の猶予がある安全な枠のみ、クリックイベントを付与する!
343    btn.addEventListener('click', (e) => {
344      const targetBtn = e.currentTarget;
345      selectDateTime(shopId, targetBtn.dataset.date, targetBtn.dataset.time);
346    });
347  });
348}
349
350function selectDateTime(shopId, date, time) {
351  // 1. フォームの隠しフィールドに選択情報をセット
352  let form = document.getElementById('customer-form');
353  if (form) {
354    if (!document.getElementById('hidden-shop-id')) {
355      form.insertAdjacentHTML('beforeend', `
356          <input type="hidden" id="hidden-shop-id" name="shop_id" value="">
357          <input type="hidden" id="hidden-date" name="date" value="">
358          <input type="hidden" id="hidden-time" name="time" value="">
359      `);
360    }
361    document.getElementById('hidden-shop-id').value = shopId;
362    document.getElementById('hidden-date').value = date;
363    document.getElementById('hidden-time').value = time;
364  }
365
366  // 2. 入力画面(STEP 3)のヘッダー情報を更新
367  // renderShopInfoHeader でセットされた店舗名と、選択した日時を表示
368  const summaryShopname = document.getElementById('summary-shopname');
369  if (summaryShopname) summaryShopname.innerHTML = currentSelectedShopName;
370
371  const summaryDatetime = document.getElementById('summary-datetime');
372  if (summaryDatetime) {
373    // 無機質な「YYYY-MM-DD」を、「YYYY年M月D日」に変換する
374    // ハイフンで切り刻んで配列にする ['2026', '05', '05']
375    const [year, month, day] = date.split('-');
376
377    // parseInt() を使うことで、「05月」を「5月」のようにゼロ埋めを消して綺麗にします
378    const formattedDate = `${year}年${parseInt(month, 10)}月${parseInt(day, 10)}日`;
379
380    // 変換した日付と時間を合体させて表示(ここにはHTMLタグは無いので textContent でOK)
381    summaryDatetime.textContent = `${formattedDate} ${time}`;
382  }
383
384  // 🚀 3. 【重要】2名予約時の「二人目入力エリア」の表示制御
385  // CURRENT_NINZU が 2 の場合のみ、二人目の入力セクションを表示する
386  const secSecond = document.getElementById('sec-second-person');
387  if (secSecond) {
388    if (CURRENT_NINZU === 2) {
389      secSecond.style.display = 'block';
390      // 二人目が表示される際は、バリデーション対象になることを意識
391    } else {
392      secSecond.style.display = 'none';
393    }
394  }
395
396  // 4. メニューごとの個別ルール適用(BMI表示や未成年警告など)
397  const rule = MENU_RULES[CURRENT_DEPARTMENT] || MENU_RULES['default'];
398  View.toggleDisplay('sec-bmi', rule.requireBmi);
399  View.toggleDisplay('label-job-10', rule.allowUnderage, 'inline-block');
400
401  // 5. 画面切り替え(カレンダーから入力フォームへ)
402  View.changeSections(['step-date'], ['step-form']);
403  View.scrollTo('step-form');
404}
405// ==========================================
406// 🏢 店舗ヘッダーへのデータ流し込み処理
407// ==========================================
408function renderShopInfoHeader(shopData, shopId) {
409  console.log('🔥 流し込み部隊到着!受け取ったデータ:', shopData);
410
411  const headerWrap = document.getElementById('js-shop-info-header');
412  if (!headerWrap) {
413    console.error('🚨 【緊急】index.phpに "js-shop-info-header" の枠が見つかりません!');
414    return;
415  }
416
417  try {
418    // 1. 店舗名(変数が無いエラーを防ぐため、直接文字列をフォールバックに)
419    const nameEl = document.getElementById('js-shop-name');
420    if (nameEl) nameEl.textContent = shopData.facility_name || '店舗名 取得中...';
421
422    // 2. 住所
423    const addressEl = document.getElementById('js-shop-address');
424    if (addressEl) addressEl.innerHTML = shopData.address || '情報取得中...';
425
426    // 3. 電話番号
427    const telEl = document.getElementById('js-shop-tel');
428    if (telEl) telEl.textContent = shopData.tel || '情報取得中...';
429
430    // 4. お知らせ(あれば表示、なければ隠す)
431    const noticeEl = document.getElementById('js-shop-notice');
432    if (noticeEl) {
433      // APIから生の配列データ(notices)が届いているか確認!
434      if (shopData.notices && shopData.notices.length > 0) {
435
436        // 👑 新規予約(new_booking)専用の検問!
437        // 'all'(全員)と 'new'(新規)だけを通し、'existing'(既存会員向け)は弾く!!
438        const targetNotices = shopData.notices.filter(notice =>
439          notice.info_type === 'all' || notice.info_type === 'new'
440        );
441
442        // priorityの値が小さいほど上に表示されます
443        const noticePriority = { 'happening': 1, 'holiday': 2, 'normal': 3 };
444        targetNotices.sort((a, b) => (noticePriority[a.type] || 99) - (noticePriority[b.type] || 99));
445
446        if (targetNotices.length > 0) {
447          // 通過したお知らせを <br><br> で美しく連結する
448          const combinedHtml = targetNotices.map(n => n.body).join('<br><br>');
449
450          noticeEl.innerHTML = combinedHtml;
451          noticeEl.style.display = 'block';
452        } else {
453          // 新規向けのお知らせが1件も無かった場合は隠す
454          noticeEl.style.display = 'none';
455        }
456      } else {
457        // そもそもお知らせ自体が無い場合も隠す
458        noticeEl.style.display = 'none';
459      }
460    }
461
462    // 5. 詳細ボタンのイベント付与
463    const oldBtn = document.getElementById('btn-top-shop-detail');
464    if (oldBtn) {
465      const newBtn = oldBtn.cloneNode(true);
466      oldBtn.parentNode.replaceChild(newBtn, oldBtn);
467
468      // データがちゃんとあればボタンを表示してクリックイベントを付ける
469      if (shopData && Object.keys(shopData).length > 0) {
470        newBtn.addEventListener('click', () => {
471          View.showShopModal(shopData);
472        });
473        newBtn.style.display = 'inline-block';
474      } else {
475        newBtn.style.display = 'none'; // データが無い時は隠す
476      }
477    }
478
479    // 🚀 全ての準備が整ったら、枠全体を表示する!
480    headerWrap.style.display = 'block';
481    console.log('✅ 流し込み完了!ヘッダーを表示しました');
482
483  } catch (e) {
484    console.error('🚨 流し込み処理中にエラーが発生しました:', e);
485  }
486}
487// ==========================================
488// 📝 STEP 3: フォームの制御とバリデーション
489// ==========================================
490function setupFormEvents() {
491  // 🚀 年齢入力時の監視
492  document.getElementById('age')?.addEventListener('input', checkUnderageCondition);
493  document.getElementById('age_2')?.addEventListener('input', checkUnderageCondition);
494
495  // 🚀 職業変更時の監視(既存の forEach(radio => ...) に追加してもOKですが、以下のようにまとめても動きます)
496  document.querySelectorAll('input[name="job"], input[name="job_2"]').forEach(radio => {
497    radio.addEventListener('change', checkUnderageCondition);
498  });
499  const jobRadios = document.querySelectorAll('input[name="job"]');
500  jobRadios.forEach(radio => {
501    radio.addEventListener('change', (e) => {
502      const val = e.target.value;
503      // 🚀 【修正】allowUnderageの条件を撤廃!
504      // PHP側でメッセージを切り替えているため、中学生(10)か高校生(20)なら無条件で警告箱を表示する!
505      const showWarning = (val === '10' || val === '20');
506      View.toggleDisplay('warning-underage', showWarning);
507    });
508  });
509
510  const jobRadios2 = document.querySelectorAll('input[name="job_2"]');
511  jobRadios2.forEach(radio => {
512    radio.addEventListener('change', (e) => {
513      const val = e.target.value;
514      // 🚀 2人目も同様に、中高生なら無条件で表示!
515      const showWarning = (val === '10' || val === '20');
516      View.toggleDisplay('warning-underage-2', showWarning);
517    });
518  });
519  const btnCalcBmi = document.getElementById('btn-calc-bmi');
520  if (btnCalcBmi) {
521    btnCalcBmi.addEventListener('click', () => {
522      const hei = parseFloat(document.getElementById('hei').value);
523      const wei = parseFloat(document.getElementById('wei').value);
524      const resultBox = document.getElementById('bmi_result');
525
526      if (hei > 0 && wei > 0) {
527        resultBox.value = (wei / Math.pow(hei * 0.01, 2)).toFixed(2);
528        View.showInputError('err-bmi', '');
529      } else {
530        resultBox.value = '';
531        View.showInputError('err-bmi', '身長と体重を正しく入力してください。');
532      }
533    });
534  }
535
536  const btnSubmit = document.getElementById('btn-submit-form');
537  if (btnSubmit) {
538    btnSubmit.addEventListener('click', () => {
539      const errors = validateForm();
540
541      if (errors.length === 0) {
542        goToConfirmStep();
543      } else {
544        let errorHtml = '<ul style="color: #e43a6e; padding-left: 20px; margin: 0;">';
545        errors.forEach(msg => errorHtml += `<li style="margin-bottom: 8px;">${msg}</li>`);
546        errorHtml += '</ul>';
547
548        View.showModal({
549          type: 'error',
550          title: '<span class="material-icons" style="vertical-align: bottom;">warning</span> 入力エラー',
551          body: errorHtml,
552          btnText: '確認して修正する'
553        });
554
555        View.scrollToFirstError();
556      }
557    });
558  }
559
560  const btnFinalSubmit = document.getElementById('btn-final-submit');
561  if (btnFinalSubmit) {
562    btnFinalSubmit.addEventListener('click', () => {
563      // --- 1. 共通情報の取得 ---
564      const shopId = document.getElementById('hidden-shop-id').value;
565      const date = document.getElementById('hidden-date').value;
566      const time = document.getElementById('hidden-time').value;
567
568      // --- 2. 一人目の情報取得と整形 ---
569      const sei = document.getElementById('name_sei').value.trim();
570      const mei = document.getElementById('name_mei').value.trim();
571      const age = document.getElementById('age').value;
572      const jobVal = document.querySelector('input[name="job"]:checked')?.value;
573      const bmi = document.getElementById('bmi_result')?.value || '';
574      const hei = document.getElementById('hei')?.value || '';
575      const wei = document.getElementById('wei')?.value || '';
576      const tel = document.getElementById('tel').value.trim();
577      const email = document.getElementById('email').value.trim();
578
579      // 🚀 旧システム仕様:姓と名を全角スペースで結合
580      document.getElementById('post-name').value = `${sei} ${mei}`;
581      document.getElementById('post-age').value = age;
582      document.getElementById('post-job').value = jobVal;
583      document.getElementById('post-bmi').value = bmi;
584      document.getElementById('post-hei').value = hei;
585      document.getElementById('post-wei').value = wei;
586      document.getElementById('post-tel').value = tel;
587      document.getElementById('post-email').value = email;
588
589      // --- 3. 二人目の情報取得(2名予約時のみ実行) ---
590      if (CURRENT_NINZU === 2) {
591        const sei2 = document.getElementById('name_sei_2').value.trim();
592        const mei2 = document.getElementById('name_mei_2').value.trim();
593        const age2 = document.getElementById('age_2').value;
594        const jobVal2 = document.querySelector('input[name="job_2"]:checked')?.value;
595        const tel2 = document.getElementById('tel_2').value.trim();
596        const email2 = document.getElementById('email_2').value.trim();
597
598        // 🚀 二人目も全角スペース結合で射出
599        document.getElementById('post-name-2').value = `${sei2} ${mei2}`;
600        document.getElementById('post-age-2').value = age2;
601        document.getElementById('post-job-2').value = jobVal2;
602        document.getElementById('post-tel-2').value = tel2;
603        document.getElementById('post-email-2').value = email2;
604      } else {
605        // 🚀 【追加】1名予約時は隠しフィールドの残骸(ゴーストデータ)を消し去る
606        document.getElementById('post-name-2').value = '';
607        document.getElementById('post-age-2').value = '';
608        document.getElementById('post-job-2').value = '';
609        document.getElementById('post-tel-2').value = '';
610        document.getElementById('post-email-2').value = '';
611      }
612
613      // --- 4. 予約メタ情報 ---
614      document.getElementById('post-shop-id').value = shopId;
615      document.getElementById('post-date').value = date;
616      document.getElementById('post-time').value = time;
617      document.getElementById('post-department').value = CURRENT_DEPARTMENT;
618      document.getElementById('post-ninzu').value = CURRENT_NINZU;
619
620      // 🚀 脱毛メニューの場合、選択されたプランIDもセット
621      const planVal = document.querySelector('input[name="plan"]:checked')?.value || '';
622      const postPlan = document.getElementById('post-plan');
623      if (postPlan) postPlan.value = planVal;
624
625      // --- 5. 送信演出と実行 ---
626      View.showModal({
627        title: '予約処理中...',
628        body: `
629            <div style="text-align: center; padding: 20px 0;">
630                <div class="loader"></div>
631                <p style="margin-top: 20px; font-size: 0.95rem; color: #E63074; font-weight: bold;">
632                    ※予約確定処理中です。<br>ブラウザを閉じずにお待ちください。
633                </p>
634            </div>
635            `,
636        isLocked: true,
637        hideFooter: true
638      });
639
640      // 二重送信防止のロック
641      btnFinalSubmit.disabled = true;
642
643      // 基幹システムへ送信
644      document.getElementById('real-form').submit();
645    });
646  }
647}
648// 🚀 未成年条件を判定して同意ボックスを出し分ける関数
649function checkUnderageCondition() {
650  let isUnderage = false;
651
652  // 指定した人物が未成年条件に当てはまるかチェックする内部関数
653  const checkPerson = (ageId, jobName) => {
654    const ageStr = document.getElementById(ageId)?.value;
655    const jobChecked = document.querySelector(`input[name="${jobName}"]:checked`);
656    if (ageStr && jobChecked) {
657      const age = parseInt(ageStr, 10);
658      const job = jobChecked.value;
659      // 条件: 18歳以下 かつ (中学生[10] または 高校生[20])
660      if (age < 18 || job === '10' || job === '20') {
661        return true;
662      }
663    }
664    return false;
665  };
666
667  // 一人目のチェック
668  if (checkPerson('age', 'job')) isUnderage = true;
669
670  // 二人目のチェック (2名予約時のみ)
671  if (CURRENT_NINZU === 2) {
672    if (checkPerson('age_2', 'job_2')) isUnderage = true;
673  }
674
675  // 条件に合致すれば表示、しなければ隠す
676  View.toggleDisplay('sec-confirm-underage', isUnderage);
677}
678function validateForm() {
679  let errorMessages = [];
680  View.clearAllErrors();
681
682  const setError = (id, msg) => {
683    View.showInputError(id, msg);
684    errorMessages.push(msg);
685  };
686
687  const rule = MENU_RULES[CURRENT_DEPARTMENT] || MENU_RULES['default'];
688
689  const sei = document.getElementById('name_sei').value.trim();
690  const mei = document.getElementById('name_mei').value.trim();
691  const hiraganaRegex = /^[ぁ-んー]+$/;
692
693  if (!sei) setError('err-name-sei', '姓を入力してください。');
694  else if (!hiraganaRegex.test(sei)) setError('err-name-sei', '姓はひらがなで入力してください。');
695
696  if (!mei) setError('err-name-mei', '名を入力してください。');
697  else if (!hiraganaRegex.test(mei)) setError('err-name-mei', '名はひらがなで入力してください。');
698
699  const ageStr = document.getElementById('age').value;
700  const jobChecked = document.querySelector('input[name="job"]:checked');
701  const age = parseInt(ageStr, 10);
702  const job = jobChecked ? jobChecked.value : null;
703
704  if (!ageStr) setError('err-age', '年齢を入力してください。');
705  if (!job) setError('err-job', '職業を選択してください。');
706
707  if (ageStr && job) {
708    if (rule.allowUnderage) {
709      if (age < 13) setError('err-age', '小学校卒業前の方はお申し込みいただけません。');
710    } else {
711      if (age < 18) setError('err-age', '未成年の方はご契約いただけません。');
712      if (job === '20') setError('err-job', '高校生の方はご契約いただけません(卒業後3月末まで不可)。');
713    }
714  }
715
716  if (rule.requireBmi) {
717    const bmiVal = parseFloat(document.getElementById('bmi_result').value);
718    if (!bmiVal || isNaN(bmiVal)) {
719      setError('err-bmi', '身長と体重を入力し、BMIを算出してください。');
720    } else if (bmiVal <= 1) {
721      setError('err-bmi', 'BMIの数値が正しくありません。');
722    }
723  }
724
725  // =========================================================
726  // 📞 一人目の電話番号チェック(日本の固定電話・携帯・IP・フリーダイヤルのみ)
727  // =========================================================
728  // 🚀 【追加】テスト用の電話番号リスト
729  const testPhones = ['00000001234', '01000001234', '02000001234', '03000001234', '04000001234'];
730
731  // =========================================================
732  // 📞 一人目の電話番号チェック
733  // =========================================================
734  const tel = document.getElementById('tel').value.trim();
735  const telRegex = /^(050|070|080|090)\d{8}$|^0120\d{6}$|^0800\d{7}$|^0[2-9]\d{8}$|^01[1-9]\d{7}$/;
736
737  if (!tel) {
738    setError('err-tel', '電話番号を入力してください。');
739  } else if (!testPhones.includes(tel) && !telRegex.test(tel)) {
740    // 💥【修正】テスト番号リストに含まれていない、かつ正規表現にも合致しない場合のã
740¿ã‚¨ãƒ©ãƒ¼ï¼
741    setError('err-tel', '正しい日本の電話番号(携帯または固定)をハイフンなしで入力してください。');
742  }
743
744  const email = document.getElementById('email').value.trim();
745  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
746  if (!email) setError('err-email', 'メールアドレスを入力してください。');
747  else if (!emailRegex.test(email)) setError('err-email', '正しいメールアドレスを入力してください。');
748
749  let isAllRulesChecked = true;
750  const requiredCheckboxes = document.querySelectorAll('.js-required-rule');
751
752  requiredCheckboxes.forEach(checkbox => {
753    if (!checkbox.checked) {
754      isAllRulesChecked = false;
755    }
756  });
757
758  if (requiredCheckboxes.length > 0 && !isAllRulesChecked) {
759    setError('err-confirm', 'すべての確認事項にチェックを入れてください。');
760  } else {
761    const errConfirm = document.getElementById('err-confirm');
762    if (errConfirm) errConfirm.innerText = '';
763  }
764
765  const secUnderage = document.getElementById('sec-confirm-underage');
766  if (secUnderage && secUnderage.style.display !== 'none') {
767    const confirmUnderage = document.getElementById('confirm_underage');
768    if (!confirmUnderage.checked) {
769      setError('err-confirm-underage', '未成年の同伴条件をご確認のうえ、チェックを入れてください。');
770    }
771  }
772
773  // =========================================================
774  // 📞 二人目の電話番号チェック
775  // =========================================================
776  if (CURRENT_NINZU === 2) {
777    const sei2 = document.getElementById('name_sei_2').value.trim();
778    const mei2 = document.getElementById('name_mei_2').value.trim();
779    const age2 = document.getElementById('age_2').value;
780    const job2 = document.querySelector('input[name="job_2"]:checked');
781    const hiraganaRegex = /^[ぁ-んー]+$/;
782    const tel2 = document.getElementById('tel_2').value.trim();
783    const telRegex2 = /^(050|070|080|090)\d{8}$|^0120\d{6}$|^0800\d{7}$|^0[2-9]\d{8}$|^01[1-9]\d{7}$/;
784
785    if (!sei2) setError('err-name-sei-2', '二人目の姓を入力してください。');
786    else if (!hiraganaRegex.test(sei2)) setError('err-name-sei-2', '二人目の姓はひらがなで入力してください。');
787
788    if (!mei2) setError('err-name-mei-2', '二人目の名を入力してください。');
789    else if (!hiraganaRegex.test(mei2)) setError('err-name-mei-2', '二人目の名はひらがなで入力してください。');
790
791    if (!age2) setError('err-age-2', '二人目の年齢を入力してください。');
792    if (!job2) setError('err-job-2', '二人目の職業を選択してください。');
793
794    if (tel2 !== "") {
795      // 💥【修正】二人目もVIP通行証(テスト番号)なら正規表現をスルーさせる!
796      if (!testPhones.includes(tel2) && !telRegex2.test(tel2)) {
797        setError('err-tel-2', '二人目の電話番号を正しく入力してください(ハイフンなし)。');
798      } else if (tel2 === tel) {
799        setError('err-tel-2', '電話番号がお一人目と同じです。二人目の方の番号を入力してください。');
800      }
801    }
802
803    const email2 = document.getElementById('email_2').value.trim();
804    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
805    if (email2 !== "") {
806      if (!emailRegex.test(email2)) {
807        setError('err-email-2', '二人目のメールアドレスを正しく入力してください。');
808      }
809    }
810  }
811
812  return errorMessages;
813}
814
815// ==========================================
816// 📄 STEP 3 -> STEP 4 (確認画面)
817// ==========================================
818function goToConfirmStep() {
819  const shopName = document.getElementById('hidden-shop-name').value;
820  const datetime = document.getElementById('summary-datetime').textContent;
821  const sei = document.getElementById('name_sei').value.trim();
822  const mei = document.getElementById('name_mei').value.trim();
823  const age = document.getElementById('age').value;
824  const jobVal = document.querySelector('input[name="job"]:checked').value;
825  const tel = document.getElementById('tel').value.trim();
826  const email = document.getElementById('email').value.trim();
827
828  const menuName = DEPARTMENT_CONFIG.getLabel(CURRENT_DEPARTMENT);
829  const jobLabels = { '10': '中学生', '20': '高校生', '30': '大学・専門学生', '40': '会社員 等', '999': 'その他' };
830
831  document.getElementById('confirm-menu').textContent = menuName;
832  document.getElementById('confirm-shop').innerHTML = shopName;
833  document.getElementById('confirm-datetime').textContent = datetime;
834  document.getElementById('confirm-name').textContent = `${sei} ${mei}`;
835  document.getElementById('confirm-age').textContent = `${age} æ­³`;
836  document.getElementById('confirm-job').textContent = jobLabels[jobVal] || '未設定';
837  document.getElementById('confirm-tel').textContent = tel;
838  document.getElementById('confirm-email').textContent = email;
839
840  const rule = MENU_RULES[CURRENT_DEPARTMENT] || MENU_RULES['default'];
841  if (rule.requireBmi) {
842    document.getElementById('confirm-bmi').textContent = document.getElementById('bmi_result').value;
843    View.toggleDisplay('row-confirm-bmi', true, 'table-row');
844  } else {
845    View.toggleDisplay('row-confirm-bmi', false);
846  }
847  // --- 2. 二人目の情報の取得と流し込み(2名予約時のみ) ---
848  // 二人目の入力欄が存在するかチェック
849  if (CURRENT_NINZU === 2) {
850    const sei2 = document.getElementById('name_sei_2').value.trim();
851    const mei2 = document.getElementById('name_mei_2').value.trim();
852    const age2 = document.getElementById('age_2').value;
853    const jobVal2 = document.querySelector('input[name="job_2"]:checked')?.value;
854
855    // 🚀 【追加】二人目の連絡先も取得
856    const tel2 = document.getElementById('tel_2').value.trim();
857    const email2 = document.getElementById('email_2').value.trim();
858
859    document.getElementById('confirm-name-2').textContent = `${sei2} ${mei2}`;
860    document.getElementById('confirm-age-2').textContent = `${age2} æ­³`;
861    document.getElementById('confirm-job-2').textContent = jobLabels[jobVal2] || '未設定';
862
863    // 🚀 【追加】連絡先は任意なので、空なら「未入力」と表示する親切設計!
864    document.getElementById('confirm-tel-2').textContent = tel2 || '未入力';
865    document.getElementById('confirm-email-2').textContent = email2 || '未入力';
866
867    // 二人目用セクションを表示
868    View.toggleDisplay('sec-confirm-second-person', true);
869  } else {
870    // 1名予約なら二人目セクションを隠す
871    View.toggleDisplay('sec-confirm-second-person', false);
872  }
873
874  // 🚀 画面切り替えとステップバーの進行、スクロールを View に依頼
875  View.changeSections([], ['step-kakunin']);
876
877  // 🚀 【追加】ユーザーを混乱させる途中のボタンを隠す!
878  View.toggleDisplay('js-submit-area', false);
879  View.toggleDisplay('js-form-back-btn', false);
880
881  View.updateStepIndicator(3);
882  View.scrollTo('step-kakunin');
883}
884
885// ==========================================
886// 🔙 戻る(修正する)ボタンの制御
887// ==========================================
888function setupBackButtonEvents() {
889  document.querySelectorAll('.btn-back').forEach(btn => {
890    btn.addEventListener('click', (e) => {
891      const currentSection = e.target.closest('section');
892
893      if (currentSection.id === 'step-date') {
894        View.changeSections(['step-date'], ['step-tenpo']);
895        View.updateStepIndicator(1);
896      }
897      else if (currentSection.id === 'step-form') {
898        View.changeSections(['step-form', 'step-kakunin'], ['step-date']);
899        View.updateStepIndicator(2);
900      }
901      else if (currentSection.id === 'step-kakunin') {
902        // 🚀 【追加・修正】確認画面を一旦消して、入力画面のボタンを復活させる!
903        View.changeSections(['step-kakunin'], []);
904        View.toggleDisplay('js-submit-area', true);
905
906        // ※大きな戻るボタンは display: flex なので第3引数で指定
907        View.toggleDisplay('js-form-back-btn', true, 'flex');
908
909        View.scrollTo('step-form');
910        // 修正に戻る=まだ確定していないのでステップ2に戻す
911        View.updateStepIndicator(2);
912      }
913    });
914  });
915}

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.