`;
}
// ── 1. LOCAL SPOTS — venues within 1.5 miles of the user's real GPS ──
function renderLocalSpots() {
const hd = document.getElementById('local-hd');
const list = document.getElementById('local-list');
const distLabel = document.getElementById('local-dist-label');
if (!hd || !list) return;
if (!gpsReady) {
// GPS not yet available — hide section, will re-render when GPS fires
hd.style.display = 'none';
list.innerHTML = '';
return;
}
// All venues within 1.5 miles, any type, open or closed — sorted nearest first
const LOCAL_RADIUS = 1.5; // miles
let local = DB.venues
.filter(v => v.dist != null && v.dist <= LOCAL_RADIUS)
.sort((a, b) => a.dist - b.dist);
if (!local.length) {
// Widen to 3 miles if nothing within 1.5
local = DB.venues
.filter(v => v.dist != null && v.dist <= 3.0)
.sort((a, b) => a.dist - b.dist)
.slice(0, 6);
}
if (!local.length) {
hd.style.display = 'none';
list.innerHTML = '';
return;
}
hd.style.display = 'flex';
const nearest = local[0];
const farthest = local[local.length - 1];
if (distLabel) distLabel.textContent = `· within ${farthest.dist.toFixed(1)} mi`;
list.innerHTML = local.slice(0, 8).map((v, i) => buildVenueCard(v, i, true)).join('');
}
// ── 2. POPULAR IN LA — top-rated open venues across the city (not distance-gated) ──
function renderPopularInLA() {
const list = document.getElementById('popular-la-list');
if (!list) return;
// Show open high-rated venues first, fall back to all high-rated if none open
let popular = DB.venues
.filter(v => isVenueOpen(v.id) && v.rating >= 4.2)
.sort((a, b) => b.rating - a.rating || (a.dist||99) - (b.dist||99))
.slice(0, 6);
if (!popular.length) {
// Daytime fallback — show top-rated regardless of open status
popular = DB.venues
.filter(v => v.rating >= 4.3)
.sort((a, b) => b.rating - a.rating)
.slice(0, 6);
}
if (!popular.length) {
list.innerHTML = '';
return;
}
list.innerHTML = popular.map((v, i) => buildVenueCard(v, i, true)).join('');
}
// ══════════════════════════════════════════════
// MONETIZATION FUNCTIONS
// ══════════════════════════════════════════════
// ── Featured / Verified badge helpers ──
function isFeatured(id) { return MONETIZATION.featuredIds.includes(id); }
function isVerified(id) { return MONETIZATION.verifiedIds.includes(id); }
function venueBadges(v) {
let b = '';
if (isFeatured(v.id)) b += '⭐ Featured ';
else if (isVerified(v.id)) b += '✓ Verified ';
return b;
}
// ── Promoted event banner ──
function renderPromoBanner() {
if (MONETIZATION.isPro) return ''; // Pro users see no ads
const events = MONETIZATION.promotedEvents.filter(e => {
const v = DB.venues.find(x=>x.id===e.venueId);
return v && isVenueOpen(v.id);
});
if (!events.length) return '';
const e = events[0];
const v = DB.venues.find(x=>x.id===e.venueId);
return `
${e.emoji}
${e.title}
${e.sub}
View →
`;
}
// ── Pro sheet ──
function openProSheet(source) {
trackProOpen(source || 'unknown');
document.getElementById('pro-sheet').classList.add('open');
}
function closeProSheet() {
document.getElementById('pro-sheet').classList.remove('open');
}
function selectPlan(plan, el) {
MONETIZATION.selectedPlan = plan;
document.querySelectorAll('.pro-plan').forEach(p => p.classList.remove('popular'));
el.classList.add('popular');
}
function confirmPro() {
MONETIZATION.isPro = true;
trackProConvert(MONETIZATION.selectedPlan);
closeProSheet();
showToast('🎉 Welcome to NitePulse Pro! 7-day free trial started.');
renderAll();
// Update profile badge
const pb = document.querySelector('.prof-badges');
if (pb && !pb.querySelector('.pro-badge')) {
const badge = document.createElement('span');
badge.className = 'pbadge pro-badge';
badge.textContent = '⚡ Pro';
pb.prepend(badge);
}
}
// ── Alert threshold sheet ──
function openAlertSheet(venueId) {
const v = DB.venues.find(x=>x.id===venueId);
if (!v) return;
if (!MONETIZATION.isPro) { openProSheet(); return; }
MONETIZATION.currentAlertVenueId = venueId;
const existing = MONETIZATION.alerts[venueId] || 70;
document.getElementById('alert-venue-name').textContent = '🔔 Alert for ' + v.name;
document.getElementById('threshold-slider').value = existing;
document.getElementById('threshold-val').textContent = existing + '%';
updateThreshold(existing);
document.getElementById('alert-sheet').classList.add('open');
}
function closeAlertSheet() {
document.getElementById('alert-sheet').classList.remove('open');
}
function updateThreshold(val) {
document.getElementById('threshold-val').textContent = val + '%';
const id = MONETIZATION.currentAlertVenueId;
const v = DB.venues.find(x=>x.id===id);
const name = v ? v.name : 'this venue';
document.getElementById('alert-preview-text').innerHTML =
'You will get a push notification when ' + name +
' drops to or below ' + val + '% capacity';
}
function confirmAlert() {
const id = MONETIZATION.currentAlertVenueId;
const val = parseInt(document.getElementById('threshold-slider').value);
MONETIZATION.alerts[id] = val;
const v = DB.venues.find(x=>x.id===id);
trackAlertSet(id, val);
closeAlertSheet();
showToast('🔔 Alert set! Notify when ' + (v?v.name:'venue') + ' drops below ' + val + '%');
}
// ── Save with Pro limit check ──
function toggleSaveWithLimit(id, btn) {
const v = DB.venues.find(x=>x.id===id);
if (!v) return;
if (!v.saved) {
const savedCount = DB.venues.filter(x=>x.saved).length;
if (!MONETIZATION.isPro && savedCount >= MONETIZATION.freeSaveLimit) {
openProSheet();
showToast('Pro users get unlimited saves ⚡');
return;
}
}
v.saved = !v.saved;
trackSave(id, v.saved);
if (btn) {
btn.textContent = v.saved ? '❤️ Saved' : '♡ Save';
btn.className = 'sh-action' + (v.saved ? '' : ' primary');
}
showToast(v.saved ? '❤️ ' + v.name + ' saved!' : 'Removed from saved');
renderSaved();
}
// ── Open Google Maps directions for a venue ──
function openDirections(venueId) {
const v = DB.venues.find(x => x.id === venueId);
if (!v) return;
trackDirections(venueId);
// Build Google Maps URL using real GPS coordinates
const url = 'https://www.google.com/maps/dir/?api=1&destination='
+ v.gpsLat + ',' + v.gpsLng
+ '&destination_place_name=' + encodeURIComponent(v.name)
+ '&travelmode=driving';
window.open(url, '_blank');
}
// ── Business contact ──
function bizContact(plan) {
// Build email dynamically so Cloudflare doesn't inject obfuscation
var u = 'partners';
var d = ['nightpulse','today'].join('.');
var addr = u + '\u0040' + d;
showToast('📬 Opening email to ' + addr);
setTimeout(function() {
var subj = 'NitePulse Partner Enquiry — ' + plan + ' Plan';
var body = 'Hi NitePulse team,%0A%0AI am interested in the ' + plan + ' plan for my venue.%0A%0AVenue name:%0AContact:%0A%0AThanks';
window.location.href = 'mailto:' + addr + '?subject=' + encodeURIComponent(subj) + '&body=' + body;
}, 800);
track('biz_contact_clicked', { plan: plan });
}
// ── Pro upgrade banner for profile ──
function renderProUpgradeBanner() {
if (MONETIZATION.isPro) return '';
return `
⚡
Upgrade to Pro
Alerts, forecasts, unlimited saves · from $1.99/mo
7 days free →
`;
}
// ── Screen routing ──
let currentScreen = 'home';
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.bnav-btn').forEach(b => b.classList.remove('active'));
const s = document.getElementById('s-'+name);
const b = document.getElementById('bn-'+name);
if (s) s.classList.add('active');
if (b) b.classList.add('active');
currentScreen = name;
trackScreen(name);
if (name==='map') drawMap();
if (name==='saved') renderSaved();
if (name==='explore') renderExplore();
if (name==='profile') {
renderProfileScreen(); // handles both logged-in and logged-out states
}
if (name==='community') {
renderFeed();
renderWeeklyPoll('poll-container');
}
if (name==='business') trackBizView();
}
// ── Greeting ──
function updateGreeting() {
const h = new Date().getHours();
const g = h<12?'Good morning':h<17?'Good afternoon':'Good evening';
document.getElementById('greeting-sub').textContent = `${g} · Los Angeles`;
}
// ── Render HOME ──
let homeType = 'all';
function homeFilter(t, btn) {
homeType = t;
trackFilter('venue_type', t);
document.querySelectorAll('#home-filters .fchip').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
renderHomeVenues();
}
function renderHomeVenues() {
// Show GPS nudge if no location yet
const nudge = document.getElementById('gps-nudge');
if (nudge) nudge.style.display = gpsReady ? 'none' : 'block';
// Cap at 3 miles when GPS is available — no more far-away venues here
const MAX_DIST_NEARBY = 3.0;
const list = DB.venues
.filter(v => {
if (homeType !== 'all' && v.type !== homeType) return false;
// Distance cap: if GPS ready, only show venues within 3 miles
if (gpsReady && v.dist != null && v.dist > MAX_DIST_NEARBY) return false;
return true;
})
.sort((a,b) => {
const aOpen = isVenueOpen(a.id), bOpen = isVenueOpen(b.id);
if (aOpen !== bOpen) return aOpen ? -1 : 1;
if (gpsReady) return (a.dist||99) - (b.dist||99);
return b.rating - a.rating;
}).slice(0, 12);
document.getElementById('home-venue-list').innerHTML = list.map((v,i) => {
const od = occDisplay(v);
const barW = od.closed ? 0 : v.occ;
const status = getVenueStatus(v.id);
return `
`;
}).join('');
}
function renderHotList() {
const hotEl = document.getElementById('hot-list');
if (!hotEl) return;
// Show open venues first, fall back to top-rated if nothing open
let hot = [...DB.venues].filter(v => isVenueOpen(v.id)).sort((a,b) => b.occ - a.occ).slice(0,5);
let showingClosed = false;
if (!hot.length) {
// Nothing open — show top rated venues as "Opening Tonight" preview
hot = [...DB.venues].filter(v => v.city === CURRENT_CITY || !v.city).sort((a,b) => b.rating - a.rating).slice(0,5);
showingClosed = true;
}
if (!hot.length) { hotEl.innerHTML = '
${checkins} check-ins${tier.nextAt} to unlock ${tier.next} ${tier.next==='Explorer'?'🗺️':tier.next==='Regular'?'🌟':'👑'}
` : '
🏆 Maximum tier reached!
'}
${checkins === 0
? "👋 Welcome to NitePulse! First check-in earns the 🚀 Pioneer badge + 50 pts. App auto-detects you at venues."
: "🙏 Thank you for contributing! Your reports help everyone find the right spot. Keep checking in to climb the ranks."}
`;
}
// ══════════════════════════════════════════════════════════
// FIND MY NIGHT — MATCHING ENGINE
// ══════════════════════════════════════════════════════════
// ── Venue atmosphere metadata ──
// mood tags, music keywords, atmosphere descriptors
const VENUE_META = {
1: { moods:['chill','date'], musicKeys:['jazz','live'], atm:['atm-chill','atm-date'], atmLabels:['💬 Conversation-friendly','💑 Date night vibes'] },
2: { moods:['dance'], musicKeys:['edm'], atm:['atm-dance'], atmLabels:['💃 Active dance floor','🎛️ World-class DJs'] },
3: { moods:['date','group','meet'], musicKeys:['hiphop','any'], atm:['atm-date','atm-group'], atmLabels:['🌆 Rooftop views','👯 Group-friendly'] },
4: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🏆 Sports & games','👯 Group-friendly'] },
5: { moods:['dance'], musicKeys:['edm'], atm:['atm-dance'], atmLabels:['💃 Active dance floor','🎉 High energy'] },
6: { moods:['date','meet'], musicKeys:['latin'], atm:['atm-date','atm-live'], atmLabels:['💑 Date night vibes','🎺 Live performance'] },
7: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🎸 Live music','👯 Group-friendly'] },
8: { moods:['date','chill'], musicKeys:['any'], atm:['atm-chill','atm-date'], atmLabels:['💬 Conversation-friendly','🍷 Natural wine'] },
9: { moods:['dance','meet'], musicKeys:['hiphop'], atm:['atm-dance','atm-meet'], atmLabels:['💃 Dance floor','🌟 Celebrity crowd'] },
10: { moods:['date','chill'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['🌆 Rooftop views','💑 Date night vibes'] },
11: { moods:['chill','meet','date'], musicKeys:['jazz','any'], atm:['atm-chill','atm-date'], atmLabels:['💬 Conversation-friendly','🍸 Craft cocktails'] },
12: { moods:['meet','date'], musicKeys:['hiphop','any'], atm:['atm-meet','atm-date'], atmLabels:['🕶️ Celebrity crowd','💑 Date night vibes'] },
13: { moods:['date','chill'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['🌅 Sunset views','💑 Date night vibes'] },
14: { moods:['date','meet'], musicKeys:['any'], atm:['atm-date','atm-group'], atmLabels:['🌊 Rooftop poolside','👯 Group-friendly'] },
15: { moods:['chill','group'], musicKeys:['latin','any'], atm:['atm-chill','atm-group'], atmLabels:['🥥 Tropical vibes','👯 Group-friendly'] },
16: { moods:['date','chill'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['💑 Date night vibes','🇫🇷 French wine bar'] },
17: { moods:['chill','date'], musicKeys:['any'], atm:['atm-chill','atm-date'], atmLabels:['🥃 Whiskey specialist','💬 Intimate'] },
18: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🍺 Craft beer','👯 Group patio'] },
19: { moods:['meet','group','date'], musicKeys:['edm','any'], atm:['atm-meet','atm-group'], atmLabels:['🎡 Carousel bar','🌆 Rooftop DTLA'] },
20: { moods:['date','meet'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['🏛️ Mediterranean','💑 Date night vibes'] },
21: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🏦 Historic venue','🍺 Craft beer'] },
22: { moods:['dance','meet'], musicKeys:['edm','any'], atm:['atm-dance','atm-meet'], atmLabels:['🏊 Pool party','💃 Dance floor'] },
23: { moods:['date','chill'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['🌺 Tiki classics','💑 Intimate'] },
24: { moods:['date','chill'], musicKeys:['jazz'], atm:['atm-date','atm-live'], atmLabels:['🎹 Live jazz','💑 Classic lounge'] },
25: { moods:['date','meet'], musicKeys:['latin'], atm:['atm-date','atm-live'], atmLabels:['💑 Date night vibes','🎺 Live performance'] },
26: { moods:['chill','group','meet'], musicKeys:['rock','any'], atm:['atm-chill','atm-meet'], atmLabels:['🎱 Dive bar','👥 Meet locals'] },
27: { moods:['chill','meet'], musicKeys:['jazz','any'], atm:['atm-live','atm-chill'], atmLabels:['🎸 Live music nightly','🎨 Arts crowd'] },
28: { moods:['meet','dance'], musicKeys:['any'], atm:['atm-meet','atm-live'], atmLabels:['🎭 Burlesque shows','🎉 Eclectic crowd'] },
29: { moods:['chill','meet'], musicKeys:['any'], atm:['atm-chill','atm-meet'], atmLabels:['🍸 Craft cocktails','💬 Intimate'] },
30: { moods:['dance','meet'], musicKeys:['edm','hiphop'], atm:['atm-dance','atm-meet'], atmLabels:['🪩 Studio 54 vibe','💃 Dance floor'] },
31: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🎱 Shuffleboard','👥 Local regulars'] },
32: { moods:['group','meet'], musicKeys:['any'], atm:['atm-group','atm-meet'], atmLabels:['🎤 Karaoke nights','👯 Group-friendly'] },
33: { moods:['chill','group'], musicKeys:['any'], atm:['atm-chill','atm-group'], atmLabels:['🎯 Pool tables','🏈 Sports bar'] },
34: { moods:['group','meet'], musicKeys:['any'], atm:['atm-group','atm-meet'], atmLabels:['🎶 Karaoke nightly','👥 Mixed crowd'] },
35: { moods:['chill','date'], musicKeys:['any'], atm:['atm-chill','atm-date'], atmLabels:['🍺 36 craft taps','💑 Intimate'] },
36: { moods:['chill','meet'], musicKeys:['rock','latin'], atm:['atm-live','atm-chill'], atmLabels:['🎸 Live music nightly','💬 Casual'] },
37: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🏄 Beach dive','🍔 Best burger'] },
38: { moods:['chill','date'], musicKeys:['any'], atm:['atm-chill','atm-date'], atmLabels:['💑 Last-drink intimate','🌙 Late night'] },
39: { moods:['meet','dance'], musicKeys:['hiphop','any'], atm:['atm-meet','atm-dance'], atmLabels:['🎷 DJ nightly','👥 Venice crowd'] },
40: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🌮 Kogi BBQ','🍺 Craft beer'] },
41: { moods:['group','meet'], musicKeys:['hiphop','any'], atm:['atm-group','atm-meet'], atmLabels:['🏈 Sports screens','🎉 Late night'] },
42: { moods:['dance','meet'], musicKeys:['hiphop'], atm:['atm-dance','atm-meet'], atmLabels:['💃 Dance floor','🥂 Bottle service'] },
43: { moods:['date','chill'], musicKeys:['jazz','any'], atm:['atm-date','atm-chill'], atmLabels:['🎹 Live music','💑 Date night vibes'] },
44: { moods:['date','chill','meet'], musicKeys:['jazz','any'], atm:['atm-date','atm-chill'], atmLabels:['📚 English manor','🥃 Whiskey lounge'] },
45: { moods:['date','meet'], musicKeys:['any'], atm:['atm-date','atm-group'], atmLabels:['🌄 Panoramic views','💑 Date night'] },
46: { moods:['date','chill'], musicKeys:['jazz','any'], atm:['atm-date','atm-chill'], atmLabels:['🍸 Award-winning cocktails','💑 Date night'] },
47: { moods:['date','chill'], musicKeys:['jazz','any'], atm:['atm-date','atm-chill'], atmLabels:['💎 Luxury hotel bar','💑 Date night'] },
48: { moods:['date','meet','chill'], musicKeys:['any'], atm:['atm-date','atm-chill'], atmLabels:['🌿 Hidden courtyard','💑 Date night'] },
49: { moods:['chill','group'], musicKeys:['rock','any'], atm:['atm-chill','atm-group'], atmLabels:['🍺 Dive bar','💬 Local crowd'] },
50: { moods:['chill','date'], musicKeys:['any'], atm:['atm-chill','atm-date'], atmLabels:['🎩 Speakeasy','🍸 No-frills craft'] },
51: { moods:['chill','meet'], musicKeys:['rock','any'], atm:['atm-chill','atm-meet'], atmLabels:['🎵 Vinyl bar','🍺 Craft beer'] },
52: { moods:['date','meet'], musicKeys:['any'], atm:['atm-date','atm-group'], atmLabels:['🏊 Rooftop pool','🌆 Beverly Hills views'] },
};
// ── Music keyword matching ──
const MUSIC_MAP = {
hiphop: ['hip-hop','r&b','trap','hip hop'],
edm: ['edm','house','electronic','techno','bass','hardstyle'],
jazz: ['jazz','live','blues','piano','cabaret','swing'],
latin: ['salsa','bachata','latin','cuban','reggaeton'],
rock: ['rock','punk','alternative','blues','indie','country'],
any: [], // matches anything
};
// ── FMN State ──
const FMN = {
answers: { mood:null, music:null, crowd:null, occ:null },
currentStep: 0,
isPro: false, // mirrors MONETIZATION.isPro
};
// ── Open / close FMN sheet ──
function openFMN() {
document.getElementById('fmn-sheet').classList.add('open');
// Reset to step 0
resetFMN();
track('fmn_opened', {});
// hide fab label after first use
const lbl = document.getElementById('fab-label');
if (lbl) lbl.style.display = 'none';
}
function closeFMN() {
document.getElementById('fmn-sheet').classList.remove('open');
}
function resetFMN() {
FMN.answers = { mood:null, music:null, crowd:null, occ:null };
FMN.currentStep = 0;
// Show steps, hide results
document.querySelectorAll('.fmn-step').forEach(s => s.classList.remove('active'));
document.getElementById('fmn-step-0').classList.add('active');
document.getElementById('fmn-results').classList.remove('show');
// Reset all selections
document.querySelectorAll('.fmn-opt, .fmn-grid-opt').forEach(o => o.classList.remove('selected'));
// Reset buttons
[0,1,2,3].forEach(i => {
const btn = document.getElementById('fmn-next-' + i);
if (btn) btn.disabled = true;
});
updateFMNDots(0);
}
function updateFMNDots(step) {
[0,1,2,3].forEach(i => {
const dot = document.getElementById('dot-' + i);
if (dot) dot.classList.toggle('active', i === step);
});
}
// ── Step selection ──
const FMN_KEYS = ['mood','music','crowd','occ'];
function selectFMN(step, value, el) {
FMN.answers[FMN_KEYS[step]] = value;
// Deselect siblings
const parent = el.parentElement;
parent.querySelectorAll('.fmn-opt, .fmn-grid-opt').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
// Enable next
const btn = document.getElementById('fmn-next-' + step);
if (btn) btn.disabled = false;
}
function goFMNStep(step) {
document.querySelectorAll('.fmn-step').forEach(s => s.classList.remove('active'));
document.getElementById('fmn-step-' + step).classList.add('active');
FMN.currentStep = step;
updateFMNDots(step);
// Scroll to top of sheet
document.getElementById('fmn-sheet').scrollTo({top:0, behavior:'smooth'});
}
// ── Run the match algorithm ──
function runFMNMatch() {
const { mood, music, crowd, occ } = FMN.answers;
FMN.isPro = MONETIZATION.isPro;
// Score every open venue
const scored = DB.venues
.filter(v => isVenueOpen(v.id))
.map(v => {
const meta = VENUE_META[v.id];
if (!meta) return null;
let score = 0;
let reasons = [];
let warnings = [];
// ── 1. Mood match (30 pts) ──
if (meta.moods.includes(mood)) {
score += 30;
const moodLabels = {chill:'Chill atmosphere ✓',dance:'Dance floor ✓',meet:'Social crowd ✓',date:'Date night vibes ✓',group:'Group-friendly ✓'};
reasons.push(moodLabels[mood] || 'Mood match ✓');
} else {
score += 5; // partial — still show it
warnings.push('Different vibe');
}
// ── 2. Music match (25 pts) ──
const musicGenre = (v.music || '').toLowerCase();
const keywords = music === 'any' ? [] : (MUSIC_MAP[music] || []);
const musicMatch = music === 'any' || keywords.some(k => musicGenre.includes(k))
|| (meta.musicKeys || []).includes(music);
if (musicMatch) {
score += 25;
if (music !== 'any') reasons.push('Music match ✓');
} else {
warnings.push('Different music');
}
// ── 3. Crowd age match (25 pts) ──
const userAge = PIONEER.ageGroup;
const demo = getVenueDemographics(v.id);
if (crowd === 'any') {
score += 20;
} else if (demo && demo.total >= 3) {
// Real demographic data exists — use it
const topAge = Object.entries(demo.ages).sort((a,b)=>b[1]-a[1])[0];
const ageGroups30plus = ['31-35','36-40','40+'];
const ageGroups20s = ['21-25','26-30'];
if (crowd === 'similar' && userAge && topAge && topAge[0] === userAge) {
score += 25; reasons.push('Your age group ✓');
} else if (crowd === 'older' && topAge && ageGroups30plus.includes(topAge[0])) {
score += 25; reasons.push('Mature crowd ✓');
} else if (crowd === 'mixed') {
const uniqueAges = Object.keys(demo.ages).length;
score += uniqueAges >= 3 ? 25 : 15;
if (uniqueAges >= 3) reasons.push('Mixed crowd ✓');
} else {
score += 10;
}
} else {
// No crowd data yet — use venue type heuristics
const clubAge = { 'club':true, 'lounge':false };
if (crowd === 'older' && (v.type === 'lounge' || v.type === 'rooftop')) { score += 20; reasons.push('Tends older crowd'); }
else if (crowd === 'mixed') { score += 15; }
else { score += 10; }
}
// ── 4. Occupancy preference (20 pts) ──
const occRanges = { quiet:[0,40], moderate:[40,70], busy:[70,90], packed:[90,101] };
const [lo, hi] = occRanges[occ] || [0,101];
if (v.occ >= lo && v.occ < hi) {
score += 20; reasons.push('Right crowd level ✓');
} else if (Math.abs(v.occ - (lo+hi)/2) < 20) {
score += 10; warnings.push('Slightly ' + (v.occ < lo ? 'quieter' : 'busier') + ' than ideal');
} else {
score += 3; warnings.push(v.occ < lo ? 'Too quiet right now' : 'More packed than you want');
}
return { v, score: Math.min(99, score), reasons, warnings };
})
.filter(Boolean)
.sort((a,b) => b.score - a.score);
// Show results
document.querySelectorAll('.fmn-step').forEach(s => s.classList.remove('active'));
document.getElementById('fmn-results').classList.add('show');
const sub = document.getElementById('fmn-result-sub');
const moodNames = {chill:'a chill night',dance:'dancing',meet:'meeting people',date:'a date night',group:'a group outing'};
sub.textContent = 'Best venues for ' + (moodNames[mood]||'tonight') + ' right now';
const top = scored.slice(0, MONETIZATION.isPro ? 5 : 3);
const cards = document.getElementById('fmn-match-cards');
cards.innerHTML = top.map((item, idx) => {
const { v, score, reasons, warnings } = item;
const od = occDisplay(v);
const isTopMatch = idx === 0;
// Free users: show score but not breakdown beyond top 3
const showBreakdown = MONETIZATION.isPro || idx < 1;
return `
`;
}).join('');
// Add Pro upsell if not Pro
if (!MONETIZATION.isPro) {
cards.innerHTML += `
⚡ See ${scored.length - 3} more matches + full breakdown
Unlock with NitePulse Pro · 7-day free trial
`;
}
track('fmn_completed', { mood, music, crowd, occ, top_match: top[0]?.v?.name, top_score: top[0]?.score });
// Scroll results into view
setTimeout(() => document.getElementById('fmn-sheet').scrollTo({top:0,behavior:'smooth'}), 100);
}
// ══════════════════════════════════════════════════════════
// GOOGLE PLACES API INTEGRATION
// Real-time busyness data from Google
// Replace GOOGLE_PLACES_KEY with your actual API key
// ══════════════════════════════════════════════════════════
const GOOGLE_PLACES_KEY = 'USE_PROXY'; // Key stored securely in Netlify env vars
const PLACES_CACHE_TTL = 30 * 60 * 1000; // 30 min cache
const PLACES_ENABLED = !['YOUR_API_KEY_HERE',''].includes(GOOGLE_PLACES_KEY);
// ── Place IDs for all 52 venues (fetched once, cached forever) ──
// These map venue IDs to Google Place IDs for fast lookups
const PLACE_ID_CACHE_KEY = 'np_place_ids_v1';
const BUSY_CACHE_KEY = 'np_busy_cache_v1';
// ── Known Google Place IDs (pre-fetched to save API calls) ──
// Format: venueId -> Google Place ID
const KNOWN_PLACE_IDS = {
// Verified working Place IDs — fetched April 2026
// Others get discovered automatically via findPlaceId()
6: 'ChIJR589Ma24woARLobYwRkcSJM', // La Descarga ✓ verified
// All other venues discovered dynamically on first load
};
// ── Busyness cache ──
const BusyCache = {
load() {
try { return JSON.parse(localStorage.getItem(BUSY_CACHE_KEY) || '{}'); } catch(e) { return {}; }
},
save(d) {
try { localStorage.setItem(BUSY_CACHE_KEY, JSON.stringify(d)); } catch(e) {}
},
get(venueId) {
const d = this.load();
const entry = d[venueId];
if (!entry) return null;
if (Date.now() - entry.ts > PLACES_CACHE_TTL) return null; // expired
return entry;
},
set(venueId, pct, source) {
const d = this.load();
d[venueId] = { pct, source, ts: Date.now() };
this.save(d);
}
};
// ── Fetch current busyness for a venue from Google Places ──
async function fetchGoogleBusyness(venueId) {
if (!PLACES_ENABLED) return null;
// Check cache first
const cached = BusyCache.get(venueId);
if (cached) return cached;
const v = DB.venues.find(x => x.id === venueId);
if (!v) return null;
try {
// Step 1: Find the place if we don't have an ID
let placeId = KNOWN_PLACE_IDS[venueId];
if (!placeId) {
// Dynamically find the Place ID via our secure proxy
placeId = await findPlaceId(venueId);
if (!placeId) return null;
}
// Step 2: Get place details including current_opening_hours.periods
// and popular_times (requires Places API Advanced = $17/1000 calls)
// The basic "busy" signal uses current_popularity field
const detailsUrl = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=current_popularity,opening_hours,rating,user_ratings_total&key=${GOOGLE_PLACES_KEY}`;
// ⚠️ CORS note: This call must go through a backend proxy in production
// The proxy pattern is: App → Your server → Google API → App
// Until backend is set up, we use the serverless function approach below
const result = await callPlacesProxy(placeId);
if (result) {
// Store real Google reviews on the venue object
if (result.reviews && result.reviews.length) {
const v2 = DB.venues.find(x => x.id === venueId);
if (v2) v2.googleReviews = result.reviews;
}
// Store live busyness
if (result.current_popularity != null) {
const pct = result.current_popularity;
BusyCache.set(venueId, pct, 'google');
return { pct, source: 'google', ts: Date.now() };
}
}
} catch(err) {
console.log('Places API error for venue', venueId, err.message);
}
return null;
}
// ── Serverless proxy function (Netlify Function) ──
// This handles the CORS issue — your app calls your Netlify function,
// which calls Google on the server side
async function callPlacesProxy(placeId) {
try {
const res = await fetch(`/.netlify/functions/places?place_id=${placeId}`);
if (!res.ok) return null;
const data = await res.json();
if (data.error) { console.log('Places API:', data.error); return null; }
return data;
} catch(e) {
console.log('Places proxy unavailable:', e.message);
return null;
}
}
// ── Find a place ID by venue name + GPS coordinates ──
async function findPlaceId(venueId) {
const v = DB.venues.find(x => x.id === venueId);
if (!v) return null;
// Check local cache first
try {
const cached = JSON.parse(localStorage.getItem('np_place_ids_v1') || '{}');
if (cached[venueId]) return cached[venueId];
} catch(e) {}
try {
const query = encodeURIComponent(v.name + ' ' + v.addr);
const res = await fetch(
`/.netlify/functions/places?query=${query}&lat=${v.gpsLat}&lng=${v.gpsLng}`
);
if (!res.ok) return null;
const data = await res.json();
const placeId = data.candidates?.[0]?.place_id;
if (placeId) {
// Cache it so we never look it up again
try {
const cached = JSON.parse(localStorage.getItem('np_place_ids_v1') || '{}');
cached[venueId] = placeId;
localStorage.setItem('np_place_ids_v1', JSON.stringify(cached));
} catch(e) {}
}
return placeId || null;
} catch(e) { return null; }
}
// ── Update all venue occupancies from Google ──
async function refreshFromGoogle() {
if (!PLACES_ENABLED) return;
// Try all open venues — unknown Place IDs get looked up automatically
// Prioritise venues with known IDs first (faster), then discover others
const knownIds = Object.keys(KNOWN_PLACE_IDS).map(Number);
const allOpenIds = DB.venues.filter(v => isVenueOpen(v.id)).map(v => v.id);
// Put known IDs first, then others
const venueIds = [...new Set([...knownIds, ...allOpenIds])].slice(0, 20); // cap at 20 to stay in free tier
for (const id of venueIds) {
if (!isVenueOpen(id)) continue;
const result = await fetchGoogleBusyness(id);
if (result && result.pct > 0) {
const v = DB.venues.find(x => x.id === id);
if (v) {
v.occ = result.pct;
v.dataSource = 'google';
}
}
// Small delay between calls to avoid rate limiting
await new Promise(r => setTimeout(r, 200));
}
renderAll();
}
// ── Data source label (updated to include Google) ──
function occSourceLabelFull(v) {
if (!isVenueOpen(v.id)) return '';
// Priority: crowd votes > Google Places > simulation
if (v.crowdData && v.crowdData.hasData && v.crowdData.voteCount >= 3) {
return '
👥 Community reported
';
}
if (v.dataSource === 'google') {
return '
📍 Google Places data
';
}
return '
○ Estimated · awaiting reports
';
}
// ══════════════════════════════════════════════════════════
// AUTH SYSTEM — Email/Password + Google Sign-In
// localStorage-based (no backend needed yet)
// ══════════════════════════════════════════════════════════
const AUTH = {
// Auth disabled — stub object so references don't error
_key: 'np_user_v1',
user: null,
isLoggedIn: false,
isProfileComplete: false,
save: function(d) { return d; },
signup: function() { return { error: 'Auth disabled' }; },
login: function() { return { error: 'Auth disabled' }; },
signout: function() { return; },
};
// ── Onboarding state ──
const ONBOARD = {
data: { age: null, gender: null, music: [], vibe: null, notifications: false }
};
// ── Switch login / signup tab ──
function switchAuthTab(mode) {
AUTH._mode = mode;
document.getElementById('tab-signup').classList.toggle('active', mode === 'signup');
document.getElementById('tab-login').classList.toggle('active', mode === 'login');
document.getElementById('field-name').style.display = mode === 'signup' ? 'block' : 'none';
document.getElementById('auth-submit-btn').textContent =
mode === 'signup' ? 'Create account →' : 'Sign in →';
document.getElementById('auth-error').classList.remove('show');
// Update password autocomplete
document.getElementById('auth-password').autocomplete =
mode === 'signup' ? 'new-password' : 'current-password';
}
// ── Show auth error ──
function showAuthError(msg) {
const el = document.getElementById('auth-error');
el.textContent = msg;
el.classList.add('show');
}
// ── Submit email auth ──
async function submitAuth() {
const nameEl = document.getElementById('auth-name');
const emailEl = document.getElementById('auth-email');
const passEl = document.getElementById('auth-password');
const btn = document.getElementById('auth-submit-btn');
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const pass = passEl ? passEl.value : '';
if (!email || !email.includes('@')) { showAuthError('Please enter a valid email address'); return; }
btn.disabled = true;
btn.textContent = 'Sending code...';
const errEl = document.getElementById('auth-error');
if (errEl) errEl.classList.remove('show');
if (AUTH._mode === 'signup') {
// ── SIGNUP: validate → send code → verify ──
if (!name || name.length < 2) {
btn.disabled = false;
btn.textContent = 'Create account →';
showAuthError('Please enter your first name');
return;
}
// Store pending user — saved to AUTH only after verification
VERIFY.pendingUser = {
name,
email: email.toLowerCase(),
password: pass ? btoa(pass) : null,
method: 'email',
joinDate: Date.now(),
verified: false,
emailVerified: false,
};
// Send verification code
const sent = await VERIFY.send(email, name);
btn.disabled = false;
btn.textContent = 'Create account →';
hideAuthScreen();
showVerifyScreen(email, 'signup');
track('sign_up_started', { method: 'email' });
} else {
// ── LOGIN: check if user exists ──
const u = { name: 'You', email: '' };
if (!u) {
btn.disabled = false;
btn.textContent = 'Sign in →';
showAuthError('No account found with this email. Please create one first.');
return;
}
if (u.email !== email.toLowerCase()) {
btn.disabled = false;
btn.textContent = 'Sign in →';
showAuthError('Email not found. Check spelling or create an account.');
return;
}
// If they have a password and entered one, use it
if (pass && u.password && u.password === btoa(pass)) {
btn.disabled = false;
btn.textContent = 'Sign in →';
hideAuthScreen();
showScreen('profile');
renderProfileScreen();
showToast('✅ Welcome back, ' + (u.name||'') + '!');
track('login', { method: 'password' });
return;
}
// Otherwise send a login code
const sent = await VERIFY.send(email, u.name);
btn.disabled = false;
btn.textContent = 'Sign in →';
hideAuthScreen();
showVerifyScreen(email, 'login');
track('login_code_sent', { method: 'email' });
}
}
// ── Google auth ──
async function authWithGoogle() {
const result = await AUTH.googleSignIn();
if (result.error) return;
track('sign_up', { method: 'google' });
showOnboarding();
}
// ── Show / hide auth screen ──
function showAuthScreen() {
const el = document.getElementById('auth-screen');
if (!el) return;
// Position over the entire app
el.style.cssText = 'display:flex;position:fixed;inset:0;z-index:200;background:var(--dark);flex-direction:column;overflow-y:auto;';
// Reset form fields
const name = document.getElementById('auth-name');
const email = document.getElementById('auth-email');
const pass = document.getElementById('auth-password');
if (name) name.value = '';
if (email) email.value = '';
if (pass) pass.value = '';
const err = document.getElementById('auth-error');
if (err) err.classList.remove('show');
switchAuthTab('signup');
// Scroll to top
el.scrollTo({top: 0});
}
function hideAuthScreen() {
const el = document.getElementById('auth-screen');
if (el) el.style.display = 'none';
}
// ── Onboarding flow ──
function showOnboarding() {
hideAuthScreen();
document.getElementById('onboard-screen').style.display = 'flex';
goOnboardStep(1);
}
function goOnboardStep(step) {
[1,2,3].forEach(s => {
const el = document.getElementById('onboard-step-' + s);
if (el) el.style.display = s === step ? 'block' : 'none';
});
document.getElementById('onboard-fill').style.width = (step * 33.3) + '%';
document.getElementById('onboard-screen').scrollTo({ top: 0, behavior: 'smooth' });
}
function skipOnboarding() {
document.getElementById('onboard-screen').style.display = 'none';
showScreen('profile');
renderProfileScreen();
}
// ── Onboarding chip selectors ──
function obSelect(field, value, btn) {
ONBOARD.data[field] = value;
const groupId = field === 'age' ? 'ob-age-chips' : field === 'gender' ? 'ob-gender-chips' : 'ob-vibe-chips';
document.querySelectorAll('#' + groupId + ' .onboard-chip').forEach(c => c.classList.remove('on'));
btn.classList.add('on');
// Enable next button for step 1 when age is selected
if (field === 'age') document.getElementById('ob-next-1').disabled = false;
}
function obMulti(field, value, btn) {
if (!ONBOARD.data[field]) ONBOARD.data[field] = [];
const arr = ONBOARD.data[field];
const idx = arr.indexOf(value);
if (idx >= 0) {
arr.splice(idx, 1);
btn.classList.remove('on');
} else {
arr.push(value);
btn.classList.add('on');
}
}
// ── Finish onboarding ──
function finishOnboarding(notifications) {
ONBOARD.data.notifications = notifications;
// Save all preferences to user profile
AUTH.save({
ageGroup: ONBOARD.data.age,
gender: ONBOARD.data.gender,
musicPrefs: ONBOARD.data.music,
vibePreference: ONBOARD.data.vibe,
notifications: notifications,
verified: true, // profile complete = verified checkmark
onboardedAt: Date.now(),
});
// Also save to PIONEER store for crowd matching
if (ONBOARD.data.age) PIONEER.setDemo('ageGroup', ONBOARD.data.age);
if (ONBOARD.data.gender) PIONEER.setDemo('gender', ONBOARD.data.gender);
document.getElementById('onboard-screen').style.display = 'none';
track('onboarding_completed', {
has_age: !!ONBOARD.data.age,
has_gender: !!ONBOARD.data.gender,
music_count: (ONBOARD.data.music || []).length,
has_vibe: !!ONBOARD.data.vibe,
notifications: notifications,
});
showScreen('profile');
renderProfileScreen();
showToast('🎉 Welcome to NitePulse! Your profile is set up.');
}
// ── Sign out ──
function signOut() {
AUTH.signout();
// Clear cached DOM elements that show old user data
var sr = document.getElementById('signout-row');
if (sr) sr.remove();
var badgeRow = document.getElementById('prof-badges-row');
if (badgeRow) badgeRow.innerHTML = '';
var nameEl = document.getElementById('prof-name-text');
if (nameEl) nameEl.textContent = 'Create your profile';
var subEl = document.getElementById('prof-sub-text');
if (subEl) subEl.textContent = 'Sign up to track your nights out';
renderProfileScreen();
showToast('Signed out successfully');
}
// ══════════════════════════════════════════════════════════
// COMMUNITY ENGINE
// Weekly Poll · Live Feed · Leaderboard · Follow system
// ══════════════════════════════════════════════════════════
// ── WEEKLY POLL DATA ──
// Poll resets every Monday. Seeded with real LA venues.
const WEEKLY_POLLS = [
{
id: 'poll_wk1',
question: "LA's hottest spot this weekend?",
options: [
{ id:'a', venue: 'La Descarga', emoji:'🎺', venueId: 6 },
{ id:'b', venue: 'Zouk LA', emoji:'🎛️', venueId: 2 },
{ id:'c', venue: 'No Vacancy', emoji:'🌿', venueId: 13 },
{ id:'d', venue: 'Academy LA', emoji:'🔊', venueId: 5 },
{ id:'e', venue: 'Dante Beverly Hills',emoji:'🍸', venueId: 46 },
]
},
{
id: 'poll_wk2',
question: "Best rooftop bar in LA right now?",
options: [
{ id:'a', venue: "Harriet's Rooftop", emoji:'🌆', venueId: 19 },
{ id:'b', venue: 'Nava Rooftop', emoji:'🌄', venueId: 45 },
{ id:'c', venue: 'Poza Rooftop BH', emoji:'🏊', venueId: 52 },
{ id:'d', venue: 'Bar Lis', emoji:'🌴', venueId: 15 },
{ id:'e', venue: 'Standard Rooftop', emoji:'🏙️', venueId: 7 },
]
},
{
id: 'poll_wk3',
question: "Best date night spot in LA?",
options: [
{ id:'a', venue: 'The Wellesbourne', emoji:'📚', venueId: 44 },
{ id:'b', venue: 'Employees Only', emoji:'🍸', venueId: 17 },
{ id:'c', venue: 'The Hideaway', emoji:'🌿', venueId: 48 },
{ id:'d', venue: 'Roger Room', emoji:'🎩', venueId: 50 },
{ id:'e', venue: 'Maybourne Bar', emoji:'💎', venueId: 47 },
]
},
{
id: 'poll_wk4',
question: "Where does LA go for live music?",
options: [
{ id:'a', venue: 'La Descarga', emoji:'🎺', venueId: 6 },
{ id:'b', venue: 'Zebulon', emoji:'🎸', venueId: 27 },
{ id:'c', venue: 'The Dresden Room', emoji:'🎹', venueId: 24 },
{ id:'d', venue: 'Cha Cha Lounge', emoji:'🌮', venueId: 26 },
{ id:'e', venue: 'Dusty Vinyl', emoji:'🎵', venueId: 51 },
]
},
];
// ── Poll store ──
const POLL_STORE = {
_key: 'np_polls_v1',
load() { try { return JSON.parse(localStorage.getItem(this._key)||'{}'); } catch(e) { return {}; } },
save(d) { try { localStorage.setItem(this._key, JSON.stringify(d)); } catch(e) {} },
getCurrentPoll() {
// Rotate weekly based on week number
const week = Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000));
return WEEKLY_POLLS[week % WEEKLY_POLLS.length];
},
getVotes(pollId) {
const d = this.load();
if (!d[pollId]) {
// Seed with realistic vote distribution (randomised slightly each install)
const base = [14, 9, 18, 7, 11];
const shuffled = base.map(v => v + Math.floor(Math.random()*5));
d[pollId] = {
votes: { a:shuffled[0], b:shuffled[1], c:shuffled[2], d:shuffled[3], e:shuffled[4] },
myVote: null
};
this.save(d);
}
return d[pollId];
},
vote(pollId, optionId) {
const d = this.load();
if (!d[pollId]) d[pollId] = { votes: { a:12, b:8, c:15, d:6, e:9 }, myVote: null };
if (d[pollId].myVote) return null; // already voted
d[pollId].votes[optionId] = (d[pollId].votes[optionId] || 0) + 1;
d[pollId].myVote = optionId;
this.save(d);
PIONEER.addPoints(5, 'Weekly poll vote');
return d[pollId];
},
getTimeLeft() {
const now = new Date();
const nextMonday = new Date(now);
const day = now.getDay();
const daysUntilMonday = day === 0 ? 1 : 8 - day;
nextMonday.setDate(now.getDate() + daysUntilMonday);
nextMonday.setHours(0,0,0,0);
const diff = nextMonday - now;
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
return days > 0 ? days + 'd ' + hours + 'h left' : hours + 'h left';
}
};
// ── Render weekly poll ──
function renderWeeklyPoll(containerId) {
const poll = POLL_STORE.getCurrentPoll();
const pollData = POLL_STORE.getVotes(poll.id);
const totalVotes = Object.values(pollData.votes).reduce((a,b)=>a+b,0);
const hasVoted = !!pollData.myVote;
const timeLeft = POLL_STORE.getTimeLeft();
// Find winner
const maxVotes = Math.max(...Object.values(pollData.votes));
const optionsHTML = poll.options.map(opt => {
const votes = pollData.votes[opt.id] || 0;
const pct = totalVotes > 0 ? Math.round((votes/totalVotes)*100) : 0;
const isMyVote = pollData.myVote === opt.id;
const isWinner = votes === maxVotes && hasVoted;
const cls = isWinner ? 'winner' : (isMyVote ? 'voted' : '');
return `
`;
const el = document.getElementById(containerId);
if (el) el.innerHTML = html;
}
function castPollVote(pollId, optionId) {
const result = POLL_STORE.vote(pollId, optionId);
if (!result) return;
renderWeeklyPoll('poll-container');
// Also update home screen poll if visible
renderWeeklyPoll('home-poll-slot');
showToast('🗳 Vote recorded! +5 Pioneer points');
track('poll_voted', { poll_id: pollId, option: optionId });
}
// ── COMMUNITY FEED ──
const FEED_STORE = {
_key: 'np_feed_v1',
load() { try { return JSON.parse(localStorage.getItem(this._key)||'[]'); } catch(e) { return []; } },
save(d) { try { localStorage.setItem(this._key, JSON.stringify(d.slice(0,100))); } catch(e) {} },
add(post) {
const feed = this.load();
feed.unshift({ ...post, id: Date.now(), ts: Date.now(), reactions: { fire:0, check:0, laugh:0 }, myReaction: null });
this.save(feed);
return feed[0];
},
react(postId, emoji) {
const feed = this.load();
const post = feed.find(p => p.id === postId);
if (!post) return;
if (post.myReaction === emoji) {
post.reactions[emoji]--;
post.myReaction = null;
} else {
if (post.myReaction) post.reactions[post.myReaction]--;
post.reactions[emoji] = (post.reactions[emoji]||0) + 1;
post.myReaction = emoji;
}
this.save(feed);
return post;
},
getSeeded() {
// All possible community posts — filtered by whether venue is actually open RIGHT NOW
const ALL_POSTS = [
// Late night / weekend posts (venues open after 9pm)
{ id:1001, venueId:6, venueName:'La Descarga', minHour:21, maxHour:27, username:'Mia K.', initials:'MK', color:'#FF8C42', text:"La Descarga is absolutely packed right now, DJ just started, energy is insane 🔥", reactions:{fire:14,check:3,laugh:1}, tier:'Explorer' },
{ id:1004, venueId:5, venueName:'Academy LA', minHour:22, maxHour:28, username:'Dev P.', initials:'DP', color:'#B06EFF', text:"Avoid Academy right now, line is 45min and the bouncer is being difficult. Try Warwick instead, walked right in.", reactions:{fire:3,check:19,laugh:2}, tier:'Pioneer' },
{ id:1007, venueId:2, venueName:'Zouk LA', minHour:22, maxHour:28, username:'Aisha R.', initials:'AR', color:'#FF3C5F', text:"Zouk is going OFF tonight, world class DJ set, this is what LA nightlife is about 🎛️", reactions:{fire:28,check:5,laugh:0}, tier:'Regular' },
{ id:1008, venueId:9, venueName:'Warwick Hollywood', minHour:21, maxHour:27, username:'Tyler B.', initials:'TB', color:'#00D68F', text:"Warwick is at like 80% right now, great energy without being too crazy. Bouncer was chill.", reactions:{fire:9,check:11,laugh:0}, tier:'Pioneer' },
{ id:1009, venueId:42, venueName:'Stage 21', minHour:22, maxHour:28, username:'Jordan K.', initials:'JK', color:'#FFB830', text:"Stage 21 is going hard tonight, dance floor is packed and the bottles are flowing 🥂", reactions:{fire:17,check:4,laugh:1}, tier:'Explorer' },
// Evening posts (venues open 5pm-midnight)
{ id:1002, venueId:44, venueName:'The Wellesbourne', minHour:18, maxHour:26, username:'Jake R.', initials:'JR', color:'#4D9FFF', text:"Wellesbourne on a weeknight? Surprisingly good crowd, grabbed a bar seat easy. Recommend the Singapore Sling.", reactions:{fire:5,check:8,laugh:0}, tier:'Pioneer' },
{ id:1005, venueId:48, venueName:'The Hideaway', minHour:17, maxHour:25, username:'Chloe M.', initials:'CM', color:'#FFB830', text:"The Hideaway is the perfect date spot, hidden garden patio, cocktails are on point and still getting good seats 💑", reactions:{fire:16,check:7,laugh:0}, tier:'Explorer' },
{ id:1010, venueId:46, venueName:'Dante Beverly Hills', minHour:17, maxHour:25, username:'Nina C.', initials:'NC', color:'#B06EFF', text:"The Garibaldi at Dante BH might be the best cocktail in LA. Place is maybe 60% full, perfect vibe right now.", reactions:{fire:22,check:8,laugh:0}, tier:'Regular' },
{ id:1011, venueId:50, venueName:'Roger Room', minHour:19, maxHour:26, username:'Yuki S.', initials:'YS', color:'#4D9FFF', text:"Roger Room has no sign on the door — look for the unmarked entrance. Seasonal menu is incredible tonight.", reactions:{fire:11,check:6,laugh:0}, tier:'Explorer' },
{ id:1012, venueId:17, venueName:'Employees Only', minHour:18, maxHour:26, username:'Marco R.', initials:'MR', color:'#00D68F', text:"Employees Only is exactly as good as people say. Bartenders are wizards. About 70% full right now.", reactions:{fire:19,check:9,laugh:0}, tier:'Regular' },
// Sunset/happy hour posts (4pm-9pm)
{ id:1003, venueId:45, venueName:'Nava Rooftop', minHour:16, maxHour:22, username:'Sofia A.', initials:'SA', color:'#00D68F', text:"Nava Rooftop at golden hour was worth every penny, views of the hills are incredible 😮💨 About 60% full still.", reactions:{fire:21,check:6,laugh:0}, tier:'Regular' },
{ id:1013, venueId:19, venueName:"Harriet's Rooftop", minHour:16, maxHour:23, username:'Bianca L.', initials:'BL', color:'#FF8C42', text:"Harriet's rooftop at sunset is something else. Get here by 7 if you want a spot — fills up fast after 8.", reactions:{fire:31,check:12,laugh:0}, tier:'Regular' },
{ id:1014, venueId:52, venueName:'Poza Rooftop BH', minHour:17, maxHour:23, username:'Leo B.', initials:'LB', color:'#FFB830', text:"Pool at Poza BH is open, music is chill, 65% full. This is the move for early evening in Beverly Hills.", reactions:{fire:14,check:8,laugh:0}, tier:'Pioneer' },
// All-day / general posts (no time restriction)
{ id:1006, venueId:6, venueName:'La Descarga', minHour:0, maxHour:24, username:'Marcus T.', initials:'MT', color:'#FF3C5F', text:"Does anyone know if La Descarga does walk-ins on Fridays or is it reservations only now?", reactions:{fire:1,check:4,laugh:0}, tier:'Newcomer' },
{ id:1015, venueId:null, venueName:null, minHour:0, maxHour:24, username:'Sam T.', initials:'ST', color:'#4D9FFF', text:"NitePulse has been saving me every weekend. Checked in at 3 places last Friday using the crowd %. Zero wait times 🙌", reactions:{fire:24,check:18,laugh:2}, tier:'Explorer' },
{ id:1016, venueId:35, venueName:"Father's Office", minHour:12, maxHour:24, username:'Leo B.', initials:'LB', color:'#FFB830', text:"Father's Office burger is criminally underrated as a pre-bar meal. Always worth the wait.", reactions:{fire:18,check:7,laugh:3}, tier:'Pioneer' },
];
const now = new Date();
const currentHour = now.getHours(); // 0-23
// Filter posts to only show ones appropriate for current time
// AND where the venue is actually open (or post is venue-independent)
const validPosts = ALL_POSTS.filter(post => {
// Time window check (maxHour can exceed 24 for past-midnight venues)
const hourOk = currentHour >= post.minHour ||
(post.maxHour > 24 && currentHour < (post.maxHour - 24));
if (!hourOk) return false;
// Venue open check
if (post.venueId && !isVenueOpen(post.venueId)) return false;
return true;
});
// If nothing valid for this time of day, show general posts
const posts = validPosts.length >= 3 ? validPosts : ALL_POSTS.filter(p => p.minHour === 0);
// Add realistic timestamps — spread posts over last few hours
return posts.slice(0, 8).map((post, i) => ({
...post,
ts: Date.now() - (i * 18 + Math.floor(Math.random()*15)) * 60000,
myReaction: null,
}));
}
};
function timeAgo(ts) {
const mins = Math.round((Date.now()-ts)/60000);
if (mins < 1) return 'just now';
if (mins < 60) return mins + 'm ago';
const hrs = Math.round(mins/60);
if (hrs < 24) return hrs + 'h ago';
return Math.round(hrs/24) + 'd ago';
}
function renderFeed() {
const container = document.getElementById('feed-list');
if (!container) return;
let posts = FEED_STORE.load();
// Always use seeded posts if no real posts yet
if (posts.length === 0) posts = FEED_STORE.getSeeded();
// Refresh seeded data — replace with time-appropriate seed if all posts are old seed
const allSeeded = posts.every(p => p.id <= 1999 && (Date.now() - p.ts) > 12*3600000);
if (allSeeded) posts = FEED_STORE.getSeeded();
// Update composer avatar
const u = { name: 'You', email: '' };
const compAv = document.getElementById('composer-avatar');
if (compAv && u) {
const parts = (u.name||'?').trim().split(' ');
compAv.textContent = parts.length >= 2
? (parts[0][0]+parts[parts.length-1][0]).toUpperCase()
: (u.name||'?').slice(0,2).toUpperCase();
}
container.innerHTML = posts.map(post => {
const tierColors = { Pioneer:'#FF8C42', Explorer:'#4D9FFF', Regular:'#B06EFF', Legend:'#FFB830', Newcomer:'var(--text3)' };
const color = post.color || '#FF8C42';
const isFollowing = SOCIAL.isFollowing(post.username);
return `
`;
}).join('');
}
// ── COMPOSER — post a report ──
let composerVenueId = null;
let composerVenueName = null;
function openComposerVenuePicker() {
// Show a quick venue picker sheet
const names = DB.venues.filter(v=>isVenueOpen(v.id)).slice(0,12).map(v=>
`
${v.emoji}${v.name}
`
).join('');
showToast('Tap a venue below to tag it');
const sheet = document.createElement('div');
sheet.id = 'venue-picker-sheet';
sheet.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:300;background:var(--card3);border-radius:20px 20px 0 0;max-height:60vh;overflow-y:auto;padding-bottom:30px;';
sheet.innerHTML = names;
var hdr = document.createElement('div');
hdr.style.cssText = 'padding:14px 16px 8px;font-weight:700;font-size:16px;display:flex;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,0.07)';
hdr.textContent = 'Tag a venue';
sheet.insertBefore(hdr, sheet.firstChild);
document.body.appendChild(sheet);
}
function setComposerVenue(id, name, el) {
composerVenueId = id;
composerVenueName = name;
const lbl = document.getElementById('composer-venue-label');
if (lbl) lbl.innerHTML = '📍 ' + name + '✕';
const picker = document.getElementById('venue-picker-sheet');
if (picker) picker.remove();
}
function postCommunityReport() {
const input = document.getElementById('composer-text');
const text = (input && input.value || '').trim();
if (text.length < 5) { showToast('Write at least a few words!'); return; }
const u = { name: 'You', email: '' };
const parts = (u.name||'User').trim().split(' ');
const initials = parts.length >= 2
? (parts[0][0]+parts[parts.length-1][0]).toUpperCase()
: (u.name||'U').slice(0,2).toUpperCase();
const tier = PIONEER.tier();
FEED_STORE.add({
username: 'Anonymous',
initials,
color: tier.color,
text,
venueId: composerVenueId,
venueName: composerVenueName,
tier: tier.name,
});
if (input) input.value = '';
composerVenueId = null;
composerVenueName = null;
const lbl = document.getElementById('composer-venue-label');
if (lbl) lbl.innerHTML = '📍 Tag a venue';
PIONEER.addPoints(8, 'Community post');
renderFeed();
showToast('✅ Posted! +8 Pioneer points');
track('community_post', { has_venue: !!composerVenueId, text_length: text.length });
}
function reactToPost(postId, emoji, btn) {
FEED_STORE.react(postId, emoji);
renderFeed();
}
// ── SOCIAL — follow system ──
const SOCIAL = {
_key: 'np_social_v1',
load() { try { return JSON.parse(localStorage.getItem(this._key)||'{"following":[]}'); } catch(e) { return {following:[]}; } },
save(d) { try { localStorage.setItem(this._key, JSON.stringify(d)); } catch(e) {} },
isFollowing(username) { return this.load().following.includes(username); },
toggle(username) {
const d = this.load();
const idx = d.following.indexOf(username);
if (idx >= 0) d.following.splice(idx,1);
else d.following.push(username);
this.save(d);
return idx < 0; // returns true if now following
}
};
function toggleFollow(username, btn) {
const nowFollowing = SOCIAL.toggle(username);
if (btn) {
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
}
showToast(nowFollowing ? '✅ Following ' + username : 'Unfollowed ' + username);
track('follow_user', { username, action: nowFollowing ? 'follow' : 'unfollow' });
}
// ── LEADERBOARD ──
function renderLeaderboard() {
const container = document.getElementById('leaderboard-list');
if (!container) return;
// Seeded leaderboard + real user if logged in
const leaders = [
{ name:'Sofia A.', initials:'SA', color:'#00D68F', pts:847, checkins:42, tier:'Legend', rank:1 },
{ name:'Jake R.', initials:'JR', color:'#4D9FFF', pts:634, checkins:31, tier:'Regular', rank:2 },
{ name:'Mia K.', initials:'MK', color:'#FF8C42', pts:521, checkins:24, tier:'Regular', rank:3 },
{ name:'Dev P.', initials:'DP', color:'#B06EFF', pts:389, checkins:18, tier:'Explorer', rank:4 },
{ name:'Chloe M.', initials:'CM', color:'#FFB830', pts:312, checkins:14, tier:'Explorer', rank:5 },
];
// Insert real user if logged in and has points
const u = { name: 'You', email: '' };
if (u && PIONEER.points > 0) {
const parts = (u.name||'You').trim().split(' ');
const initials = parts.length >= 2 ? (parts[0][0]+parts[parts.length-1][0]).toUpperCase() : (u.name||'Y').slice(0,2).toUpperCase();
leaders.push({ name: u.name||'You', initials, color: PIONEER.tier().color, pts: PIONEER.points, checkins: PIONEER.checkins, tier: PIONEER.tier().name, rank: 6, isMe: true });
leaders.sort((a,b)=>b.pts-a.pts);
leaders.forEach((l,i)=>l.rank=i+1);
}
const rankClass = r => r===1?'gold':r===2?'silver':r===3?'bronze':'';
const rankEmoji = r => r===1?'🥇':r===2?'🥈':r===3?'🥉':'';
container.innerHTML = leaders.slice(0,8).map(l => `
${l.rank <= 3 ? rankEmoji(l.rank) : l.rank}
${l.initials}
${l.name}${l.isMe?' (you)':''}
${l.tier} · ${l.checkins} check-ins
${l.pts}
`).join('');
}
function renderFollowing() {
const container = document.getElementById('following-list');
if (!container) return;
const following = SOCIAL.load().following;
if (!following.length) {
container.innerHTML = '
`;
}).join('');
}
// ── Community tab switcher ──
let currentCommunityTab = 'feed';
function switchCommunityTab(tab, btn) {
currentCommunityTab = tab;
['feed','poll','people'].forEach(t => {
const el = document.getElementById('community-'+t+'-tab');
if (el) el.style.display = t===tab ? 'block' : 'none';
const b = document.getElementById('ctab-'+t);
if (b) b.classList.toggle('active', t===tab);
});
if (tab==='poll') { renderWeeklyPoll('poll-container'); }
if (tab==='people') { renderLeaderboard(); renderFollowing(); }
if (tab==='feed') { renderFeed(); }
}
// ── Add poll to home screen ──
function renderHomePoll() {
const slot = document.getElementById('home-poll-slot');
if (!slot) return;
const poll = POLL_STORE.getCurrentPoll();
const pollData = POLL_STORE.getVotes(poll.id);
const total = Object.values(pollData.votes).reduce((a,b)=>a+b,0);
const hasVoted = !!pollData.myVote;
// Show compact version on home screen
const topOption = poll.options.reduce((best, opt) => {
return (pollData.votes[opt.id]||0) > (pollData.votes[best.id]||0) ? opt : best;
}, poll.options[0]);
slot.innerHTML = `
🗳 WEEKLY POLL · ${total} votes
${POLL_STORE.getTimeLeft()}
${poll.question}
${hasVoted ? '✓ You voted · ' + topOption.venue + ' leading' : 'Tap to vote · +5 pts'}
See all →
`;
}
// ══════════════════════════════════════════════════════════
// INTERNATIONAL VENUES DATABASE
// Istanbul · Frankfurt · Paris
// Real venues, real GPS, real hours (local time handled via timezone offset)
// ══════════════════════════════════════════════════════════
const INTL_VENUES = [
// ─── ISTANBUL ─────────────────────────────────────────
// Timezone: UTC+3. Hours stored in local time.
{id:101,city:'istanbul',name:'Sortie',type:'club',emoji:'🎶',color:'#FF3C5F',occ:68,cap:800,wait:10,rating:4.3,dist:null,addr:'Muallim Naci Cd. 54, Kuruçeşme, Istanbul',tags:['Bosphorus View','Open Air','VIP'],saved:false,desc:"Legendary open-air club on the Bosphorus strait — multi-level terraces, world-class DJs and a crowd that defines Istanbul nightlife from May to September.",gpsLat:41.0553,gpsLng:29.0362,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:102,city:'istanbul',name:'Reina',type:'club',emoji:'👑',color:'#FFB830',occ:75,cap:1200,wait:20,rating:4.2,dist:null,addr:'Muallim Naci Cd. 44, Ortaköy, Istanbul',tags:['Bosphorus','Superclub','Celebrity'],saved:false,desc:"Istanbul's most iconic superclub — sprawling waterfront complex with multiple dance floors, restaurants and bars, all overlooking the Bosphorus bridge.",gpsLat:41.0478,gpsLng:29.0282,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:103,city:'istanbul',name:'Babylon',type:'live',emoji:'🎸',color:'#4D9FFF',occ:55,cap:400,wait:5,rating:4.6,dist:null,addr:'Şehbender Sk. 3, Asmalımescit, Istanbul',tags:['Live Music','Indie','Jazz'],saved:false,desc:"Istanbul's premier independent music venue — intimate space in Beyoğlu hosting the best local and international acts across jazz, indie, electronic and world music.",gpsLat:41.0322,gpsLng:28.9774,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:104,city:'istanbul',name:'360 Istanbul',type:'rooftop',emoji:'🌃',color:'#B06EFF',occ:62,cap:300,wait:8,rating:4.5,dist:null,addr:'İstiklal Cd. 163, Beyoğlu, Istanbul',tags:['Rooftop','360 Views','Cocktails'],saved:false,desc:"Panoramic rooftop bar with 360-degree views of Istanbul — the old city, the Bosphorus, two continents at once. One of the world's great bar locations.",gpsLat:41.0330,gpsLng:28.9771,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:105,city:'istanbul',name:'Nardis Jazz Club',type:'jazz',emoji:'🎷',color:'#00D68F',occ:45,cap:120,wait:0,rating:4.7,dist:null,addr:'Kuledibi Sk. 14, Galata, Istanbul',tags:['Jazz','Live Music','Intimate'],saved:false,desc:"The best jazz club in Istanbul — a cozy basement venue in Galata with nightly live performances from Turkey's finest jazz musicians.",gpsLat:41.0266,gpsLng:28.9742,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:106,city:'istanbul',name:'Anjelique',type:'lounge',emoji:'🌊',color:'#FF8C42',occ:58,cap:350,wait:5,rating:4.4,dist:null,addr:'Muallim Naci Cd. 35, Ortaköy, Istanbul',tags:['Waterfront','Lounge','DJ'],saved:false,desc:"Stylish waterfront lounge in Ortaköy — terraced seating over the Bosphorus, resident DJs spinning house music and an excellent seafood menu.",gpsLat:41.0473,gpsLng:29.0268,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:107,city:'istanbul',name:'Indigo',type:'club',emoji:'🔵',color:'#4D9FFF',occ:71,cap:500,wait:10,rating:4.3,dist:null,addr:'Akarsu Yk. Cd. 1, Cihangir, Istanbul',tags:['Electronic','Underground','Techno'],saved:false,desc:"Istanbul's underground electronic music institution — dark, loud and serious about music. The place the city's DJ community calls home.",gpsLat:41.0335,gpsLng:28.9817,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:108,city:'istanbul',name:'Minimüzikhol',type:'club',emoji:'🎵',color:'#FF3C5F',occ:63,cap:250,wait:5,rating:4.5,dist:null,addr:'Soğancı Sk. 7, Cihangir, Istanbul',tags:['Alternative','Indie','Local Scene'],saved:false,desc:"Beloved alternative venue in Cihangir — unpretentious, creative crowd, consistently great bookings across indie, post-punk and electronic genres.",gpsLat:41.0340,gpsLng:28.9820,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
// ─── FRANKFURT ────────────────────────────────────────
// Timezone: UTC+1 (UTC+2 summer). Hours stored in local time.
{id:201,city:'frankfurt',name:'Robert Johnson',type:'club',emoji:'🖤',color:'#B06EFF',occ:72,cap:300,wait:10,rating:4.8,dist:null,addr:'Nordring 131, Offenbach am Main (near Frankfurt)',tags:['Techno','Legendary','Underground'],saved:false,desc:"One of Europe's most respected techno clubs — Robert Johnson in Offenbach is a pilgrimage site for serious electronic music fans. Intimate, dark, uncompromising.",gpsLat:50.1008,gpsLng:8.7761,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:202,city:'frankfurt',name:'Cocoon Club',type:'club',emoji:'🥚',color:'#FF3C5F',occ:65,cap:1200,wait:15,rating:4.4,dist:null,addr:'Carl-Benz-Str. 21, Frankfurt',tags:['Techno','Large Venue','Sven Väth'],saved:false,desc:"Frankfurt's most famous club — the biomorphic architecture alone is worth the visit. Home to Cocoon parties by Sven Väth and the heart of Germany's techno scene.",gpsLat:50.1074,gpsLng:8.6987,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:203,city:'frankfurt',name:'Gibson Club',type:'club',emoji:'🎸',color:'#FFB830',occ:58,cap:800,wait:8,rating:4.2,dist:null,addr:'Zeil 85, Frankfurt',tags:['Mainstream','Central Location','Mixed Music'],saved:false,desc:"Multi-floor club in the heart of Frankfurt's Zeil shopping district — diverse music programming from hip-hop to house, accessible and always packed on weekends.",gpsLat:50.1135,gpsLng:8.6857,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:204,city:'frankfurt',name:'Velvet Club',type:'lounge',emoji:'🍷',color:'#FF8C42',occ:48,cap:200,wait:0,rating:4.3,dist:null,addr:'Weißfrauenstr. 12, Frankfurt',tags:['Lounge','Cocktails','After Work'],saved:false,desc:"Sophisticated lounge bar in the banking district — floor-to-ceiling velvet curtains, expert mixologists and a crowd of Frankfurt's financial elite unwinding after work.",gpsLat:50.1065,gpsLng:8.6675,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:205,city:'frankfurt',name:'King Kamehameha Club',type:'club',emoji:'🌺',color:'#00D68F',occ:61,cap:600,wait:8,rating:4.2,dist:null,addr:'Hanauer Landstr. 192, Frankfurt',tags:['Hip-Hop','R&B','Sports Crowd'],saved:false,desc:"Frankfurt's go-to spot for hip-hop and R&B — large dancefloor, regular celebrity appearances and the unofficial club of Eintracht Frankfurt players.",gpsLat:50.1132,gpsLng:8.7223,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:206,city:'frankfurt',name:'Bahnhofsviertel Bar',type:'bar',emoji:'🍺',color:'#4D9FFF',occ:44,cap:80,wait:0,rating:4.4,dist:null,addr:'Taunusstr. 34, Frankfurt Bahnhofsviertel',tags:['Bar District','Craft Beer','Neighbourhood'],saved:false,desc:"The Bahnhofsviertel is Frankfurt's most eclectic neighbourhood — this bar sits at its heart, serving excellent craft beer to a creative mixed crowd that defines modern Frankfurt.",gpsLat:50.1072,gpsLng:8.6625,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:207,city:'frankfurt',name:'Strandperle',type:'bar',emoji:'🏖️',color:'#FFB830',occ:39,cap:150,wait:0,rating:4.3,dist:null,addr:'Hanauer Landstr. 154, Frankfurt',tags:['Beach Bar','Outdoor','Summer'],saved:false,desc:"Frankfurt's beloved urban beach bar on the Main riverbank — sand underfoot, cold Aperol Spritz in hand, skyline views and the most relaxed crowd in the city.",gpsLat:50.1108,gpsLng:8.7182,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:208,city:'frankfurt',name:"Jimmy's Bar",type:'lounge',emoji:'🎩',color:'#B06EFF',occ:53,cap:100,wait:0,rating:4.5,dist:null,addr:'Friedrich-Ebert-Anlage 40, Frankfurt (Hessischer Hof)',tags:['Classic Bar','Whisky','Hotel Bar'],saved:false,desc:"One of Germany's finest hotel bars in the legendary Hessischer Hof — dark wood panelling, a whisky list that runs to hundreds of expressions and timeless elegance.",gpsLat:50.1128,gpsLng:8.6613,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
// ─── PARIS ────────────────────────────────────────────
// Timezone: UTC+1 (UTC+2 summer). Hours stored in local time.
{id:301,city:'paris',name:'Concrete',type:'club',emoji:'🏗️',color:'#FF3C5F',occ:78,cap:800,wait:20,rating:4.7,dist:null,addr:'Port de la Râpée, 75012 Paris',tags:['Techno','Seine','Legendary'],saved:false,desc:"Paris's most respected techno club — a barge on the Seine that runs from Saturday night through Monday morning. The after-hours institution of an entire generation.",gpsLat:48.8409,gpsLng:2.3687,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:302,city:'paris',name:'Rex Club',type:'club',emoji:'👾',color:'#B06EFF',occ:66,cap:400,wait:12,rating:4.6,dist:null,addr:'5 Boulevard Poissonnière, 75002 Paris',tags:['Electronic','Institution','Laurent Garnier'],saved:false,desc:"The cathedral of Parisian electronic music — Rex Club has been running since 1990, hosting Laurent Garnier's legendary nights and remaining the city's most credible club.",gpsLat:48.8712,gpsLng:2.3481,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:303,city:'paris',name:'Silencio',type:'lounge',emoji:'🎬',color:'#FFB830',occ:55,cap:200,wait:5,rating:4.5,dist:null,addr:'142 Rue Montmartre, 75002 Paris',tags:['Members Club','David Lynch','Creative'],saved:false,desc:"David Lynch's private members club in Paris — open to the public after midnight. Art deco interior designed by Lynch himself, creative industry crowd, no photos allowed.",gpsLat:48.8681,gpsLng:2.3448,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:304,city:'paris',name:'Le Baron',type:'lounge',emoji:'🌹',color:'#FF8C42',occ:61,cap:150,wait:8,rating:4.4,dist:null,addr:'6 Avenue de New York, 75016 Paris',tags:['Fashion','Selective Door','House Music'],saved:false,desc:"Paris's most fashionable small club — Le Baron is where the fashion world goes after dinner. Selective door, intimate space, consistently excellent house music.",gpsLat:48.8637,gpsLng:2.2972,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:305,city:'paris',name:'Nuba',type:'rooftop',emoji:'🗼',color:'#00D68F',occ:59,cap:400,wait:8,rating:4.4,dist:null,addr:'Parc de Bercy, 75012 Paris',tags:['Rooftop','Eiffel Views','Summer'],saved:false,desc:"Rooftop club in Parc de Bercy with views toward the Eiffel Tower — open-air dancefloor, tropical cocktails and the most photogenic nightlife setting in Paris.",gpsLat:48.8371,gpsLng:2.3789,mapX:0.5,mapY:0.5,trend:'up',reviews:[]},
{id:306,city:'paris',name:'Le Pompon',type:'bar',emoji:'🍸',color:'#4D9FFF',occ:47,cap:100,wait:0,rating:4.5,dist:null,addr:'39 Rue des Petites Écuries, 75010 Paris',tags:['Cocktail Bar','Canal Saint-Martin','Hip'],saved:false,desc:"One of Paris's finest cocktail bars in the buzzing Canal Saint-Martin neighbourhood — innovative seasonal menus, no-reservation policy and a genuinely cool crowd.",gpsLat:48.8741,gpsLng:2.3531,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:307,city:'paris',name:'Wanderlust',type:'club',emoji:'🌍',color:'#FF3C5F',occ:64,cap:1000,wait:10,rating:4.3,dist:null,addr:"32 Quai d'Austerlitz, 75013 Paris",tags:['Seine','Open Air','Mixed Music'],saved:false,desc:"Multi-space venue on the Seine with an open-air rooftop, indoor club and riverside terrace — the most social and accessible of Paris's serious nightlife venues.",gpsLat:48.8397,gpsLng:2.3651,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
{id:308,city:'paris',name:'Café de la Danse',type:'live',emoji:'💃',color:'#B06EFF',occ:52,cap:500,wait:0,rating:4.5,dist:null,addr:'5 Passage Louis-Philippe, 75011 Paris',tags:['Live Music','Bastille','Concerts'],saved:false,desc:"A beloved mid-size concert venue in the Bastille neighbourhood — beautiful acoustics, diverse programming and the kind of intimate atmosphere that large venues can never replicate.",gpsLat:48.8527,gpsLng:2.3719,mapX:0.5,mapY:0.5,trend:'stable',reviews:[]},
];
// ── City definitions ──
const CITIES = {
losangeles: { label:'Los Angeles', flag:'🇺🇸', lat:34.052, lng:-118.243, tzOffset:-8, currency:'$', venuePrefix:'la' },
istanbul: { label:'Istanbul', flag:'🇹🇷', lat:41.015, lng:28.980, tzOffset:3, currency:'₺', venuePrefix:'ist' },
frankfurt: { label:'Frankfurt', flag:'🇩🇪', lat:50.110, lng:8.682, tzOffset:1, currency:'€', venuePrefix:'fra' },
paris: { label:'Paris', flag:'🇫🇷', lat:48.856, lng:2.352, tzOffset:1, currency:'€', venuePrefix:'par' },
};
// ── Detect user city from GPS ──
function detectCity(lat, lng) {
if (!lat || !lng) return 'losangeles';
// Simple bounding box detection
if (lat > 40.0 && lat < 42.5 && lng > 27.0 && lng < 30.5) return 'istanbul';
if (lat > 49.5 && lat < 50.5 && lng > 8.0 && lng < 9.5) return 'frankfurt';
if (lat > 48.5 && lat < 49.1 && lng > 2.0 && lng < 2.7) return 'paris';
if (lat > 33.0 && lat < 35.0 && lng > -119.0 && lng < -116.5) return 'losangeles';
// Default — find nearest city
let nearest = 'losangeles';
let minDist = Infinity;
for (const [key, city] of Object.entries(CITIES)) {
const d = Math.sqrt(Math.pow(lat - city.lat, 2) + Math.pow(lng - city.lng, 2));
if (d < minDist) { minDist = d; nearest = key; }
}
return nearest;
}
// ── Current city state ──
let CURRENT_CITY = localStorage.getItem('np_city') || 'losangeles';
function setCity(cityKey) {
CURRENT_CITY = cityKey;
localStorage.setItem('np_city', cityKey);
// Merge international venues into DB for this city
mergeIntlVenues();
renderAll();
renderHomePoll();
updateCityDisplay();
}
function mergeIntlVenues() {
// Remove any previously added intl venues
DB.venues = DB.venues.filter(v => v.id < 100);
// Add venues for current city
if (CURRENT_CITY !== 'losangeles') {
const cityVenues = INTL_VENUES.filter(v => v.city === CURRENT_CITY);
DB.venues = [...DB.venues, ...cityVenues];
} else {
// LA — restore all local/extra venues
DB.venues = [...DB.venues, ...DB.extraVenues, ...DB.localVenues].filter((v,i,a) => a.findIndex(x=>x.id===v.id)===i);
}
}
function updateCityDisplay() {
const city = CITIES[CURRENT_CITY];
if (!city) return;
// Update greeting area with city indicator
const cityEl = document.getElementById('city-indicator');
if (cityEl) {
cityEl.innerHTML = `${city.flag}${city.label}`;
}
}
// ── City switcher UI ──
function openCitySwitcher() {
const existing = document.getElementById('city-switcher-sheet');
if (existing) { existing.remove(); return; }
const options = Object.entries(CITIES).map(([key, city]) => `