Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
run: |
node scripts/rewrite-links.mjs
node scripts/smoke-test.mjs
node scripts/seat-color-test.mjs
node scripts/seo-audit.mjs
node scripts/check-outbound-links.mjs
- name: Verify explicitly indexable build
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pages-stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
npm run build
node scripts/rewrite-links.mjs
node scripts/smoke-test.mjs
node scripts/seat-color-test.mjs
node scripts/seo-audit.mjs

- name: Stage full site and preview into gh-pages
Expand Down
44 changes: 44 additions & 0 deletions scripts/seat-color-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseSeatColor, seatForeground, seatTypePalette, seatColorStyle } from '../src/lib/seat-colors.js';

// Source-supplied names do not imply a color; the Color field does.
const sourceTypes = [
{ Type: 1, Name: 'Ghế Thường', Color: '#7f55db' },
{ Type: 2, Name: 'VIP', Color: 'rgb(236, 62, 83)' },
{ Type: 3, Name: 'WAKU WAKU', Color: '#e4a43e' },
{ Type: 4, Name: 'DOKI DOKI', Color: '#e64f9e' }
];
const palette = seatTypePalette(sourceTypes);
assert.equal(palette.size, 4);
assert.equal(palette.get('1').color, '#7f55db');
assert.equal(palette.get('2').color, '#ec3e53');
assert.equal(palette.get('3').color, '#e4a43e');
assert.equal(palette.get('4').color, '#e64f9e');
assert.equal(new Set([...palette.values()].map((type) => type.color)).size, 4);
assert.equal(seatForeground('#ffffff'), '#132033');
assert.equal(seatForeground('#151515'), '#ffffff');

// Repeated/missing/invalid colors must not collapse two distinct seat types.
const ambiguousTypes = seatTypePalette([
{ Type: '1', Name: 'Ghế Thường', Color: '#7f55db' },
{ Type: '2', Name: 'WAKU WAKU', Color: '#7f55db' },
{ Type: '3', Name: 'DOKI DOKI', Color: '' },
{ Type: '4', Name: 'Khác', Color: 'red;position:absolute' }
]);
assert.equal(ambiguousTypes.size, 4);
assert.equal(new Set([...ambiguousTypes.values()].map((type) => type.color)).size, 4);
assert.equal(parseSeatColor('#abc'), '#aabbcc');
assert.equal(parseSeatColor('rgba(0,0,0,0)'), null);
assert.equal(parseSeatColor('url(javascript:alert(1))'), null);
assert.match(seatColorStyle(ambiguousTypes.get('4')), /^--seat-type-color:#[0-9a-f]{6};--seat-type-foreground:#[0-9a-f]{6}$/);
const markup = readFileSync('src/components/SeatMap.astro', 'utf8');
const css = readFileSync('src/styles/seatmap-redesign.css', 'utf8');
assert.ok(markup.includes('palette.get(String(seat.cinemaAreaCode))'), 'Seat must resolve by actual source code');
assert.ok(markup.includes('seatColorStyle(type)'), 'Seat and legend must share the same palette');
assert.ok(markup.includes('seatmap__counts'), 'Occupancy totals must not be presented as seat type colors');
assert.ok(!markup.includes("const kind = /vip/"), 'Do not infer type color from names');
assert.ok(css.includes('background: var(--seat-type-color'), 'Seat type CSS variable missing');
assert.ok(css.includes('.seatmap__swatch--type'), 'Legend color CSS variable missing');
console.log('PASS: seat type colors, duplicate/invalid data fallbacks, contrast, rendering and legend');
45 changes: 26 additions & 19 deletions src/components/SeatMap.astro
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import '../styles/seatmap-redesign.css';

<script>
import { loadSeats } from '../lib/momo-client.js';
import { seatTypePalette, seatColorStyle } from '../lib/seat-colors.js';

const root = document.querySelector('[data-seatmap]');
if (root) {
Expand Down Expand Up @@ -143,49 +144,55 @@ import '../styles/seatmap-redesign.css';

function renderSeats(detail, data) {
const info = data.SeatsInfo;
const typeByCode = new Map((info.SeatTypes || []).map((type) => [type.Type, type]));
const palette = seatTypePalette(info.SeatTypes || []);
const visibleTypes = new Set();
let unknownType = false;
let free = 0;
let taken = 0;
const rowsHtml = (info.Seats || []).map((row) => {
const label = row.find((seat) => seat.nameRow)?.nameRow || '';
const cells = row.map((seat) => {
if (!seat.id) return '<span class="seat seat--gap" aria-hidden="true"></span>';
const name = `${seat.nameRow}${seat.momoId || ''}`;
const type = typeByCode.get(seat.cinemaAreaCode);
const color = type?.Color || '';
const type = palette.get(String(seat.cinemaAreaCode));
if (seat.status !== 0) {
taken++;
return `<button type="button" class="seat" disabled title="${esc(name)} — đã đặt">${esc(seat.momoId || '')}</button>`;
}
free++;
const typeName = type?.Name ? ` · ${type.Name}` : '';
const normalized = String(type?.Name || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const kind = /vip/.test(normalized) ? 'seat--vip'
: /(couple|sweet|doi|double|love)/.test(normalized) ? 'seat--couple'
: 'seat--standard';
return `<button type="button" class="seat seat--available ${kind}" data-seat="${esc(name)}" data-price="${seat.price || 0}" aria-pressed="false" title="${esc(name + typeName)} — ${vnd.format(seat.price || 0)}₫">${esc(seat.momoId || '')}</button>`;
if (type) visibleTypes.add(type.key);
else unknownType = true;
const seatTypeName = type?.label || 'Chưa xác định loại ghế';
const style = type ? seatColorStyle(type)
: '--seat-type-color:#637186;--seat-type-foreground:#ffffff';
const tooltip = name + ' · ' + seatTypeName + ' — ' + vnd.format(seat.price || 0) + '₫';
return `<button type="button" class="seat seat--available" style="${style}" data-seat="${esc(name)}" data-price="${seat.price || 0}" aria-label="${esc(tooltip)}" aria-pressed="false" title="${esc(tooltip)}">${esc(seat.momoId || '')}</button>`;
}).join('');
return `<div class="seatmap__row"><span class="seatmap__row-label">${esc(label)}</span>${cells}<span class="seatmap__row-label">${esc(label)}</span></div>`;
}).join('');
const legendTypes = (info.SeatTypes || []).map((type) => {
const normalized = String(type.Name || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const kind = /vip/.test(normalized) ? 'seatmap__swatch--vip'
: /(couple|sweet|doi|double|love)/.test(normalized) ? 'seatmap__swatch--couple'
: 'seatmap__swatch--standard';
return `<span><i class="seatmap__swatch ${kind}"></i>${esc(type.Name)}</span>`;
const legendTypes = [...visibleTypes].map((key) => {
const type = palette.get(key);
return `<span><i class="seatmap__swatch seatmap__swatch--type" style="${seatColorStyle(type)}"></i>${esc(type.label)}</span>`;
}).join('');
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">
<div class="seatmap__screen">MÀN HÌNH</div>
<div class="seatmap__grid">${rowsHtml}</div>
</div>
</div>
<p class="seatmap__scroll-hint" data-seatmap-hint hidden>↔ Vuốt ngang để xem toàn bộ ghế</p>
<div class="seatmap__legend">
<span><i class="seatmap__swatch seatmap__swatch--standard"></i>Ghế trống (${free})</span>
<span><i class="seatmap__swatch seatmap__swatch--taken"></i>Đã đặt (${taken})</span>
<span><i class="seatmap__swatch seatmap__swatch--selected"></i>Đang chọn</span>
<div class="seatmap__counts" aria-label="Thống kê trạng thái ghế">
<span>Còn trống <strong>${free}</strong></span>
<span>Đã đặt <strong>${taken}</strong></span>
</div>
<div class="seatmap__legend" aria-label="Chú thích màu ghế">
${legendTypes}
${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>
</div>`;
const scroller = bodyEl.querySelector('[data-seatmap-scroll]');
const hint = bodyEl.querySelector('[data-seatmap-hint]');
Expand Down
80 changes: 80 additions & 0 deletions src/lib/seat-colors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Visual seat colors derive from SeatsInfo.SeatTypes, not guesses based on
* names such as VIP, WAKU WAKU, DOKI DOKI, or Sweetbox.
*/
const FALLBACK_COLORS = Object.freeze([
'#8056d9', '#ef4763', '#eb9b3c', '#d846a6',
'#228ea8', '#34a173', '#c2a42f', '#6372d6',
'#a96a4f', '#a44fa1', '#5374aa', '#b9617c'
]);

export function parseSeatColor(raw) {
if (typeof raw !== 'string') return null;
const color = raw.trim();
const hex3 = /^#([0-9a-f]{3})$/i.exec(color);
if (hex3) return '#' + [...hex3[1]].map((x) => x + x).join('').toLowerCase();
const hex6 = /^#([0-9a-f]{6})$/i.exec(color);
if (hex6) return '#' + hex6[1].toLowerCase();
const rgb = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.exec(color);
if (rgb) {
const values = rgb.slice(1).map(Number);
if (values.every((x) => x >= 0 && x <= 255)) {
return '#' + values.map((x) => x.toString(16).padStart(2, '0')).join('');
}
}
return null;
}

function hslToRgb(h, s, l) {
const f = (n) => {
const k = (n + h * 12) % 12;
const a = s * Math.min(l, 1 - l);
return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))));
};
return [f(0), f(8), f(4)];
}

function fallbackColor(used, index) {
for (let offset = 0; offset < FALLBACK_COLORS.length; offset++) {
const color = FALLBACK_COLORS[(index + offset) % FALLBACK_COLORS.length];
if (!used.has(color)) return color;
}
const hue = (index * 137.508) % 360;
const rgb = hslToRgb(hue / 360, 0.55, 0.48);
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
}

/** Numeric label contrast against a given RGB background. */
export function seatForeground(hex) {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const luminance = rgb.map((x) => x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4)
.reduce((a, x, i) => a + x * [0.2126, 0.7152, 0.0722][i], 0);
return luminance > 0.18 ? '#132033' : '#ffffff';
}

export function seatTypePalette(types = []) {
const colors = new Set();
const palette = new Map();
for (const [index, type] of types.entries()) {
if (type?.Type === undefined || type.Type === null) continue;
const key = String(type.Type);
if (palette.has(key)) continue;
let color = parseSeatColor(type.Color);
// Treat repeated upstream colors as ambiguous and render a distinct
// fallback, so different labels cannot look like the same category.
if (!color || colors.has(color)) color = fallbackColor(colors, index);
colors.add(color);
palette.set(key, {
key,
label: String(type.Name || 'Loại ghế ' + key),
color,
foreground: seatForeground(color)
});
}
return palette;
}

/** CSS custom properties can only receive normalized hex colors above. */
export function seatColorStyle(type) {
return '--seat-type-color:' + type.color + ';--seat-type-foreground:' + type.foreground;
}
37 changes: 27 additions & 10 deletions src/styles/seatmap-redesign.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,18 @@
width: 30px; height: 30px; font-size: 10px; font-weight: 800;
background: #7850d6; color: #fff; transition: background .14s, box-shadow .14s, transform .14s;
}
.seatmap .seat--available.seat--vip { background: #ec3e53; color: white; }
.seatmap .seat--available.seat--couple { background: #d936a8; color: white; min-width: 47px; }
.seatmap .seat:hover:not(:disabled) {
background: #9978f0; border-color: #f9edff; color: #fff; transform: translateY(-2px);
.seatmap .seat--available {
background: var(--seat-type-color, #637186);
color: var(--seat-type-foreground, #ffffff);
}
.seatmap .seat--available:hover:not(:disabled) {
background: var(--seat-type-color, #637186);
color: var(--seat-type-foreground, #ffffff);
border-color: #faf4ff;
box-shadow: 0 0 0 2px rgba(255,255,255,.35);
filter: brightness(1.12);
transform: translateY(-2px);
}
.seatmap .seat--available.seat--vip:hover:not(:disabled) { background: #fb6576; }
.seatmap .seat--available.seat--couple:hover:not(:disabled) { background: #f064c7; }
.seatmap .seat:disabled {
background: #53505b; border-color: transparent; color: #c3becd;
text-decoration: none; cursor: not-allowed; opacity: .7;
Expand All @@ -55,18 +60,30 @@
background: #19b3a8; border-color: #b7fff7; color: #061d25;
box-shadow: 0 0 0 2px rgba(25,179,168,.23); transform: none;
}
/* Selected seat must stay teal even when hovered (hover specificity is higher). */
.seatmap .seat--available[aria-pressed="true"]:hover:not(:disabled) {
background: #19b3a8; color: #061d25; border-color: #b7fff7;
filter: none; transform: none;
}
.seatmap .seat--gap { visibility: hidden; }
.seatmap .seatmap__legend {
max-width: 760px; margin: 24px auto 0; padding-top: 18px;
border-top: 1px solid rgba(255,255,255,.1); justify-content: center; gap: 12px 20px;
color: #d2ccd9; font-size: 11px; line-height: 1.5;
}
.seatmap .seatmap__swatch { width: 16px; height: 16px; border: 0; border-radius: 4px; background: #7850d6; }
.seatmap .seatmap__swatch--standard { background: #7850d6; }
.seatmap .seatmap__swatch--vip { background: #ec3e53; }
.seatmap .seatmap__swatch--couple { background: #d936a8; }
.seatmap .seatmap__swatch { width: 16px; height: 16px; border: 0; border-radius: 4px; display: inline-block; }
.seatmap .seatmap__swatch--type { background: var(--seat-type-color, #637186); }
.seatmap .seatmap__swatch--unknown { background: #637186; }
.seatmap .seatmap__swatch--taken { background: #53505b; }
.seatmap .seatmap__swatch--selected { background: #19b3a8; }
.seatmap .seatmap__counts {
display: flex; justify-content: center; gap: 22px; flex-wrap: wrap;
padding: 15px 0 0; margin: 10px auto 0; border-top: 1px solid rgba(255,255,255,.12);
font-size: 12px; color: #c9c6d1;
}
.seatmap .seatmap__counts strong { color: #fff; margin-left: 5px; font-variant-numeric: tabular-nums; }
.seatmap .seatmap__legend { border-top: 0; padding-top: 9px; margin-top: 0; }
.seatmap .seatmap__legend span { white-space: normal; }
.seatmap .seatmap__scroll-hint { color: #e0d6ec; font-size: 12px; margin: 3px 0 14px; }
.seatmap .seatmap__status { color: #c8c4d3; font-size: 13px; line-height: 1.65; }
.seatmap .seatmap__status button { background: #2b354c; color: #fff; border-color: #667084; }
Expand Down
Loading