CSS
.bcr, .bcr * { box-sizing: border-box; }
.bcr { --accent: #2563eb; max-width: 760px; margin: 0 auto; color: #1f2937; }
.bcr__title { margin: 0 0 6px; font-size: 22px; }
.bcr__lead { margin: 0 0 12px; font-size: 14px; color: #4b5563; }
.bcr__legend { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 13px; color: #4b5563; }
.bcr__legend b { margin-right: 4px; color: #111827; }
.bcr__days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin: 0; padding: 0; list-style: none; }
.bcr__day { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; width: 100%; min-height: 76px; padding: 6px 2px; font: inherit; color: #111827; background: #fff; border: 2px solid #d1d5db; border-radius: 4px; cursor: pointer; }
.bcr__day:hover { border-color: var(--accent); }
.bcr__day:focus-visible, .bcr__time:focus-visible { outline: 3px solid #111827; outline-offset: 2px; }
.bcr__date { font-size: 14px; font-weight: 700; white-space: nowrap; }
.bcr__state { font-size: 12px; white-space: nowrap; }
.bcr__day[aria-disabled="true"] { color: #6b7280; background: #f3f4f6; border-color: #f3f4f6; cursor: default; }
.bcr__day[aria-pressed="true"] { color: #fff; background: var(--accent); border-color: var(--accent); }
.bcr__slots { margin-top: 18px; padding: 18px 20px; background: #f3f4f6; }
.bcr__day-title { margin: 0 0 10px; font-size: 16px; }
.bcr__times { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 8px; margin: 0; padding: 0; list-style: none; }
.bcr__time { display: flex; flex-direction: column; align-items: center; width: 100%; min-height: 56px; padding: 6px 4px; font: inherit; color: #111827; background: #fff; border: 2px solid #fff; border-radius: 4px; cursor: pointer; }
.bcr__time b { font-size: 16px; }
.bcr__time span { font-size: 12px; }
.bcr__time[aria-disabled="true"] { color: #9ca3af; background: transparent; border-color: #e5e7eb; cursor: default; }
.bcr__time[aria-pressed="true"] { color: #fff; background: var(--accent); border-color: var(--accent); }
.bcr__status { min-height: 1.5em; margin: 12px 0 8px; font-size: 14px; }
.bcr__go { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 28px; font-weight: 700; color: #fff; background: var(--accent); border-radius: 6px; text-decoration: none; }
.bcr__go[hidden] { display: none; }
.bcr__go:focus-visible { outline: 3px solid #111827; outline-offset: 2px; }
@media (max-width: 599px) {
.bcr__title { font-size: 19px; }
.bcr__days { grid-template-columns: repeat(4, 1fr); }
.bcr__date { font-size: 13px; }
.bcr__slots { padding: 16px; }
.bcr__times { grid-template-columns: 1fr 1fr; }
.bcr__go { width: 100%; }
}
JavaScript
const BCR_TZ = 'Asia/Tokyo'; // お店の地域
const BCR_DAYS = 14; // 何日先まで見せるか
const BCR_CLOSED = [3]; // 定休日の曜日(0=日〜6=土)
const BCR_TIMES = ['10:00', '11:00', '13:00', '14:00', '15:00', '16:00'];
const BCR_WEEK = ['日', '月', '火', '水', '木', '金', '土'];
const BCR_MARK = {
open: ['○', '空きあり'], few: ['△', '残りわずか'], full: ['×', '満席'], off: ['休', '定休日'],
};
// 本番では予約の仕組みから受け取る(例:fetch(`/api/slots?date=${key}`))。
// ここでは日付から決まる見本のデータ(時刻ごとの残りの数)を返す
const bcrFetchSlots = (y, m, d) => BCR_TIMES.map((t, i) => {
if (d % 5 === 0) return 0; // 満席の日
if (d % 3 === 0) return i % 3 === 1 ? 1 : 0; // 残りわずかの日
return (d * 7 + i * 3 + m) % 4;
});
const bcrRoot = document.querySelector('.bcr');
const bcrPart = (name) => bcrRoot.querySelector(`[data-bcr="${name}"]`);
const bcrPad = (n) => String(n).padStart(2, '0');
// お店の地域の今日から、日付の一覧を作る
const bcrStart = new Intl.DateTimeFormat('en-US', { timeZone: BCR_TZ, year: 'numeric', month: 'numeric', day: 'numeric' })
.formatToParts(new Date()).reduce((o, p) => ({ ...o, [p.type]: Number(p.value) }), {});
const bcrDays = Array.from({ length: BCR_DAYS }, (_, i) => {
const t = new Date(Date.UTC(bcrStart.year, bcrStart.month - 1, bcrStart.day + i));
const y = t.getUTCFullYear(), m = t.getUTCMonth() + 1, d = t.getUTCDate(), wd = t.getUTCDay();
const slots = BCR_CLOSED.includes(wd) ? null : bcrFetchSlots(y, m, d);
const left = slots ? slots.reduce((a, b) => a + b, 0) : 0;
const state = !slots ? 'off' : left === 0 ? 'full' : left <= 4 ? 'few' : 'open';
return { key: `${y}-${bcrPad(m)}-${bcrPad(d)}`, m, d, wd, slots, state };
});
const bcrSelectTime = (day, time, btn) => {
bcrPart('times').querySelectorAll('.bcr__time').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
bcrPart('status').textContent = `${day.m}月${day.d}日(${BCR_WEEK[day.wd]})${time} を選びました`;
const go = bcrPart('go');
go.href = `/reserve/?date=${day.key}&time=${encodeURIComponent(time)}`;
go.hidden = false;
};
const bcrSelectDay = (index) => {
const day = bcrDays[index];
bcrPart('days').querySelectorAll('.bcr__day').forEach((b, i) => b.setAttribute('aria-pressed', String(i === index)));
bcrPart('day-title').textContent = `${day.m}月${day.d}日(${BCR_WEEK[day.wd]})の空き`;
bcrPart('status').textContent = '';
bcrPart('go').hidden = true;
bcrPart('times').innerHTML = '';
day.slots.forEach((left, i) => {
const time = BCR_TIMES[i];
const [mark, text] = left === 0 ? ['×', '満席'] : left === 1 ? ['△', 'あと1'] : ['○', '空き'];
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bcr__time';
btn.setAttribute('aria-pressed', 'false');
if (left === 0) btn.setAttribute('aria-disabled', 'true');
btn.innerHTML = `<b>${time}</b><span>${mark} ${text}</span>`;
btn.addEventListener('click', () => { if (left > 0) bcrSelectTime(day, time, btn); });
li.appendChild(btn);
bcrPart('times').appendChild(li);
});
};
bcrDays.forEach((day, i) => {
const [mark, text] = BCR_MARK[day.state];
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bcr__day';
btn.setAttribute('aria-pressed', 'false');
if (day.state === 'off' || day.state === 'full') btn.setAttribute('aria-disabled', 'true');
btn.innerHTML = `<span class="bcr__date">${day.m}/${day.d}(${BCR_WEEK[day.wd]})</span><span class="bcr__state">${mark} ${text}</span>`;
btn.addEventListener('click', () => { if (btn.getAttribute('aria-disabled') !== 'true') bcrSelectDay(i); });
li.appendChild(btn);
bcrPart('days').appendChild(li);
});
// 最初は、いちばん近い空きのある日を選んでおく
const bcrFirst = bcrDays.findIndex((day) => day.state === 'open' || day.state === 'few');
if (bcrFirst >= 0) bcrSelectDay(bcrFirst);
else bcrPart('slots').hidden = true;