cafe / public /js /main.js
3v324v23's picture
💰 Convert all prices to ₹ (rupees)
6c1a79a
Raw
History Blame Contribute Delete
13.5 kB
// ── The Roasted Bean — Editorial Motion ────────────────────────────────────
//
// Motion decisions (Jakub primary · Jhey secondary · Emil selective):
// • Hero: one orchestrated entrance on load — opacity + translateY + blur
// 700ms, 120–160ms stagger (Jakub's enter recipe)
// • Menu cards: staggered cascade via CSS --card-index (Jhey moment)
// 500ms per card, 40ms delay increment
// • Scroll reveals: editorial subtlety — 8px translateY, no blur (avoid slop)
// • Buttons: scale(0.97) on :active (Emil's tactile feedback)
// • Nav / category filter / newsletter: no JS animation (Emil's frequency gate)
// — these use CSS transitions only
// • About image: scale(1.03) on hover (CSS transition, no JS)
// • prefers-reduced-motion: all animations collapsed to instant (0.01ms)
// • No parallax, no custom cursor, no preloader, no springs
document.addEventListener('DOMContentLoaded', async () => {
triggerHeroEntrance();
await loadCafeData();
setupNavigation();
setupNewsletter();
initScrollReveal();
initMenuCardReveal();
setupSmoothScroll();
});
// ── Hero Entrance ───────────────────────────────────────────────────────────
// One orchestrated moment — rare interaction (first visit only)
// Jakub's enter recipe: opacity + translateY + blur materialization
function triggerHeroEntrance() {
const hero = document.querySelector('.hero');
if (!hero) return;
// Small delay so the browser has painted the initial state
requestAnimationFrame(() => {
requestAnimationFrame(() => {
hero.classList.add('hero-entered');
});
});
}
// ── Load Data ───────────────────────────────────────────────────────────────
let menuItems = [];
let menuCategories = [];
async function loadCafeData() {
try {
const res = await fetch('/api/menu');
const data = await res.json();
menuItems = data.items;
menuCategories = data.categories;
populateHero(data.info);
buildCategories(data.categories);
buildMenu(data.items, data.categories);
buildVisit(data.info);
// Re-run menu card observer after DOM update
initMenuCardReveal();
} catch (err) {
console.error('Error loading cafe data:', err);
}
}
function populateHero(info) {
if (info.name) {
document.getElementById('cafeName').textContent = info.name;
document.title = `${info.name} — Specialty Coffee Roasters`;
}
if (info.address) {
const el = document.getElementById('cafeAddressSmall');
if (el) el.textContent = info.address;
}
if (info.phone) {
const el = document.getElementById('cafePhoneSmall');
if (el) el.textContent = info.phone;
}
}
// ── Categories ──────────────────────────────────────────────────────────────
// Rebuilds the grid from filtered data — no display:none, no empty cells
let activeCategory = 'all';
function buildCategories(categories) {
const container = document.getElementById('categoryFilters');
if (!container) return;
container.innerHTML = categories
.map(cat => `
<button class="cat-btn ${cat.id === 'all' ? 'active' : ''}"
data-category="${cat.id}">
${cat.name}
</button>
`).join('');
container.addEventListener('click', (e) => {
const btn = e.target.closest('.cat-btn');
if (!btn) return;
document.querySelectorAll('.cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeCategory = btn.dataset.category;
rebuildGrid();
});
}
function rebuildGrid() {
const grid = document.getElementById('menuGrid');
if (!grid) return;
const filtered = activeCategory === 'all'
? menuItems
: menuItems.filter(item => item.category === activeCategory);
if (filtered.length === 0) {
grid.innerHTML = '<div class="menu-empty">No items in this category yet.</div>';
return;
}
buildMenu(filtered, menuCategories);
initMenuCardReveal();
}
function filterCards() {
// Kept for API compatibility — rebuildGrid handles filtering now
rebuildGrid();
}
// ── Menu Cards ──────────────────────────────────────────────────────────────
function buildMenu(items, categories) {
const grid = document.getElementById('menuGrid');
if (!grid) return;
const catMeta = {};
categories.forEach(c => { catMeta[c.id] = { name: c.name, icon: c.icon }; });
grid.innerHTML = items.map((item, i) => {
const cat = catMeta[item.category] || { name: item.category, icon: '/img/icons/default.svg' };
const tagClass = !item.available ? ' soldout' : '';
const tagText = !item.available ? 'Sold Out' : cat.name;
// Thumbnail: SVG fallback always present; image overlays on top
const thumbSvg = svgPlaceholder(item.category);
const thumbImg = (item.image && item.image.startsWith('http'))
? `<img src="${escapeHtml(item.image)}" alt="${escapeHtml(item.name)}" loading="lazy" onerror="this.style.display='none'">`
: '';
return `
<div class="menu-card"
data-category="${item.category}"
data-card-index="${i}"
style="--card-index: ${i}">
<div class="menu-card-thumb">
<div class="thumb-icon">${thumbImg}${thumbSvg}</div>
</div>
<div class="menu-card-header">
<h3 class="menu-card-name">${escapeHtml(item.name)}</h3>
<span class="menu-card-price">₹${item.price.toFixed(0)}</span>
</div>
<span class="menu-card-label">Tasting</span>
<p class="menu-card-description">${escapeHtml(item.description)}</p>
<div class="menu-card-footer">
<span class="menu-card-cat-icon">${svgInline(cat.icon)}</span>
<span class="menu-card-tag${tagClass}">${tagText}</span>
</div>
</div>
`;
}).join('');
}
// Inline SVGs from icon files — lightweight, no extra requests
function svgInline(path) {
const icons = {
'/img/icons/coffee.svg': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h14v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"/><path d="M18 9h2a2 2 0 012 2v1a2 2 0 01-2 2h-2"/><ellipse cx="8" cy="14" rx="2" ry="1.5"/><path d="M8 1v2" opacity="0.4"/><path d="M6 0v2" opacity="0.3"/><path d="M10 0v2" opacity="0.3"/></svg>',
'/img/icons/tea.svg': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8h10a3 3 0 013 3v3a3 3 0 01-3 3H5a3 3 0 01-3-3v-3a3 3 0 013-3z"/><path d="M2 11l-2 3h2l1-3"/><path d="M7 8V6a1 1 0 011-1h2a1 1 0 011 1v2"/><circle cx="10" cy="3.5" r="1.2"/><path d="M8 1v2" opacity="0.4"/><path d="M10 0v2" opacity="0.3"/><path d="M12 1v2" opacity="0.3"/></svg>',
'/img/icons/pastry.svg': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 16C4 16 6 10 12 8s10 0 10 0c-4 2-6 8-6 8"/><path d="M5 14c1-3 4-6 7-7"/><path d="M19 14c-1-3-4-6-7-7"/><path d="M8 10h8" opacity="0.3"/></svg>',
'/img/icons/food.svg': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="18" rx="10" ry="3"/><rect x="8" y="8" width="8" height="7" rx="1"/><line x1="10" y1="9.5" x2="14" y2="9.5" opacity="0.4"/><line x1="10" y1="11.5" x2="14" y2="11.5" opacity="0.3"/><ellipse cx="12" cy="16" rx="3" ry="2" fill="currentColor" opacity="0.15"/></svg>',
'/img/icons/default.svg': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="12" rx="8" ry="4.5"/><path d="M12 7.5v9"/><path d="M7 10c0 3 2 5 5 5s5-2 5-5" opacity="0.4"/></svg>'
};
return icons[path] || icons['/img/icons/default.svg'];
}
function svgPlaceholder(category) {
const icons = {
'coffee': '/img/icons/coffee.svg',
'tea': '/img/icons/tea.svg',
'pastries': '/img/icons/pastry.svg',
'food': '/img/icons/food.svg'
};
return svgInline(icons[category] || '/img/icons/default.svg');
}
// ── Menu Card Reveal — Jhey's stagger via CSS custom properties ─────────────
let menuCardObserver = null;
function initMenuCardReveal() {
// Disconnect previous observer if re-initializing
if (menuCardObserver) menuCardObserver.disconnect();
const cards = document.querySelectorAll('.menu-card:not(.revealed)');
if (cards.length === 0) return;
menuCardObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
menuCardObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.05,
rootMargin: '0px 0px -30px 0px'
});
cards.forEach(card => menuCardObserver.observe(card));
}
// ── Scroll Reveal — Jakub polish, editorial subtlety ────────────────────────
function initScrollReveal() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.08,
rootMargin: '0px 0px -40px 0px'
});
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
}
// ── Visit Section ───────────────────────────────────────────────────────────
function buildVisit(info) {
const hoursList = document.getElementById('hoursList');
if (hoursList && info.hours) {
hoursList.innerHTML = info.hours
.map(h => `<div class="hours-item"><span class="days">${h.days}</span><span>${h.time}</span></div>`)
.join('');
}
const addr = document.getElementById('cafeAddress');
if (addr && info.address) addr.textContent = info.address;
const phone = document.getElementById('cafePhone');
if (phone && info.phone) phone.textContent = info.phone;
const email = document.getElementById('cafeEmailText');
if (email && info.email) email.textContent = info.email;
}
// ── Navigation ──────────────────────────────────────────────────────────────
function setupNavigation() {
const nav = document.getElementById('nav');
const toggle = document.getElementById('navToggle');
if (!nav) return;
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
nav.classList.toggle('scrolled', window.scrollY > 40);
ticking = false;
});
ticking = true;
}
});
if (toggle) {
toggle.addEventListener('click', () => nav.classList.toggle('open'));
}
document.querySelectorAll('.nav-links a, .nav-logo').forEach(link => {
link.addEventListener('click', () => nav.classList.remove('open'));
});
}
// ── Newsletter — functional, no decorative animation (Emil's gate) ──────────
function setupNewsletter() {
const form = document.getElementById('newsletterForm');
if (!form) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
const btn = form.querySelector('button');
const orig = btn.textContent;
btn.textContent = 'Subscribed ✓';
btn.style.background = '#3C6E55';
form.querySelector('input').value = '';
setTimeout(() => {
btn.textContent = orig;
btn.style.background = '';
}, 2800);
});
}
// ── Smooth Scroll ───────────────────────────────────────────────────────────
function setupSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
const target = document.querySelector(this.getAttribute('href'));
if (target) {
e.preventDefault();
window.scrollTo({
top: target.offsetTop - 72,
behavior: 'smooth'
});
}
});
});
}
// ── Utility ─────────────────────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}