472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
// ============================================================
|
||
// 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: '14–16 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 (8PM–9:30PM) & Social',
|
||
},
|
||
{
|
||
day: 'Tuesday',
|
||
name: 'SBK',
|
||
when: '9:00 PM – 11:30 PM',
|
||
fee: '100k VND',
|
||
venue: 'Cáliz',
|
||
address: '47–49 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:30PM–9:15PM) & Bachata (9:30PM–Late) · 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 (8PM–9PM) & 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();
|
||
});
|