Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
node scripts/rewrite-links.mjs
node scripts/smoke-test.mjs
node scripts/seat-color-test.mjs
node scripts/seatmap-ui-test.mjs
node scripts/seat-center-zone-test.mjs
node scripts/seo-audit.mjs
node scripts/check-outbound-links.mjs
- name: Verify explicitly indexable build
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pages-stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ jobs:
node scripts/rewrite-links.mjs
node scripts/smoke-test.mjs
node scripts/seat-color-test.mjs
node scripts/seatmap-ui-test.mjs
node scripts/seat-center-zone-test.mjs
node scripts/seo-audit.mjs

- name: Stage full site and preview into gh-pages
Expand Down
57 changes: 57 additions & 0 deletions scripts/seat-center-zone-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { estimateCenterZone } from '../src/lib/seat-center-zone.js';

const createRow = (y, xs, occupied = false) => ({
seats: xs.map(x=>({x,y,width:27,height:27,occupied}))
});

// Reproduces the reported CGV layout: eight contiguous rows A-H, then a
// detached, smaller J-K block separated by a walkable aisle. The old algorithm
// stretched the center frame from D to J; the new one MUST stop by H.
const mainXs = [300,334,368,402,436,470,504,538];
const frontBank = Array.from({length:8},(_,i)=>createRow(i*35,mainXs,i>4));
const rearBank = [createRow(365,[140,174,...mainXs]),createRow(400,[140,174,...mainXs])];
const cg = estimateCenterZone([...frontBank,...rearBank]);
assert.ok(cg, 'CGV center frame should exist');
assert.equal(cg.blockCount,2,'Detached back-row bank must not merge into main');
assert.equal(cg.mainRowCount,8,'A-H are the main continuous block');
assert.equal(cg.firstMainRow,3,'Central rows should start around row D');
assert.equal(cg.lastMainRow,7,'Central rows must stop at row H');
assert.ok(cg.y>frontBank[2].seats[0].y,'Frame starts behind front rows');
assert.ok(cg.y+cg.height<rearBank[0].seats[0].y,
'Frame MUST NOT cross the physical aisle before J');
assert.ok(cg.x>285 && cg.x+cg.width<555,'Frame stays near central occupied seats');
assert.ok(cg.width>40 && cg.height>70,'Frame should cover meaningful seats');

// All-occupied geometry must produce the same location; availability cannot
// change which seating area is objectively in the middle.
const blocked = estimateCenterZone([...frontBank,...rearBank].map(r => ({
seats: r.seats.map(s=>({...s,occupied:true}))
})));
assert.deepEqual(blocked,cg,'Seat occupancy cannot alter central-zone position');

// A normal theater with no aisle produces one meaningful box.
const standard = estimateCenterZone(
Array.from({length:11},(_,i)=>createRow(i*34,Array.from({length:12},(_,j)=>j*34)))
);
assert.ok(standard && standard.blockCount===1);
assert.ok(standard.x>70 && standard.x+standard.width<340);
assert.ok(standard.lastMainRow<11);

// A rear block with more seats than the front should be selected on its own,
// rather than combining separated seating sections into an enormous rectangle.
const rearBig = [
...[0,35,70,105].map(y=>createRow(y,[80,115,150,185,220,255])),
...[200,235,270,305,340,375,410].map(y=>createRow(y,[0,35,70,105,140,175,210,245,280,315]))
];
const rearZone=estimateCenterZone(rearBig);
assert.equal(rearZone.blockCount,2);
assert.equal(rearZone.mainRowCount,7);
assert.ok(rearZone.y>=200,'Choose main contiguous rear block, without crossing aisle');

// Do not draw misleading pseudo-precision for tiny or malformed seating plans.
assert.equal(estimateCenterZone([]),null);
assert.equal(estimateCenterZone([createRow(0,[1]),createRow(35,[1])]),null);
assert.equal(estimateCenterZone([{seats:[{x:NaN,y:1,width:1,height:1}]}]),null);
console.log('PASS: center frame avoids the H-J aisle, uses the main bank, and ignores occupancy');
29 changes: 29 additions & 0 deletions scripts/seatmap-ui-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const component = readFileSync('src/components/SeatMap.astro', 'utf8');
const style = readFileSync('src/styles/seatmap-redesign.css', 'utf8');

for (const control of ['zoom-out', 'zoom-in', 'zoom-fit', 'zoom-reset', 'zoom-value']) {
assert.ok(component.includes('data-seatmap-' + control), 'Missing zoom control: ' + control);
}
assert.match(component, /const ZOOM_MIN = 0\.45;/, 'Minimum zoom must be bounded');
assert.match(component, /const ZOOM_MAX = 1\.8;/, 'Maximum zoom must be bounded');
assert.match(component, /stage\.style\.zoom = String\(next\)/, 'Zoom must affect stage layout, not just transform paint');
assert.match(component, /touchmove/, 'Touch pinch handler missing');
assert.match(component, /passive: false/, 'Pinch should prevent native zoom of the whole page');
assert.match(component, /root\.parentElement !== document\.body/, 'Modal needs to escape parent stacking context');
assert.match(style, /z-index: 1200/, 'Modal must overlay sticky mobile header');
assert.match(component, /data-seatmap-center-zone/, 'Center frame geometry missing');
assert.match(style, /pointer-events: none/, 'Center frame must allow clicking seats');
assert.match(style, /\.seatmap__swatch--center/, 'Legend lacks center zone outline');
assert.match(component, /Giữa dãy ghế chính \(ước lượng\)/, 'Do not claim an official provider central-seat classification');
assert.match(component, /estimateCenterZone\(seatRows\)/, 'Use aisle-aware center seat estimator');
assert.match(component, /button\.classList\.add\('seat--selected'\)/, 'Selecting must apply dedicated CSS class');
assert.match(component, /button\.classList\.remove\('seat--selected'\)/, 'Deselecting must remove dedicated CSS class');
assert.match(component, /button\.setAttribute\('aria-pressed', 'true'\)/, 'Selected seat must remain accessible');
assert.match(style, /\.seatmap \.seat\.seat--selected[\s\S]*?background: #19b3a8 !important/, 'Teal must always override provider type color');
assert.ok(!component.includes('Mua vé'), 'Preview cannot imply it purchases tickets');

console.log('PASS: seat map zoom, selection color, central zone geometry and mobile modal precedence');
143 changes: 139 additions & 4 deletions src/components/SeatMap.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ import '../styles/seatmap-redesign.css';
</div>
<button type="button" class="seatmap__close" data-seatmap-close aria-label="Đóng">✕</button>
</header>
<div class="seatmap__zoom-controls" data-seatmap-zoom-controls hidden role="group" aria-label="Điều chỉnh độ phóng to sơ đồ ghế">
<span class="seatmap__zoom-label">Sơ đồ ghế</span>
<div class="seatmap__zoom-actions">
<button type="button" data-seatmap-zoom-out aria-label="Thu nhỏ sơ đồ ghế" title="Thu nhỏ">−</button>
<output data-seatmap-zoom-value aria-live="polite">100%</output>
<button type="button" data-seatmap-zoom-in aria-label="Phóng to sơ đồ ghế" title="Phóng to">+</button>
<button type="button" data-seatmap-zoom-fit aria-label="Hiển thị toàn bộ chiều ngang sơ đồ" title="Vừa màn hình">Vừa khung</button>
<button type="button" data-seatmap-zoom-reset aria-label="Đặt tỷ lệ sơ đồ về 100 phần trăm" title="Đặt lại 100%">100%</button>
</div>
</div>
<div class="seatmap__body" data-seatmap-body aria-live="polite"></div>
<footer class="seatmap__footer" data-seatmap-footer></footer>
</div>
Expand All @@ -27,7 +37,7 @@ import '../styles/seatmap-redesign.css';
<style is:global>
/* Global on purpose: the seat grid is injected via innerHTML after the API
responds, and Astro's scoped styles never reach injected nodes. */
.seatmap { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; }
.seatmap { position: fixed; inset: 0; z-index: 1200; display: grid; place-items: center; }
.seatmap[hidden] { display: none; }
.seatmap__backdrop { position: absolute; inset: 0; background: rgba(13, 20, 36, .62); }
.seatmap__panel {
Expand Down Expand Up @@ -106,13 +116,30 @@ import '../styles/seatmap-redesign.css';
<script>
import { loadSeats } from '../lib/momo-client.js';
import { seatTypePalette, seatColorStyle } from '../lib/seat-colors.js';
import { estimateCenterZone } from '../lib/seat-center-zone.js';

const root = document.querySelector('[data-seatmap]');
if (root) {
// Escape parent stacking contexts so the fixed dialog is never covered
// by the sticky site header or clipped inside a schedule card on iOS.
if (root.parentElement !== document.body) document.body.append(root);
const titleEl = root.querySelector('[data-seatmap-title]');
const metaEl = root.querySelector('[data-seatmap-meta]');
const bodyEl = root.querySelector('[data-seatmap-body]');
const footerEl = root.querySelector('[data-seatmap-footer]');
const zoomControls = root.querySelector('[data-seatmap-zoom-controls]');
const zoomValueEl = root.querySelector('[data-seatmap-zoom-value]');
const zoomOutBtn = root.querySelector('[data-seatmap-zoom-out]');
const zoomInBtn = root.querySelector('[data-seatmap-zoom-in]');
const zoomFitBtn = root.querySelector('[data-seatmap-zoom-fit]');
const zoomResetBtn = root.querySelector('[data-seatmap-zoom-reset]');
const ZOOM_MIN = 0.45;
const ZOOM_MAX = 1.8;
const ZOOM_STEP = 0.15;
let zoom = 1;
let naturalStageWidth = 0;
let pinchStartDistance = 0;
let pinchStartZoom = 1;
const vnd = new Intl.NumberFormat('vi-VN');
const selected = new Map();
let requestToken = 0;
Expand All @@ -122,8 +149,10 @@ import '../styles/seatmap-redesign.css';
));

function close() {
++requestToken; // Ignore a late seat response after the dialog closes.
root.hidden = true;
document.body.style.overflow = '';
zoomControls.hidden = true;
}
root.querySelectorAll('[data-seatmap-close]').forEach((el) => el.addEventListener('click', close));
window.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !root.hidden) close(); });
Expand All @@ -142,6 +171,77 @@ import '../styles/seatmap-redesign.css';
'<span class="seatmap__note">Trạng thái ghế theo dữ liệu MoMo tại thời điểm tải. ePlus hiện chưa hỗ trợ giữ ghế và thanh toán trực tuyến.</span>';
}

function limitZoom(value) {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 100) / 100));
}

function applyZoom(value, { center = true } = {}) {
const scroller = bodyEl.querySelector('[data-seatmap-scroll]');
const stage = bodyEl.querySelector('[data-seatmap-stage]');
if (!scroller || !stage) return;
const next = limitZoom(value);
const previous = zoom;
if (Math.abs(next - previous) < 0.001) return;
const centerX = scroller.scrollLeft + scroller.clientWidth / 2;
const centerY = scroller.scrollTop + scroller.clientHeight / 2;
stage.style.zoom = String(next);
zoom = next;
zoomValueEl.textContent = Math.round(zoom * 100) + '%';
zoomOutBtn.disabled = zoom <= ZOOM_MIN;
zoomInBtn.disabled = zoom >= ZOOM_MAX;
requestAnimationFrame(() => {
if (center) {
scroller.scrollLeft = centerX * (next / previous) - scroller.clientWidth / 2;
scroller.scrollTop = centerY * (next / previous) - scroller.clientHeight / 2;
} else {
scroller.scrollLeft = Math.max(0, (scroller.scrollWidth - scroller.clientWidth) / 2);
scroller.scrollTop = 0;
}
const hint = bodyEl.querySelector('[data-seatmap-hint]');
if (hint) hint.hidden = scroller.scrollWidth <= scroller.clientWidth + 1;
});
}

function zoomToFit() {
const scroller = bodyEl.querySelector('[data-seatmap-scroll]');
if (!scroller || !naturalStageWidth) return;
applyZoom((scroller.clientWidth - 10) / naturalStageWidth, { center: false });
}

function drawCenterRegion() {
const grid = bodyEl.querySelector('[data-seatmap-grid]');
if (!grid) return false;
grid.querySelector('[data-seatmap-center-zone]')?.remove();
// Use the seat positions that are actually rendered, not raw row
// indices: detached banks after an aisle must not stretch the box.
const seatRows = [...grid.querySelectorAll('.seatmap__row')].map(row => ({
seats: [...row.querySelectorAll('.seat:not(.seat--gap)')].map(seat => ({
x: seat.offsetLeft,
y: seat.offsetTop,
width: seat.offsetWidth,
height: seat.offsetHeight
}))
}));
const zone = estimateCenterZone(seatRows);
if (!zone) return false;
const frame = document.createElement('div');
frame.className = 'seatmap__center-zone';
frame.dataset.seatmapCenterZone = '';
frame.setAttribute('aria-hidden', 'true');
frame.title = 'Ước lượng khu vực giữa dãy ghế chính, không bao gồm lối đi.';
Object.assign(frame.style, {
left: zone.x + 'px', top: zone.y + 'px',
width: zone.width + 'px', height: zone.height + 'px'
});
grid.append(frame);
return true;
}

zoomOutBtn.addEventListener('click', () => applyZoom(zoom - ZOOM_STEP));
zoomInBtn.addEventListener('click', () => applyZoom(zoom + ZOOM_STEP));
zoomFitBtn.addEventListener('click', zoomToFit);
zoomResetBtn.addEventListener('click', () => applyZoom(1));

function renderSeats(detail, data) {
const info = data.SeatsInfo;
const palette = seatTypePalette(info.SeatTypes || []);
Expand Down Expand Up @@ -177,10 +277,10 @@ import '../styles/seatmap-redesign.css';
const unknownLegend = unknownType
? '<span><i class="seatmap__swatch seatmap__swatch--unknown"></i>Chưa rõ loại ghế</span>'
: '';
bodyEl.innerHTML = `<div class="seatmap__map-scroll" data-seatmap-scroll role="region" aria-label="Sơ đồ ghế — cuộn ngang để xem tất cả các ghế" tabindex="0">
<div class="seatmap__stage">
bodyEl.innerHTML = `<div class="seatmap__map-scroll" data-seatmap-scroll role="region" aria-label="Sơ đồ ghế — cuộn ngang hoặc dùng điều khiển phóng to thu nhỏ để xem tất cả các ghế" tabindex="0">
<div class="seatmap__stage" data-seatmap-stage>
<div class="seatmap__screen">MÀN HÌNH</div>
<div class="seatmap__grid">${rowsHtml}</div>
<div class="seatmap__grid" data-seatmap-grid>${rowsHtml}</div>
</div>
</div>
<p class="seatmap__scroll-hint" data-seatmap-hint hidden>↔ Vuốt ngang để xem toàn bộ ghế</p>
Expand All @@ -193,23 +293,54 @@ import '../styles/seatmap-redesign.css';
${unknownLegend}
<span><i class="seatmap__swatch seatmap__swatch--taken"></i>Đã đặt</span>
<span><i class="seatmap__swatch seatmap__swatch--selected"></i>Đang chọn</span>
<span data-seatmap-center-legend><i class="seatmap__swatch seatmap__swatch--center"></i>Giữa dãy ghế chính (ước lượng)</span>
</div>`;
zoom = 1;
zoomControls.hidden = false;
zoomValueEl.textContent = '100%';
zoomOutBtn.disabled = false;
zoomInBtn.disabled = false;
const hasCenterZone = drawCenterRegion();
const centerLegend = bodyEl.querySelector('[data-seatmap-center-legend]');
if (centerLegend) centerLegend.hidden = !hasCenterZone;
const scroller = bodyEl.querySelector('[data-seatmap-scroll]');
const stage = bodyEl.querySelector('[data-seatmap-stage]');
naturalStageWidth = stage?.offsetWidth || 0;
const hint = bodyEl.querySelector('[data-seatmap-hint]');
if (scroller) {
const horizontalOverflow = scroller.scrollWidth > scroller.clientWidth + 1;
if (hint) hint.hidden = !horizontalOverflow;
// Start at the middle on narrow screens without changing seat positions.
if (horizontalOverflow) scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
}
// Two-finger pinch controls the same geometry as the zoom buttons.
// One-finger swipes continue to pan through the auditorium.
scroller?.addEventListener('touchstart', (event) => {
if (event.touches.length !== 2) return;
pinchStartDistance = Math.hypot(event.touches[0].clientX - event.touches[1].clientX,
event.touches[0].clientY - event.touches[1].clientY);
pinchStartZoom = zoom;
}, { passive: true });
scroller?.addEventListener('touchmove', (event) => {
if (event.touches.length !== 2 || !pinchStartDistance) return;
event.preventDefault();
const distance = Math.hypot(event.touches[0].clientX - event.touches[1].clientX,
event.touches[0].clientY - event.touches[1].clientY);
applyZoom(pinchStartZoom * distance / pinchStartDistance);
}, { passive: false });
scroller?.addEventListener('touchend', (event) => {
if (event.touches.length < 2) pinchStartDistance = 0;
}, { passive: true });
bodyEl.querySelectorAll('[data-seat]').forEach((button) => {
button.addEventListener('click', () => {
const name = button.dataset.seat;
if (selected.has(name)) {
selected.delete(name);
button.classList.remove('seat--selected');
button.setAttribute('aria-pressed', 'false');
} else {
selected.set(name, { label: name, price: Number(button.dataset.price || 0) });
button.classList.add('seat--selected');
button.setAttribute('aria-pressed', 'true');
}
renderFooter();
Expand All @@ -221,6 +352,10 @@ import '../styles/seatmap-redesign.css';
async function open(detail) {
const token = ++requestToken;
selected.clear();
zoomControls.hidden = true;
zoom = 1;
naturalStageWidth = 0;
pinchStartDistance = 0;
footerEl.innerHTML = '';
titleEl.textContent = detail.filmTitle || 'Chọn ghế';
metaEl.textContent = [detail.cinemaName, detail.screenName, detail.dateLabel, detail.timeLabel, detail.format]
Expand Down
Loading
Loading