Files
saborflow/js/main.js
2026-05-06 20:49:10 +07:00

472 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// Sabor Flow Da Nang — main.js
// Update SCHEDULE array monthly when the weekly program changes.
// ============================================================
const SCHEDULE = [
{
day: 'Monday',
name: 'Bachata on Mondays',
when: '8:00 PM 11:00 PM',
fee: '100k VND',
venue: 'Webe Coffee',
address: '1416 Nguyễn Hữu Thông, An Hải, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/DiRPZrr4wyW1Wy1eA',
music: '8 Bachata · 1 Salsa · 1 Kizomba',
city: 'Da Nang',
organizer: ['Frog and Goose'],
organizerUrl: ['https://www.instagram.com/fgbachata/'],
offer: '1 drink included · Workshop included',
vibe: 'Workshop (8PM9:30PM) & Social',
},
{
day: 'Tuesday',
name: 'SBK',
when: '9:00 PM 11:30 PM',
fee: '100k VND',
venue: 'Cáliz',
address: '4749 An Thượng 3, Ngũ Hành Sơn, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/JUBLZ5qHxkwXCe5u7',
music: '2 Salsa · 3 Bachata · 2 Kizomba',
city: 'Da Nang',
organizer: ['Daisy Nguyen'],
organizerUrl: ['https://www.facebook.com/minhtrang610'],
offer: '50k for first 10 ladies',
vibe: 'Elegant Wine Bar',
},
{
day: 'Wednesday',
name: 'Kiz & Bachata',
when: '7:30 PM Late',
fee: 'FREE',
venue: 'Kết Fai',
address: '138 Đỗ Bá, Ngũ Hành Sơn, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/W6nRJfwr2Vhy8iK66',
music: 'Kizomba · Bachata',
city: 'Da Nang',
organizer: ['Sean Kim'],
organizerUrl: ['https://www.facebook.com/happynowkkop'],
offer: 'Buy 1 Cocktail, Get 1 Wine',
vibe: 'Kizomba (7:30PM9:15PM) & Bachata (9:30PMLate) · Chill bar',
},
{
day: 'Thursday',
name: 'Salsa & Bachata',
when: '9:00 PM 11:30 PM',
fee: '100k VND',
venue: 'Malibu Beach Club',
address: 'Bãi tắm, 3 Võ Nguyên Giáp, An Hải, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/GZQC5micBRunbVNP8',
music: '2 Salsa · 2 Bachata',
city: 'Da Nang',
organizer: ['Lucho Giraldes'],
organizerUrl: ['https://www.facebook.com/luchogiraldespersonal'],
offer: 'Beach club snacks available',
vibe: 'Open-air Sea Breeze',
},
{
day: 'Friday',
name: 'Salsa & Bachata',
when: '8:00 PM Late',
fee: '100k VND',
venue: 'An Thượng By Night',
address: '100 Lã Xuân Oai, Ngũ Hành Sơn, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/R6mH4uQfAZtUWiH87',
music: '2 Salsa · 2 Bachata · 2 Kizomba',
city: 'Da Nang',
organizer: ['Nadya Yafarova'],
organizerUrl: ['https://www.facebook.com/yagfarova'],
offer: 'Free for first 10 ladies',
vibe: 'Community Family',
},
{
day: 'Saturday',
name: '100% Kizomba',
when: '8:00 PM 11:00 PM',
fee: '100k VND',
venue: 'LiiDy Studio',
address: '75 Thái Phiên, Hải Châu, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/GZ5UtDPFafW1BCSP9',
music: 'Kizomba',
city: 'Da Nang',
organizer: ['Kate Bvote'],
organizerUrl: ['https://www.facebook.com/kate.bovt'],
offer: '1 Drinking water included',
vibe: 'Deep Connection',
},
{
day: 'Sunday',
name: 'SBK',
when: '8:30 PM Late',
fee: '50k VND',
venue: 'The Workshop',
address: '35 Chế Lan Viên, Ngũ Hành Sơn, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/xxVwTocSQHKU1Ejd8',
music: '2 Salsa · 3 Bachata · 3 Kizomba',
city: 'Da Nang',
organizer: ['Daisy Nguyen'],
organizerUrl: ['https://www.facebook.com/minhtrang610'],
offer: '1-hr Kiz session included',
vibe: 'Festival style rotation',
},
{
day: 'Sunday',
name: 'Salsa & Bachata',
when: '8:00 PM 11:30 PM',
fee: '100k VND',
venue: 'An Thượng By Night',
address: '100 Lã Xuân Oai, Ngũ Hành Sơn, Đà Nẵng',
mapUrl: 'https://maps.app.goo.gl/R6mH4uQfAZtUWiH87',
music: '2 Salsa · 2 Bachata',
city: 'Da Nang',
organizer: ['Vaclav', 'Ksenia Tokareva'],
organizerUrl: ['https://www.facebook.com/vasek.nemec.1', 'https://www.facebook.com/omks234'],
offer: '1 drink included & 10% discount on meals',
vibe: 'Workshop (8PM9PM) & Social',
},
];
const DAYS_ORDER = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function getTodayName() {
return DAYS_ORDER[new Date().getDay()];
}
// Parse "2 Salsa · 3 Bachata · 1 Kizomba" into sfB-chip spans
function musicToChips(music) {
const styleMap = { salsa: 'salsa', bachata: 'bachata', kizomba: 'kizomba', zouk: 'zouk' };
return music.split('·').map(s => s.trim()).filter(Boolean).map(segment => {
let cls = '';
for (const [key, val] of Object.entries(styleMap)) {
if (segment.toLowerCase().includes(key)) { cls = val; break; }
}
return `<span class="sfB-chip ${cls}">${segment}</span>`;
}).join('');
}
// Build organizer name(s) as linked text
function organizerLinks(organizer, organizerUrl, stopProp) {
const stop = stopProp ? ' onclick="event.stopPropagation()"' : '';
return organizer.map((name, i) => {
const url = organizerUrl && organizerUrl[i];
return url ? `<a href="${url}" target="_blank" rel="noopener"${stop}>${name}</a>` : name;
}).join(' & ');
}
// ---- Nav (mobile) ----
function initNav() {
const hamburger = document.getElementById('sfB-hamburger');
const mobileNav = document.getElementById('sfB-mobile-nav');
const closeBtn = document.getElementById('sfB-mobile-nav-close');
if (!hamburger || !mobileNav) return;
hamburger.addEventListener('click', () => mobileNav.classList.add('open'));
if (closeBtn) closeBtn.addEventListener('click', () => mobileNav.classList.remove('open'));
mobileNav.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => mobileNav.classList.remove('open'));
});
mobileNav.addEventListener('click', e => {
if (e.target === mobileNav) mobileNav.classList.remove('open');
});
}
// ---- Event detail modal ----
let _sortedSchedule = [];
function openEventModal(row) {
const modal = document.getElementById('sfB-ev-modal');
if (!modal) return;
const now = new Date();
const offset = (DAYS_ORDER.indexOf(row.day) - now.getDay() + 7) % 7;
const date = new Date(now);
date.setDate(now.getDate() + offset);
const dateLabel = `${date.getDate()}/${date.getMonth() + 1}`;
document.getElementById('sfB-ev-modal-day').textContent = `${dateLabel} · ${row.day} · ${row.when}`;
document.getElementById('sfB-ev-modal-name').textContent = row.name;
const orgLinks = organizerLinks(row.organizer, row.organizerUrl, false);
document.getElementById('sfB-ev-modal-body').innerHTML = `
<div class="sfB-ev-row">
<span class="sfB-ev-label">Venue</span>
<span class="sfB-ev-val">
<a href="${row.mapUrl}" target="_blank" rel="noopener">📍 ${row.venue}</a>
<span class="sfB-ev-address">${row.address}</span>
</span>
</div>
<div class="sfB-ev-row">
<span class="sfB-ev-label">Music</span>
<span class="sfB-ev-val sfB-ev-chips">${musicToChips(row.music)}</span>
</div>
<div class="sfB-ev-row">
<span class="sfB-ev-label">Host</span>
<span class="sfB-ev-val"><span>${orgLinks}</span></span>
</div>
<div class="sfB-ev-row">
<span class="sfB-ev-label">Entry</span>
<span class="sfB-ev-val sfB-ev-fee">${row.fee}</span>
</div>
<div class="sfB-ev-row">
<span class="sfB-ev-label">Offer</span>
<span class="sfB-ev-val sfB-ev-offer">${row.offer}</span>
</div>
<div class="sfB-ev-row">
<span class="sfB-ev-label">Vibe</span>
<span class="sfB-ev-val">${row.vibe}</span>
</div>
`;
modal.classList.add('open');
document.body.style.overflow = 'hidden';
}
function initEventModal() {
const modal = document.getElementById('sfB-ev-modal');
if (!modal) return;
function closeModal() {
modal.classList.remove('open');
document.body.style.overflow = '';
}
document.getElementById('sfB-ev-modal-close').addEventListener('click', closeModal);
document.getElementById('sfB-ev-modal-overlay').addEventListener('click', closeModal);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
}
// ---- Schedule: dynamic filter pills ----
function renderScheduleFilters(events) {
const container = document.getElementById('sfB-schedule-pills');
if (!container) return;
const styleOptions = [
{ key: 'salsa', label: 'Salsa' },
{ key: 'bachata', label: 'Bachata' },
{ key: 'kizomba', label: 'Kizomba' },
{ key: 'zouk', label: 'Zouk' },
];
const cityOptions = [
{ key: 'hoian', label: 'Hoi An', match: 'hoi an' },
];
const activeStyles = styleOptions.filter(s =>
events.some(e => e.music.toLowerCase().includes(s.key))
);
const activeCities = cityOptions.filter(c =>
events.some(e => e.city.toLowerCase().includes(c.match))
);
container.innerHTML = [
'<button class="filter-pill active" data-filter="all">All</button>',
...activeStyles.map(s => `<button class="filter-pill" data-filter="${s.key}">${s.label}</button>`),
...activeCities.map(c => `<button class="filter-pill" data-filter="${c.key}">${c.label}</button>`),
].join('');
container.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
container.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
const f = pill.dataset.filter;
document.querySelectorAll('#sfB-schedule-list .sfB-row').forEach(row => {
if (f === 'all') { row.style.display = ''; return; }
const music = row.dataset.music || '';
const city = row.dataset.city || '';
const matchCity = f === 'danang' ? 'da nang' : f === 'hoian' ? 'hoi an' : f;
row.style.display = (music.includes(f) || city.includes(matchCity)) ? '' : 'none';
});
});
});
}
// ---- Schedule: full list (schedule.html) ----
function renderScheduleList() {
const list = document.getElementById('sfB-schedule-list');
if (!list) return;
const now = new Date();
const todayName = DAYS_ORDER[now.getDay()];
const startIdx = DAYS_ORDER.indexOf(todayName);
const dayOffset = {};
DAYS_ORDER.forEach(name => {
dayOffset[name] = (DAYS_ORDER.indexOf(name) - startIdx + 7) % 7;
});
_sortedSchedule = [...SCHEDULE].sort((a, b) => dayOffset[a.day] - dayOffset[b.day]);
renderScheduleFilters(_sortedSchedule);
list.innerHTML = _sortedSchedule.map((row, i) => {
const isToday = row.day === todayName;
const offset = dayOffset[row.day];
const date = new Date(now);
date.setDate(now.getDate() + offset);
const dayNum = `${date.getDate()}/${date.getMonth() + 1}`;
const orgHtml = organizerLinks(row.organizer, row.organizerUrl, true);
return `
<div class="sfB-row${isToday ? ' today' : ''}" data-idx="${i}"
data-music="${row.music.toLowerCase()}"
data-city="${row.city.toLowerCase()}"
tabindex="0" role="button" aria-label="View details for ${row.name}">
<div class="col-day">
<div class="day-line">
<span class="date-num">${dayNum}</span>
<span class="name">${row.day}</span>
</div>
${isToday ? '<span class="tonight-label">▶ Tonight</span>' : ''}
</div>
<div class="col-when">${row.when}</div>
<div class="col-social">${row.name}</div>
<div class="col-org">${orgHtml}</div>
<div class="col-music">${musicToChips(row.music)}</div>
<div class="col-venue">
<span class="pin">📍</span>
<a href="${row.mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${row.venue}</a>
</div>
</div>`;
}).join('');
list.querySelectorAll('.sfB-row').forEach(el => {
function openRow() {
const idx = parseInt(el.dataset.idx, 10);
openEventModal(_sortedSchedule[idx]);
}
el.addEventListener('click', openRow);
el.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
});
});
}
// ---- Home: "Coming up" teaser (4 cards starting from today) ----
function renderScheduleTeaser() {
const container = document.getElementById('sfB-coming-row');
if (!container) return;
const now = new Date();
const today = getTodayName();
const startIdx = DAYS_ORDER.indexOf(today);
const order = [...DAYS_ORDER.slice(startIdx), ...DAYS_ORDER.slice(0, startIdx)];
const dayOffset = {};
DAYS_ORDER.forEach(name => {
dayOffset[name] = (DAYS_ORDER.indexOf(name) - startIdx + 7) % 7;
});
const sorted = [...SCHEDULE].sort(
(a, b) => order.indexOf(a.day) - order.indexOf(b.day)
);
container.innerHTML = sorted.slice(0, 4).map(row => {
const offset = dayOffset[row.day];
const date = new Date(now);
date.setDate(now.getDate() + offset);
const dayNum = `${date.getDate()}/${date.getMonth() + 1}`;
const orgText = row.organizer.join(' & ');
return `
<a href="/schedule/" class="sfB-tcard">
<div class="day"><span class="day-date">${dayNum}</span> ${row.day}</div>
<div class="when">${row.when || ''}</div>
<h4>${row.name}</h4>
<div class="organizer">${orgText}</div>
<div class="at">@ ${row.venue} · ${row.city}</div>
<div class="chips">${musicToChips(row.music)}</div>
</a>
`;
}).join('');
}
// ---- Filter pills (studios.html / classes.html) ----
function initFilters() {
const cards = document.querySelectorAll('.filterable-card');
if (!cards.length) return;
const pills = document.querySelectorAll('.filter-pill');
if (!pills.length) return;
pills.forEach(pill => {
pill.addEventListener('click', () => {
pills.forEach(p => p.classList.remove('active'));
pill.classList.add('active');
const filter = pill.dataset.filter;
cards.forEach(card => {
if (filter === 'all') {
card.classList.remove('hidden');
} else {
const styles = (card.dataset.styles || '')
.split(',')
.map(s => s.trim().toLowerCase());
card.classList.toggle('hidden', !styles.includes(filter));
}
});
});
});
}
// ---- Palm fronds: lock sizes AND positions to px at load so nothing recalculates on mobile scroll ----
function lockPalmSizes() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
const config = {
'corner-tl': { size: clamp(vw * 0.40, 100, 620), pos: { top: vh * -0.08, left: vw * -0.15 } },
'corner-tr': { size: clamp(vw * 0.40, 100, 620), pos: { top: vh * -0.08, right: vw * -0.10 } },
'corner-bl': { size: clamp(vw * 0.40, 100, 620), pos: { bottom: vh * -0.08, left: vw * -0.15 } },
'corner-br': { size: clamp(vw * 0.40, 100, 620), pos: { bottom: vh * -0.08, right: vw * -0.10 } },
'corner-ml': { size: clamp(vw * 0.49, 120, 760), pos: { top: vh * 0.32, left: vw * -0.15 } },
'corner-ml2': { size: clamp(vw * 0.45, 110, 700), pos: { top: vh * 0.62, left: vw * -0.10 } },
'corner-mr': { size: clamp(vw * 0.44, 110, 680), pos: { top: vh * 0.22, right: vw * -0.10 } },
'corner-mr2': { size: clamp(vw * 0.48, 115, 740), pos: { top: vh * 0.70, right: vw * -0.10 } },
};
document.querySelectorAll('.sfB-palm').forEach(el => {
for (const cls of el.classList) {
const cfg = config[cls];
if (!cfg) continue;
const px = cfg.size + 'px';
el.style.width = px;
el.style.height = px;
for (const [prop, val] of Object.entries(cfg.pos)) {
el.style[prop] = val + 'px';
}
break;
}
});
}
// ---- Fireflies ----
function initFireflies() {
const container = document.createElement('div');
container.setAttribute('aria-hidden', 'true');
for (let i = 0; i < 15; i++) {
const fly = document.createElement('div');
fly.className = 'firefly';
container.appendChild(fly);
}
document.body.prepend(container);
}
// ---- Init ----
document.addEventListener('DOMContentLoaded', () => {
initFireflies();
initNav();
renderScheduleList();
renderScheduleTeaser();
initFilters();
initEventModal();
const yearEl = document.getElementById('footer-year');
if (yearEl) yearEl.textContent = new Date().getFullYear();
lockPalmSizes();
});