/* WOAH! NIGHT CLUB — Main App */
const { useState, useEffect, useRef, useCallback } = React;
/* Cache busting — uses window.v from extras.jsx (loaded first).
To force reload of all assets after upload, bump window.ASSET_VERSION
at the top of extras.jsx */
const v = window.v || ((path) => path);
const NAV_LINKS = [
['Staff','staff'],['Residents','djs'],['Events','events'],['Menu','cocktails'],
['VIP','vip'],['Merch','merch']
];
/* Bingo is rendered as the 7th nav slot — opens external in a new tab */
const NAV_BINGO = { label:'Bingo', url:'https://djgaiaworld.com/bingo/' };
/* Staff socials — Gaia fills these in; null = render gray placeholder */
const STAFF_SOCIALS = {
'GAIA': { instagram:'https://www.instagram.com/djgaia.ffxiv', twitter:null },
'KAIEN': { instagram:'https://www.instagram.com/kaiennanami_ffxiv/', twitter:null },
'MYOUKI': { instagram:null, twitter:null },
'AEDRIC': { instagram:'https://www.instagram.com/aedric.xiv/', twitter:null },
'AXEL': { instagram:'https://www.instagram.com/axelvesta_ffxiv/', twitter:null },
'FELINA': { instagram:'https://www.instagram.com/felina_fleur/', twitter:null },
'FOX': { instagram:'https://www.instagram.com/foxk.ffxiv/', twitter:null },
'HYTSUKY': { instagram:'https://www.instagram.com/hytsuky_xiv/', twitter:null },
'LIVELIA': { instagram:'https://www.instagram.com/livelia_vandern/', twitter:null },
'MAYLIN': { instagram:'https://www.instagram.com/maymay.ff14/', twitter:null },
'SUGAR': { instagram:'https://www.instagram.com/sugar.ffxiv/', twitter:null },
'NOELLE': { instagram:null, twitter:null },
'MAY': { instagram:null, twitter:null },
'ZANE': { instagram:null, twitter:null },
'HEIZU': { instagram:null, twitter:null },
'KURO': { instagram:null, twitter:null },
'MIZUKI': { instagram:null, twitter:null },
'NOIR': { instagram:null, twitter:null },
'ELLANORE': { instagram:null, twitter:null },
'AIKA': { instagram:null, twitter:null },
'KIM': { instagram:null, twitter:null },
'PEANUT': { instagram:null, twitter:null },
'RAVEN': { instagram:null, twitter:null },
'NERU': { instagram:null, twitter:null },
'ANGIE': { instagram:'https://www.instagram.com/angie_ffxiv/', twitter:null },
'MANA': { instagram:'https://www.instagram.com/mana.heatherwind.ffxiv/', twitter:null },
};
const DJS = [
{ id:'gaia', name:'Gaia', tag:'The Anime Made DJ', jp:'ガイア',
pills:['TECHNO','ROCK & METAL','ANIME'], status:'● RESIDENT DJ',
bio:'50% Romanian, 50% Japanese DJ and music producer ruling the dance floors of Eorzea. Resident DJ at multiple top-tier FFXIV venues. Specializing in techno, EDM, and rock/metal — every set is a journey from melodic highs to chaotic drops. Follow on Discord, Spotify, and let\u2019s keep the party going. Woah!',
twitch:'https://www.twitch.tv/dj_gaia', handle:'@dj_gaia', g:'#FF2E97' },
{ id:'aemilia', name:'Aemilia', tag:'The Bunterfly ✦ Full-Time Streamer', jp:'エミリア',
pills:['HYPE STUFFS','EDM','DNB'], status:'● RESIDENT DJ',
bio:'The Bunterfly. Full-time streamer, content creator, and one of the warmest presences in the WOAH! collective. Wherever Aemilia goes, the vibe follows — a true metamorphosis of energy, color, and heart.',
twitch:'https://www.twitch.tv/aemilia', handle:'@aemilia', g:'#5DD3FF' },
{ id:'reyalex', name:'Rey Alex', tag:'Owner of Urban Night Club · Manager of DJ Gaia', jp:'レイ・アレックス',
pills:['TRANCE','SYNTHWAVE','LO-FI'], status:'● RESIDENT DJ',
badge:'👑 WOAH! MANAGEMENT', note:'Bookings via Discord: @reyalexffxiv',
bio:'The mastermind behind the curtain. Owner of Urban Night Club and the manager who keeps DJ Gaia\u2019s career — and the entire collective — running smoothly. \u201CI do things and stuff happens.\u201D',
twitch:'https://www.twitch.tv/reyalexxx', handle:'@reyalexxx', g:'#FF6BCB' },
{ id:'khangomon', name:'Khangomon XIV', tag:'Viet-Asia Mix · VTuber · Owner of Project XIV', jp:'カンゴモン',
pills:['HYPE STUFFS','EDM','DNB'], status:'● RESIDENT DJ',
bio:'Viet-Asia Mix Streamer, VTuber, occasional TCG enjoyer, and proud member of the FFXIV community. Owner of Project XIV. Every stream is a great time for everyone — Hype or Vibe, DUH!',
twitch:'https://www.twitch.tv/khangomon', handle:'@khangomon', g:'#FF3B3B' },
{ id:'rekse', name:'Rekse Nintly', tag:'Hype Party Kitty · Owner of The Cherry Blossom', jp:'レクセ・ニントリー',
pills:['TECHNO','EDM','TRANCE'], status:'● RESIDENT DJ',
bio:'Hype Party Kitty. Resident DJ at The Cherry Blossom, Black Sapphire, WOAH!, End of the Line, and Sinners. Owner of The Cherry Blossom night club. Find her on Endeavor Promotions.',
twitch:'https://www.twitch.tv/reksenintly', handle:'@reksenintly', g:'#FFB347' },
{ id:'alec', name:'Alec Marston', tag:'Dark, heavy, high-energy. Loud and made to move you.', jp:'アレック・マーストン',
pills:['ROCK & METAL','TECHNO','EDM'], status:'● RESIDENT DJ',
bio:'Rocking the DJ decks like he rocks his coffee — dark and questionable. Mixing dark, heavy, high-energy sounds from techno to metal. If it hits hard, it\u2019s in. Live sets full of intensity, chaos, and raw emotion.',
twitch:'https://www.twitch.tv/alecmarston', handle:'@alecmarston', g:'#9D2EFF' },
{ id:'rin', name:'Rin Tsukii', tag:'Owner of Ignite Club · Rising in the FFXIV scene', jp:'リン・ツキ',
pills:['HYPE STUFFS','EDM','VINTAGE'], status:'● RESIDENT DJ',
bio:'Venturing into DJ\u2019ing in the FFXIV scene — still learning, still growing. Owner of Ignite Club. Thank you for your patience and for being part of the journey. ✨',
twitch:'https://www.twitch.tv/rin_tsukii', handle:'@rin_tsukii', g:'#A4DD3A' },
{ id:'kiwi', name:'Kiwi', tag:'Co-Owner of Ignite Club · Former NA Raider', jp:'キウイ',
pills:['EDM','ROCK & METAL','TRANCE'], status:'● RESIDENT DJ',
bio:'Started out as a raider in NA. Went to \u201Cbriefly\u201D help a friend with their club, slipped, and has been stuck in the venue scene ever since. Co-Owner of Ignite Club. 🥝',
twitch:'https://www.twitch.tv/kiwinjoyer', handle:'@kiwinjoyer', g:'#7DEB6E' },
{ id:'iris', name:'Iris Stellaris', tag:'Melodic DJ from Czechia ✨', jp:'アイリス・ステラリス',
pills:['MELODIC TECHNO','PROGRESSIVE','HYPE STUFFS'], status:'● RESIDENT DJ',
bio:'Melodic DJ from Czechia. Specializing in progressive, deep, and melodic house, melodic techno — with frequent voyages into trance, uplifting, hard, and mainstage. Recently exploring harder techno territory too. 🫶',
twitch:'https://www.twitch.tv/iris_stellaris', handle:'@iris_stellaris', g:'#8B5CF6' },
];
const EVENT_NAMES = [
{ en:'NEON BLOOM', jp:'ネオン ブルーム' },
{ en:'KITSUNE FIRE', jp:'狐火' },
{ en:'CHROME HEARTS', jp:'クロム・ハーツ' },
{ en:'MIDNIGHT SAKURA',jp:'夜桜' },
{ en:'ELECTRIC OKAMI', jp:'電気狼' },
{ en:'CYBER BLOSSOM', jp:'サイバー桜' }
];
const FIRST_PARTY_DATE = '2026-05-18';
const TWO_WEEKS_MS = 14*24*60*60*1000;
function getUpcomingParties() {
const now = new Date();
const first = new Date(FIRST_PARTY_DATE+'T20:00:00Z');
let cur = new Date(first), idx = 0;
while (cur < now) { cur = new Date(cur.getTime()+TWO_WEEKS_MS); idx++; }
const out = [];
for (let i=0;i<3;i++) { out.push({date:new Date(cur), idx: idx+i }); cur = new Date(cur.getTime()+TWO_WEEKS_MS); }
return out;
}
const EVENTS_LEGACY = [
{ tag:'GUEST DJ', name:'NEON BLOOM', jp:'ネオン ブルーム', date:'2026.05.16 · 22:00 EST',
lineup:'DJ GAIA · IRIS STELLARIS · KIWI', hue:320 },
{ tag:'B2B SPECIAL', name:'KITSUNE FIRE', jp:'狐火', date:'2026.05.23 · 23:00 EST',
lineup:'GAIA × ALEC MARSTON', hue:0 },
{ tag:'LADIES NIGHT', name:'CHROME HEARTS', jp:'クロム・ハーツ', date:'2026.05.30 · 22:00 EST',
lineup:'AEMILIA · REKSE · RIN', hue:190 },
{ tag:'COSPLAY GACHA', name:'ULTRAVIOLET GACHA', jp:'ガチャ・ナイト', date:'2026.06.06 · 22:00 EST',
lineup:'KHANGOMON · GAIA', hue:265 },
];
const STAFF_DIVISIONS = [
{ num:'01', name:'MANAGEMENT', icon:'crown', jp:'運営', hue:50,
members:[ {n:'GAIA', head:true, photo:v('assets/staff-gaia.png')}, {n:'KAIEN', photo:v('assets/staff-kaien.png')}, {n:'MYOUKI', photo:v('assets/staff-myouki.png')} ] },
{ num:'02', name:'PHOTOGRAPHERS', icon:'camera', jp:'撮影', hue:200,
members:[ {n:'AEDRIC', head:true, photo:v('assets/staff-aedric.png')}, {n:'AXEL', photo:v('assets/staff-axel.png')}, {n:'FELINA', photo:v('assets/staff-felina.png')}, {n:'FOX', photo:v('assets/staff-fox.png')}, {n:'HYTSUKY', photo:v('assets/staff-hytsuky.png')}, {n:'LIVELIA', photo:v('assets/staff-livelia.png')}, {n:'MAYLIN', photo:v('assets/staff-maylin.png')}, {n:'SUGAR', photo:v('assets/staff-sugar.png')} ] },
{ num:'03', name:'BARTENDERS', icon:'wine', jp:'バーテン', hue:30,
members:[ {n:'NOELLE', head:true, photo:v('assets/staff-noelle.png')}, {n:'MAY', photo:v('assets/staff-may.png')}, {n:'ZANE', photo:v('assets/staff-zane.png')} ] },
{ num:'04', name:'SECURITY / GREETER', icon:'shield', jp:'警備', hue:265,
members:[ {n:'HEIZU', photo:v('assets/staff-heizu.png')}, {n:'KURO'}, {n:'MIZUKI', photo:v('assets/staff-mizuki.png')}, {n:'NOIR', photo:v('assets/staff-noir.png')} ] },
{ num:'05', name:'HOST / HOSTESS', icon:'star', jp:'ホステス', hue:320,
members:[ {n:'ELLANORE', head:true, photo:v('assets/staff-ellanore.png')}, {n:'AIKA', photo:v('assets/staff-aika.png')}, {n:'KIM'}, {n:'PEANUT', photo:v('assets/staff-peanut.png')}, {n:'RAVEN', photo:v('assets/staff-raven.png')} ] },
{ num:'06', name:'GAMBLERS', icon:'dices', jp:'賭博', hue:280,
members:[ {n:'NERU', photo:v('assets/staff-neru.png')}, {n:'KURO'}, {n:'KAIEN', photo:v('assets/staff-kaien.png')} ] },
{ num:'07', name:'PROMOTERS', icon:'megaphone',jp:'広報', hue:160,
members:[ {n:'ANGIE', photo:v('assets/staff-angie.png')}, {n:'MANA', photo:v('assets/staff-mana.png')} ] },
];
const STAFF_LEGACY = [
{ name:'Mira Cole', role:'HOSTESS', jp:'ホステス', initials:'MC', hue:320 },
{ name:'Vex Lin', role:'PHOTOGRAPHER', jp:'撮影', initials:'VL', hue:190 },
{ name:'Kano Reed', role:'BARTENDER', jp:'バーテン', initials:'KR', hue:30 },
{ name:'Rey Alex', role:'MANAGER', jp:'マネージャー', initials:'RA', hue:50, mgr:true },
{ name:'Zero Vance', role:'SECURITY', jp:'警備', initials:'ZV', hue:265 },
];
const COCKTAIL_CATS = [
{ code:'01', name:'NEON COCKTAILS', sub:'signature alcoholic creations',
drinks:[
{ code:'001', name:'WOAH! Overdrive', jp:'ウォア・オーバードライブ', ing:'vodka · grenadine · citrus', note:'intense and electric', abv:'18% ABV', price:'10,000 GIL', glass:'martini', c1:'#FF2E55', c2:'#FF8E1D', extras:['ice','spark'], featured:true, photo:v('assets/drink-001-overdrive.jpg') },
{ code:'002', name:'Chrome Desire', jp:'クロム・デザイア', ing:'gin · blue curaçao · tonic', note:'cold metallic kiss', abv:'20% ABV', price:'12,000 GIL', glass:'highball', c1:'#5DD3FF', c2:'#C8C8E0', extras:['ice','bubbles'], photo:v('assets/drink-002-chrome.jpg') },
{ code:'003', name:'Midnight Upload',jp:'ミッドナイト・アップロード', ing:'black vodka · blackcurrant · lime',note:'darkness in a glass', abv:'22% ABV', price:'14,000 GIL', glass:'coupe', c1:'#5B2D8C', c2:'#0a0a14', extras:['lime'], photo:v('assets/drink-003-midnight.jpg') },
]},
{ code:'02', name:'SYNTH SHOTS', sub:'short, high-impact',
drinks:[
{ code:'004', name:'Firewall', jp:'ファイアウォール', ing:'tequila · hot sauce · cinnamon', note:'burns like rebellion', abv:'40% ABV', price:'7,500 GIL', glass:'shot', c1:'#FF3B3B', c2:'#FFD700', extras:['steam'], photo:v('assets/drink-004-firewall.jpg') },
{ code:'005', name:'Glitch Shot', jp:'グリッチ・ショット', ing:'absinthe · blue layer · green layer', note:'reality breaks', abv:'38% ABV', price:'8,000 GIL', glass:'shot', c1:'#A4DD3A', c2:'#5DD3FF', extras:['glitch'], photo:v('assets/drink-005-glitch.jpg') },
{ code:'006', name:'Neural Kick', jp:'ニューラル・キック', ing:'espresso vodka · kahlua · cream', note:'wake the system', abv:'30% ABV', price:'9,000 GIL', glass:'shot', c1:'#3D2418', c2:'#E8D4A8', extras:['ice'], photo:v('assets/drink-006-neural.jpg') },
]},
{ code:'03', name:'DIGITAL MOCKTAILS', sub:'premium non-alcoholic',
drinks:[
{ code:'007', name:'Pixel Wave', jp:'ピクセル・ウェーブ', ing:'coconut · butterfly pea · lemon', note:'colors that change', abv:'0%', price:'6,000 GIL', glass:'highball', c1:'#5DD3FF', c2:'#8B5CF6', extras:['ice','flower'], photo:v('assets/drink-007-pixelwave.jpg') },
{ code:'008', name:'Cyber Bloom', jp:'サイバー・ブルーム', ing:'rose · lychee · sparkling water', note:'floral data stream', abv:'0%', price:'6,500 GIL', glass:'coupe', c1:'#FF6BCB', c2:'#FF2E97', extras:['bubbles','flower'], photo:v('assets/drink-008-cyberbloom.jpg') },
{ code:'009', name:'Soft Reboot', jp:'ソフト・リブート', ing:'cucumber · mint · elderflower', note:'reset your night', abv:'0%', price:'5,500 GIL', glass:'martini', c1:'#A4DD3A', c2:'#E8F4E0', extras:['lime'], photo:v('assets/drink-009-softreboot.jpg') },
]},
{ code:'04', name:'POWA DRINKS', sub:'energy boost',
drinks:[
{ code:'010', name:'Night Mode Energy', jp:'ナイト・モード', ing:'yuzu · taurine · ginseng', note:'unlock the dark', abv:'0%', price:'4,000 GIL', glass:'can', c1:'#0F4C81', c2:'#0a0a14', extras:['spark'], photo:v('assets/drink-010-nightmode.jpg') },
{ code:'011', name:'Electric Mate', jp:'エレクトリック', ing:'yerba mate · lemon · mint', note:'natural circuit', abv:'0%', price:'4,000 GIL', glass:'can', c1:'#7CD92F', c2:'#1A4A0F', extras:['spark'], photo:v('assets/drink-011-electric.jpg') },
{ code:'012', name:'Code Red Bull', jp:'コード・レッド', ing:'guarana · cherry · citrus', note:'max performance', abv:'0%', price:'4,500 GIL', glass:'can', c1:'#FF2E55', c2:'#FF6BCB', extras:['spark','glitch'], photo:v('assets/drink-012-codered.jpg') },
]},
];
const MERCH = [
{ code:'M-001', name:'WOAH! Tour Tee', price:'18,000 GIL', shape:0, hue:320, ph:'TEE' },
{ code:'M-002', name:'Kitsune Hoodie', price:'42,000 GIL', shape:0, hue:265, ph:'HOODIE' },
{ code:'M-003', name:'Eorzea Bucket Hat', price:'14,000 GIL', shape:1, hue:50, ph:'HAT' },
{ code:'M-004', name:'Holo Sticker Pack', price:'4,000 GIL', shape:0, hue:190, ph:'STICKERS' },
];
const MEMORIES = [
{ h:240, hue:320, cap:'NEON BLOOM · 2026.04', tags:['ALL','GUEST DJS'] },
{ h:300, hue:265, cap:'COSPLAY NIGHT · 2026.04', tags:['ALL','COSPLAY'] },
{ h:200, hue:190, cap:'KITSUNE FIRE · 2026.03', tags:['ALL','GUEST DJS'] },
{ h:280, hue:30, cap:'COMMUNITY GACHA · 2026.03', tags:['ALL','COMMUNITY'] },
{ h:220, hue:50, cap:'GAIA B-DAY · 2026.03', tags:['ALL','COMMUNITY'] },
{ h:260, hue:0, cap:'BLACK SAPPHIRE B2B · 2026.02', tags:['ALL','GUEST DJS'] },
{ h:200, hue:120, cap:'MASKED RAVE · 2026.02', tags:['ALL','COSPLAY'] },
{ h:320, hue:280, cap:'NIGHT 100 · 2026.01', tags:['ALL','COMMUNITY'] },
{ h:240, hue:340, cap:'HEART OF EORZEA · 2026.01', tags:['ALL','COMMUNITY'] },
{ h:260, hue:200, cap:'CHROME HEARTS · 2025.12', tags:['ALL','GUEST DJS'] },
{ h:220, hue:90, cap:'KIWI X RIN OPEN · 2025.12', tags:['ALL','GUEST DJS'] },
{ h:280, hue:230, cap:'COSPLAY CONTEST · 2025.11', tags:['ALL','COSPLAY'] },
];
/* ---------- Components ---------- */
/* Eorzea Time — Madrid time minus 2h, refreshes every minute */
const EorzeaTime = () => {
const calc = () => {
const madrid = new Date(new Date().toLocaleString('en-US',{timeZone:'Europe/Madrid'}));
const t = new Date(madrid.getTime() - 2*60*60*1000);
const h = String(t.getHours()).padStart(2,'0');
const m = String(t.getMinutes()).padStart(2,'0');
const days = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
// Calculate "cycle" — week of the year for added gacha flair
const startOfYear = new Date(t.getFullYear(), 0, 1);
const dayOfYear = Math.floor((t - startOfYear) / 86400000);
const cycle = String(Math.floor(dayOfYear / 7) + 1).padStart(2,'0');
return { h, m, day: days[t.getDay()], cycle };
};
const [t, setT] = useState(calc);
useEffect(()=>{
const tick = () => setT(calc());
const ms = 60000 - (Date.now() % 60000);
const to = setTimeout(()=>{ tick(); }, ms);
const iv = setInterval(tick, 60000);
return () => { clearTimeout(to); clearInterval(iv); };
},[]);
return (
{/* HUD corner brackets */}
{/* Orbital rune on the left */}
{/* Vertical kanji watermark */}
時刻
{/* Time + day info */}
// EORZEA TIME
{t.h}
:
{t.m}
{t.day} · CYCLE {t.cycle}
);
};
/* Countdown to the next bi-weekly party */
const NextNightCountdown = () => {
const parties = (typeof getUpcomingParties === 'function') ? getUpcomingParties() : [];
const next = parties[0];
const event = next ? EVENT_NAMES[next.idx % EVENT_NAMES.length] : null;
const target = next ? next.date.getTime() : null;
const [now, setNow] = useState(Date.now());
const [secFlick, setSecFlick] = useState(0);
useEffect(()=>{
const iv = setInterval(()=>{ setNow(Date.now()); setSecFlick(f=>f+1); }, 1000);
return () => clearInterval(iv);
},[]);
if (!next) return null;
const diff = target - now;
const live = diff <= 0;
const D = Math.max(0, Math.floor(diff/86400000));
const H = Math.max(0, Math.floor((diff%86400000)/3600000));
const M = Math.max(0, Math.floor((diff%3600000)/60000));
const S = Math.max(0, Math.floor((diff%60000)/1000));
const d = next.date;
const ymd = `${d.getUTCFullYear()}.${String(d.getUTCMonth()+1).padStart(2,'0')}.${String(d.getUTCDate()).padStart(2,'0')}`;
return (
// NEXT NIGHT
{live ? (
[ ★ LIVE NOW ★ ]
) : (
<>
{event.en}
{event.jp}
{String(D).padStart(2,'0')} D
:
{String(H).padStart(2,'0')} H
:
{String(M).padStart(2,'0')} M
:
{String(S).padStart(2,'0')} S
{ymd} · 16:00 ST
>
)}
);
};
/* Hero countdown — large flip-style display that counts down to the next opening.
Uses the same getUpcomingParties() data source as NextNightCountdown. */
const HeroCountdown = () => {
const parties = (typeof getUpcomingParties === 'function') ? getUpcomingParties() : [];
const next = parties[0];
const partyEvent = next ? EVENT_NAMES[next.idx % EVENT_NAMES.length] : null;
const target = next ? next.date.getTime() : null;
const [now, setNow] = useState(Date.now());
useEffect(()=>{
const iv = setInterval(()=>setNow(Date.now()), 1000);
return () => clearInterval(iv);
},[]);
if (!next) return null;
const diff = Math.max(0, target - now);
const D = Math.floor(diff/86400000);
const H = Math.floor((diff%86400000)/3600000);
const M = Math.floor((diff%3600000)/60000);
const S = Math.floor((diff%60000)/1000);
const live = diff <= 0;
const d = next.date;
const ymd = `${String(d.getUTCDate()).padStart(2,'0')}.${String(d.getUTCMonth()+1).padStart(2,'0')}.${d.getUTCFullYear()}`;
if (live) {
return (
// LIVE NOW
{partyEvent && {partyEvent.en} }
[ ★ DOORS OPEN ★ ]
);
}
return (
// NEXT OPENING
{partyEvent && {partyEvent.en} }
{ymd} · 16:00 ST
{String(D).padStart(2,'0')}
DAYS
{String(H).padStart(2,'0')}
HOURS
{String(M).padStart(2,'0')}
MINUTES
{String(S).padStart(2,'0')}
SECONDS
);
};
const WoahMark = ({ size = 28 }) => (
WOAH !
);
/* Real WOAH! logo — used in nav, footer, music player album art */
const WoahLogo = ({ height = 52, alt = 'WOAH! Night Club' }) => (
);
const Pill = ({ children }) => {children} ;
const AmbientParticles = () => (
{Array.from({length:14}).map((_,i)=>{
const colors = ['var(--pink)','var(--cyan)','var(--violet)'];
return ;
})}
);
const Nav = ({ active, navigate, onOpenMobile, soundOn, onToggleSound, pageIdx, totalPages }) => {
const [scrolled, setScrolled] = useState(false);
useEffect(()=>{
const onScroll = () => setScrolled(window.scrollY > 80);
onScroll();
window.addEventListener('scroll', onScroll, {passive:true});
return () => window.removeEventListener('scroll', onScroll);
},[]);
const go = (id) => (e) => { e.preventDefault(); navigate(id); };
return (
);
};
const MobileMenu = ({ open, onClose, navigate }) => {
const go = (id) => (e) => { e.preventDefault(); onClose(); setTimeout(()=>navigate(id), 220); };
return (
{NAV_LINKS.map(([label,id],i)=>(
{label}
))}
);
};
const Hero = () => {
const logoRef = useRef(null);
const onLogoMove = (e) => {
if (window.matchMedia('(pointer:coarse)').matches) return;
const el = logoRef.current; if(!el) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left)/r.width - .5;
const y = (e.clientY - r.top)/r.height - .5;
el.style.setProperty('--lry', `${x*14}deg`);
el.style.setProperty('--lrx', `${-y*14}deg`);
};
const onLogoLeave = () => {
const el = logoRef.current; if(!el) return;
el.style.setProperty('--lry','0deg');
el.style.setProperty('--lrx','0deg');
};
const particles = Array.from({length:18}).map((_,i)=>{
const colors = ['#FF2E97','#00F0FF','#8B5CF6'];
return { left:`${(i*5.7)%100}%`, color:colors[i%3], dur:14+(i%5)*2, delay:-(i*0.9) };
});
// Get next party for the editorial countdown card
const upcoming = (typeof getUpcomingParties === 'function') ? getUpcomingParties() : [];
const nextParty = upcoming[0];
const nextEvent = nextParty ? EVENT_NAMES[nextParty.idx % EVENT_NAMES.length] : null;
const nextDateStr = nextParty ? `${String(nextParty.date.getUTCDate()).padStart(2,'0')}.${String(nextParty.date.getUTCMonth()+1).padStart(2,'0')}.${nextParty.date.getUTCFullYear()}` : '';
return (
{/* Atmospheric layered background */}
{particles.map((p,i)=>( ))}
{/* Floating WOAH! logo — sits next to the title with chrome/holo glitch effect */}
{/* Editorial side rail — left vertical strip */}
N° 01
夜の物語
VOL.III · 2026
{/* Top-right HUD with time */}
{/* Main hero — clean 2-column layout */}
{/* LEFT column: all the textual content */}
LIGHT · ALPHA · MIST W20 P35
THE ANIME MADE DJ
SINCE 2023
WOAH!
[
NIGHT CLUB
]
ウォア・ナイトクラブ
DJ Gaia's underground sanctuary inside Final Fantasy XIV. Bi-weekly resident sets,
live producer drops, kawaii cyberpunk visuals and a roster of 28 operatives
across seven divisions. Built for friends, designed for the night.
{/* RIGHT column: video player */}
{typeof VideoPlayer !== 'undefined' && }
{/* Scroll indicator */}
);
};
const SectionHead = ({ id, title, jp, lead, leadEl }) => (
{/* Vertical floating title — sits on the left edge */}
{/* Compact horizontal bar with meta + lead */}
// SECTION {id}
{title}
{jp}
{leadEl ? (
{leadEl}
) : (lead &&
{lead}
)}
);
const Events = () => {
const parties = getUpcomingParties();
const [now, setNow] = useState(Date.now());
useEffect(()=>{ const t=setInterval(()=>setNow(Date.now()),1000); return ()=>clearInterval(t); },[]);
const cards = parties.map((p,i)=>{
const nm = EVENT_NAMES[p.idx % EVENT_NAMES.length];
const d = p.date;
const ymd = `${d.getUTCFullYear()}.${String(d.getUTCMonth()+1).padStart(2,'0')}.${String(d.getUTCDate()).padStart(2,'0')}`;
return { ...nm, date: d, ymd, idx:i, hue:[320,275,200][i] };
});
const next = cards[0]?.date;
const fmtCD = (d) => {
if (!d) return '';
const ms = d.getTime() - now; if (ms<=0) return 'D-00 · 00:00:00';
const days = Math.floor(ms/86400000);
const h = Math.floor((ms%86400000)/3600000);
const m = Math.floor((ms%3600000)/60000);
const s = Math.floor((ms%60000)/1000);
return `D-${String(days).padStart(2,'0')} · ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
};
return (
[ NEXT 3 NIGHTS · BI-WEEKLY · MONDAYS ]
Each banner is a different chapter. Bring your crew. Bring your fit.
}
/>
{cards.map((e,i)=>(
{String(i+1).padStart(2,'0')}
{i===0 && ★ NEXT NIGHT
}
{i===0 && {fmtCD(next)}
}
OFFICIAL NIGHT // 0{i+1}
{e.en}
{e.jp}
{e.ymd} · 16:00 ST
EDM · TECHNO · ROCK/METAL · ANIME
RESERVE
))}
);
};
/* Staff card socials — gray placeholder unless STAFF_SOCIALS has a link */
const StaffSocials = ({ name }) => {
const s = STAFF_SOCIALS[name] || { instagram:null, twitter:null };
const Btn = ({ kind, url }) => {
const enabled = !!url;
const tip = enabled ? (kind==='instagram'?(url.match(/instagram\.com\/([^\/?#]+)/)?.[1] && '@'+url.match(/instagram\.com\/([^\/?#]+)/)[1]) : (url.match(/(?:twitter|x)\.com\/([^\/?#]+)/)?.[1] && '@'+url.match(/(?:twitter|x)\.com\/([^\/?#]+)/)[1])) : null;
return enabled ? (
e.stopPropagation()}>
) : (
);
};
return (
);
};
/* DJ photo path convention: assets/dj-{id}.png. If file missing, the onError handler
hides the img and the placeholder layer (logo + gradient + scanlines) shows. */
const djPhotoPath = (id) => v(`assets/dj-${id}.png`);
/* DJ card — gacha-banner style. Photo cover, identity logo small, info at bottom. */
const DjCard = ({ dj, featured, onOpen }) => {
const ref = useRef(null);
const [photoOk, setPhotoOk] = useState(true);
const onMove = e => {
if (window.matchMedia('(pointer:coarse)').matches) return;
const r = ref.current.getBoundingClientRect();
const x = (e.clientX - r.left)/r.width - .5;
const y = (e.clientY - r.top)/r.height - .5;
ref.current.style.setProperty('--ry', `${x*5}deg`);
ref.current.style.setProperty('--rx', `${-y*5}deg`);
};
const onLeave = () => {
ref.current.style.setProperty('--ry','0deg');
ref.current.style.setProperty('--rx','0deg');
};
return (
onOpen(dj)}
tabIndex={0}
onKeyDown={e=>{ if(e.key==='Enter') onOpen(dj); }}>
{/* Photo (bottom layer) — covers whole card. onError → switch to placeholder mode */}
setPhotoOk(false)}
/>
{/* Placeholder layer — visible only when there's no photo: gradient bg + big logo */}
{/* Atmospheric overlays */}
{/* HUD corner brackets — same color as DJ */}
{/* Top-left small logo identity badge (always visible — distinct from placeholder big logo) */}
{/* Status pill top-right */}
RESIDENT
{/* Bottom info block — sits over photo with gradient */}
{dj.name.toUpperCase()}
{dj.jp}
{featured ? `"${dj.tag}"` : dj.tag}
{featured && dj.origin &&
{dj.origin}
}
{featured && dj.badge &&
{dj.badge}
}
{/* Hover CTA */}
VIEW PROFILE
);
};
const DjModal = ({ dj, onClose }) => {
const [photoFail, setPhotoFail] = useState(false);
useEffect(()=>{ setPhotoFail(false); },[dj?.id]);
useEffect(()=>{
if (dj) playCardOpen();
},[dj?.id]);
useEffect(()=>{
if(!dj) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = e => { if(e.key==='Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => { document.body.style.overflow = prev; window.removeEventListener('keydown', onKey); };
},[dj, onClose]);
if(!dj) return null;
return ReactDOM.createPortal(
e.stopPropagation()}>
{/* Photo background — blurred, with cinematic slow zoom */}
{!photoFail && (
setPhotoFail(true)}
/>
)}
{/* Color vignette using DJ's color */}
{/* Atmospheric scanlines */}
{/* Content (sits on top of photo layers) */}
{dj.status}
{dj.name.toUpperCase()}
{dj.jp}
"{dj.tag}"
{dj.origin &&
{dj.origin} }
{dj.badge &&
{dj.badge} }
{dj.bio}
{dj.note &&
{dj.note}
}
// {dj.handle} · TWITCH
,
document.body
);
};
const DJs = () => {
const [open, setOpen] = useState(null);
return (
[ THE WOAH! COLLECTIVE — {DJS.length} ARTISTS ]
The voices behind every drop. The hands that move the night.
}
/>
{DJS.map(dj=>)}
setOpen(null)}/>
);
};
const StaffModal = ({ member, division, onClose }) => {
useEffect(()=>{
if (member) playCardOpen();
},[member?.n]);
useEffect(()=>{
if(!member) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = e => { if(e.key==='Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => { document.body.style.overflow = prev; window.removeEventListener('keydown', onKey); };
},[member, onClose]);
if(!member) return null;
const role = (member.role || division.name.split(' / ')[0]).toUpperCase();
const bio = member.bio || 'Bio coming soon — this operative is being briefed for the WOAH! files.';
return ReactDOM.createPortal(
e.stopPropagation()}>
{/* Close button */}
{/* LEFT SIDE — info */}
{division.name}
{member.head &&
★ HEAD OF DIVISION
}
{role}
{member.n.toUpperCase()}
// ABOUT ME
{bio}
{/* RIGHT SIDE — photo frame */}
{member.photo && (
)}
{!member.photo && (
[ NO PHOTO ]
)}
{/* CRT scanlines overlay */}
{/* HUD corners on the photo frame */}
,
document.body
);
};
const Staff = () => {
const [open, setOpen] = useState(null);
const totalOps = STAFF_DIVISIONS.reduce((s,d)=>s+d.members.length,0);
let runningIdx = 0;
return (
[ ROSTER 2026 · {STAFF_DIVISIONS.length} DIVISIONS · {String(totalOps).padStart(2,'0')} OPERATIVES ]
The team that brings every WOAH! night to life.
}
/>
{STAFF_DIVISIONS.map((div,di)=>(
{/* Divider de división — ocupa toda la fila del grid */}
{div.num}
{div.name}
{div.jp}
[ {String(div.members.length).padStart(2,'0')} ]
{/* Cards de la división */}
{div.members.map((m,mi)=>{
const idx = runningIdx++;
return (
setOpen({member:m, division:div})}
onKeyDown={e=>{ if(e.key==='Enter') setOpen({member:m, division:div}); }}>
{m.head && ★ HEAD
}
{div.name.split(' / ')[0]}
{m.photo && }
{m.photo && }
{!m.photo && (
[ NO PHOTO ]
)}
{m.n}
{div.name.split(' / ')[0]}
);
})}
))}
setOpen(null)}/>
);
};
const Cocktails = () => (
{COCKTAIL_CATS.map(cat=>(
[ {cat.name} ]
// {cat.code} · {cat.sub}
{cat.drinks.map(d=>(
// {d.code}
{d.featured &&
★ HOUSE SIGNATURE
}
{d.photo
?
:
}
{d.name}
{d.jp}
{d.ing}
"{d.note}"
{d.abv}
{d.price}
))}
))}
);
const Merch = () => (
// STATUS · IN PRODUCTION
COMING SOON
準備中
Our merch line is being crafted as we speak. T-shirts, hoodies, caps and exclusive WOAH! drops
are on their way. Stay tuned — the first collection drops on a full-moon night.
NOTIFY ME ON DISCORD
);
const VIP_TIERS = [
{ id:'1night', name:'1 NIGHT', sub:'Single Evening Pass', jp:'夜',
price:'1,000,000', priceUnit:'GIL', cadence:'one night',
color:'#00F0FF', kanjiColor:'rgba(0,240,255,.85)',
bg:v('assets/vip-bg-1night.jpg'),
tag:'Best for trying it out.',
perksExtra:[] },
{ id:'1month', name:'1 MONTH', sub:'30 Days of Premium', jp:'月',
price:'1,500,000', priceUnit:'GIL', cadence:'per month',
color:'#FF2E97', kanjiColor:'rgba(255,46,151,.85)',
bg:v('assets/vip-bg-1month.jpg'),
tag:'Most chosen by regulars.',
popular:true,
perksExtra:[] },
{ id:'3months', name:'3 MONTHS', sub:'Quarterly Excellence', jp:'三',
price:'2,000,000', priceUnit:'GIL', cadence:'per quarter',
color:'#8B5CF6', kanjiColor:'rgba(139,92,246,.85)',
bg:v('assets/vip-bg-3months.jpg'),
tag:'Best value per night.',
perksExtra:['Priority booking on themed nights'] },
{ id:'lifetime', name:'LIFETIME', sub:'Forever Elite', jp:'永',
price:'5,000,000', priceUnit:'GIL', cadence:'one-time payment',
color:'#FFD700', kanjiColor:'rgba(255,215,0,.9)',
bg:v('assets/vip-bg-lifetime.jpg'),
tag:'The ultimate flex.',
lifetime:true,
perksExtra:['Custom Discord title','Name on Hall of Fame','Lifetime priority access'] },
];
const VIP_PERKS_BASE = [
['zap','SKIP THE LINE','No more waiting. VIPs get priority entry to the club.'],
['wine','2 FREE DRINKS','Each night, grab two complimentary drinks at the bar.'],
['sparkles','EXCLUSIVE DISCORD ROLE','Get recognized as a VIP in our community.'],
['heart','SUPPORT THE CLUB','Your membership helps keep the party alive and the clubs running.'],
];
const VIP = () => {
const [tierIdx, setTierIdx] = useState(1); // start on "1 MONTH" (popular)
const tier = VIP_TIERS[tierIdx];
// Generate a fake member number stable per tier so it doesn't jitter on rerender
const memberNum = React.useMemo(()=>{
const seed = (tier.id+'WOAH').split('').reduce((a,c)=>a+c.charCodeAt(0),0);
const seg = (n)=>((seed*n)%9000+1000).toString();
return `${seg(7)} ${seg(13)} ${seg(19)} ${seg(23)}`;
},[tier.id]);
return (
VIP
{Array.from({length:18}).map((_,i)=>(
))}
{/* THE CARD — interactive tier switcher */}
[ SELECT YOUR TIER ]
// 4 membership levels available
{/* Tier tabs */}
{VIP_TIERS.map((t,i)=>(
setTierIdx(i)}>
{t.popular && ★ POPULAR }
{t.lifetime && ♛ ELITE }
{t.name}
{t.price} {t.priceUnit}
))}
{/* The MEMBERSHIP CARD */}
{/* Background image */}
{/* Rotating orbital ring — gives the feel of the focal element spinning */}
{/* Floating particles in tier color */}
{Array.from({length:14}).map((_,i)=>(
))}
{/* Drifting sakura petals */}
{Array.from({length:6}).map((_,i)=>(
❀
))}
{/* Diagonal light streak */}
{/* HUD corner brackets */}
{/* Content overlay — left side */}
// TIER
{tier.name}
{tier.sub}
{memberNum}
VALID · {tier.cadence.toUpperCase()}
{/* Lifetime gets extra sparkles */}
{tier.lifetime && (
{Array.from({length:8}).map((_,i)=>(
✦
))}
)}
{/* Perks panel — sits beside / below card */}
[ {tier.name} INCLUDES ]
{tier.tag}
{VIP_PERKS_BASE.map(([icon,title,desc])=>(
))}
{tier.perksExtra.map((extra,i)=>(
{extra}
Bonus included with {tier.name}.
))}
);
};
const Memories = () => {
return (
{typeof MemoriesGalleryV2 !== 'undefined' && }
);
};
const Footer = () => {
const year = new Date().getFullYear();
return (
);
};
/* ---------- Top Marquee — sits below the nav, measures its height in real time ---------- */
const TopMarquee = () => {
const [navH, setNavH] = useState(80);
useEffect(() => {
const measure = () => {
const nav = document.querySelector('.nav');
if (nav) setNavH(nav.getBoundingClientRect().height);
};
measure();
window.addEventListener('resize', measure);
// Also re-measure shortly after mount, in case fonts/images load late
const t = setTimeout(measure, 300);
const t2 = setTimeout(measure, 1200);
return () => {
window.removeEventListener('resize', measure);
clearTimeout(t);
clearTimeout(t2);
};
}, []);
// One set of items, rendered twice for seamless infinite loop
const items = [
'WOAH!', '狼の夜', 'NIGHT CLUB', 'DJ GAIA RESIDENT',
'BI-WEEKLY MONDAYS', 'KAWAII CYBERPUNK',
'WOAH!', '狼煙', 'LIGHT · ALPHA · MIST W20 P35',
];
const renderSet = (keyPrefix) => items.map((text, i) => (
{text}
◆
));
return (
{renderSet('a')}
{renderSet('b')}
);
};
/* ---------- Routing + Transitions ---------- */
const PAGES = [
{ id:'home', label:'HOME', theme:'home', Comp:Hero, sid:'7F3A' },
{ id:'staff', label:'STAFF', theme:'staff', Comp:Staff, sid:'4A2E' },
{ id:'djs', label:'RESIDENTS', theme:'djs', Comp:DJs, sid:'9D44' },
{ id:'events', label:'EVENTS', theme:'events', Comp:Events, sid:'B12C' },
{ id:'cocktails', label:'MENU', theme:'cocktails', Comp:Cocktails, sid:'C7F1' },
{ id:'vip', label:'VIP', theme:'vip', Comp:VIP, sid:'GOLD' },
{ id:'merch', label:'MERCH', theme:'merch', Comp:Merch, sid:'08AB' },
{ id:'memories', label:'MEMORIES', theme:'memories', Comp:Memories, sid:'F0F0' },
];
const getPage = (id) => PAGES.find(p=>p.id===id) || PAGES[0];
/* Cyberpunk procedural transition sound — WebAudio
Layers: hi-tech click → metallic zap sweep → whoosh noise → sub-bass thump → digital glitch tail.
Designed to fit the cyberpunk anime UI vibe.
*/
let _audioCtx;
const _ensureCtx = () => {
if (!_audioCtx) _audioCtx = new (window.AudioContext||window.webkitAudioContext)();
// Browsers suspend the context until first user gesture; resume to be safe
if (_audioCtx.state === 'suspended') { try { _audioCtx.resume(); } catch(e){} }
return _audioCtx;
};
const playWhoosh = () => {
try {
const ctx = _ensureCtx();
const t = ctx.currentTime;
const master = ctx.createGain();
master.gain.value = 0.85;
master.connect(ctx.destination);
/* === LAYER 1: Hi-tech click (sharp transient) === */
const click = ctx.createOscillator();
click.type = 'square';
click.frequency.setValueAtTime(2400, t);
click.frequency.exponentialRampToValueAtTime(800, t + 0.04);
const clickGain = ctx.createGain();
clickGain.gain.setValueAtTime(0, t);
clickGain.gain.linearRampToValueAtTime(0.06, t + 0.005);
clickGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
click.connect(clickGain).connect(master);
click.start(t); click.stop(t + 0.07);
/* === LAYER 2: Metallic zap sweep (the signature cyberpunk swoosh) === */
const zap = ctx.createOscillator();
zap.type = 'sawtooth';
zap.frequency.setValueAtTime(180, t + 0.02);
zap.frequency.exponentialRampToValueAtTime(2200, t + 0.28);
const zapFilter = ctx.createBiquadFilter();
zapFilter.type = 'bandpass';
zapFilter.Q.value = 6;
zapFilter.frequency.setValueAtTime(600, t + 0.02);
zapFilter.frequency.exponentialRampToValueAtTime(3200, t + 0.28);
const zapGain = ctx.createGain();
zapGain.gain.setValueAtTime(0, t + 0.02);
zapGain.gain.linearRampToValueAtTime(0.07, t + 0.06);
zapGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
zap.connect(zapFilter).connect(zapGain).connect(master);
zap.start(t + 0.02); zap.stop(t + 0.36);
/* === LAYER 3: Noise whoosh (air movement / digital sweep) === */
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.45, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random()*2-1) * (1 - i/d.length);
const noise = ctx.createBufferSource(); noise.buffer = buf;
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = 'bandpass';
noiseFilter.Q.value = 10;
noiseFilter.frequency.setValueAtTime(500, t);
noiseFilter.frequency.exponentialRampToValueAtTime(5000, t + 0.32);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.12, t);
noiseGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.42);
noise.connect(noiseFilter).connect(noiseGain).connect(master);
noise.start(t); noise.stop(t + 0.45);
/* === LAYER 4: Sub-bass thump (gives it "weight") === */
const sub = ctx.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(80, t + 0.04);
sub.frequency.exponentialRampToValueAtTime(38, t + 0.22);
const subGain = ctx.createGain();
subGain.gain.setValueAtTime(0, t + 0.04);
subGain.gain.linearRampToValueAtTime(0.18, t + 0.07);
subGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
sub.connect(subGain).connect(master);
sub.start(t + 0.04); sub.stop(t + 0.3);
/* === LAYER 5: Digital glitch tail (8-bit blip at the end) === */
const blip = ctx.createOscillator();
blip.type = 'square';
blip.frequency.setValueAtTime(1600, t + 0.22);
blip.frequency.setValueAtTime(900, t + 0.27);
blip.frequency.setValueAtTime(2200, t + 0.31);
blip.frequency.exponentialRampToValueAtTime(220, t + 0.4);
const blipGain = ctx.createGain();
blipGain.gain.setValueAtTime(0.05, t + 0.22);
blipGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.42);
blip.connect(blipGain).connect(master);
blip.start(t + 0.22); blip.stop(t + 0.42);
} catch(e) { /* silent fail */ }
};
/* Premium card-open SFX — minimalist, ~350ms total.
Layers: hi-tech tick (very brief), bright sparkle bloom, soft sub-pulse.
Designed to feel "wow, premium" without being intrusive on repeated opens. */
const playCardOpen = () => {
if (typeof window !== 'undefined' && window.__woahSoundOn === false) return;
try {
const ctx = _ensureCtx();
const t = ctx.currentTime;
const master = ctx.createGain();
master.gain.value = 0.55;
master.connect(ctx.destination);
/* === LAYER 1: micro tick (a precise high transient — like a tactile button) === */
const tick = ctx.createOscillator();
tick.type = 'triangle';
tick.frequency.setValueAtTime(3200, t);
tick.frequency.exponentialRampToValueAtTime(1800, t + 0.025);
const tickGain = ctx.createGain();
tickGain.gain.setValueAtTime(0, t);
tickGain.gain.linearRampToValueAtTime(0.07, t + 0.003);
tickGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
tick.connect(tickGain).connect(master);
tick.start(t); tick.stop(t + 0.05);
/* === LAYER 2: rising sparkle bloom (the "wow" — bright harmonic bell) === */
/* Two slightly detuned sines play a perfect-fifth-ish chord (E5 + B5) for a luxe shimmer */
const note1 = ctx.createOscillator();
note1.type = 'sine';
note1.frequency.setValueAtTime(659.25, t + 0.02); // E5
const note2 = ctx.createOscillator();
note2.type = 'sine';
note2.frequency.setValueAtTime(987.77, t + 0.04); // B5
const note3 = ctx.createOscillator();
note3.type = 'sine';
note3.frequency.setValueAtTime(1318.51, t + 0.06); // E6
const bellGain = ctx.createGain();
bellGain.gain.setValueAtTime(0, t);
bellGain.gain.linearRampToValueAtTime(0.10, t + 0.05);
bellGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.42);
/* Soft bandpass to give it that crystalline character */
const bandpass = ctx.createBiquadFilter();
bandpass.type = 'bandpass';
bandpass.frequency.setValueAtTime(900, t);
bandpass.frequency.exponentialRampToValueAtTime(1800, t + 0.25);
bandpass.Q.value = 1.4;
note1.connect(bellGain);
note2.connect(bellGain);
note3.connect(bellGain);
bellGain.connect(bandpass).connect(master);
note1.start(t + 0.02); note1.stop(t + 0.42);
note2.start(t + 0.04); note2.stop(t + 0.42);
note3.start(t + 0.06); note3.stop(t + 0.42);
/* === LAYER 3: gentle sub-bass thump (gives weight without booming) === */
const sub = ctx.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(110, t);
sub.frequency.exponentialRampToValueAtTime(55, t + 0.18);
const subGain = ctx.createGain();
subGain.gain.setValueAtTime(0, t);
subGain.gain.linearRampToValueAtTime(0.06, t + 0.02);
subGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
sub.connect(subGain).connect(master);
sub.start(t); sub.stop(t + 0.24);
} catch(e) { /* silent fail */ }
};
const TransitionOverlay = ({ phase, page }) => (
{/* Hexagonal grid backdrop */}
{/* Vertical glitch slabs that crash into the center */}
{/* Static + RGB chromatic glitch noise */}
{/* Horizontal cyan bands that streak across */}
{Array.from({length:9}).map((_,i)=>(
))}
{/* Energy wave — pink burst from center */}
{/* Cross-hair laser scanlines */}
{/* Theme color flash on entry */}
{/* Big section name in the center */}
// SECTION_{String(PAGES.indexOf(page)+1).padStart(2,'0')}
{page.label}
{/* Loader */}
> LOADING [ {page.label} ] _
{/* Corner brackets */}
);
const SessionId = ({ page }) => (
SESSION: WOAH-{page.label}-{page.sid}
);
const CursorFlash = ({ trigger }) => {
const [pos, setPos] = useState({x:-100,y:-100});
const [on, setOn] = useState(false);
useEffect(()=>{
const onMove = e => setPos({x:e.clientX,y:e.clientY});
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
},[]);
useEffect(()=>{
if (!trigger) return;
setOn(true);
const t = setTimeout(()=>setOn(false), 380);
return () => clearTimeout(t);
},[trigger]);
if (!on) return null;
return ;
};
/* ---------- App ---------- */
/* Hash routing helpers — keep page state in URL so F5 / back / forward work */
const getPageFromHash = () => {
const hash = (window.location.hash || '').replace(/^#/, '').toLowerCase();
// Only accept it if it matches a known page id
if (PAGES && PAGES.some(p => p.id === hash)) return hash;
return 'home';
};
const App = () => {
const [mobileOpen, setMobileOpen] = useState(false);
// Initialize from URL hash so refresh keeps you on the same page
const [pageId, setPageId] = useState(getPageFromHash);
const [phase, setPhase] = useState('idle'); // idle | out | mid | in
const [pendingId, setPendingId] = useState(null);
const [soundOn, setSoundOn] = useState(true);
const [cursorTick, setCursorTick] = useState(0);
// Expose soundOn globally so non-React helpers (playCardOpen) can respect it
useEffect(()=>{
if (typeof window !== 'undefined') window.__woahSoundOn = soundOn;
}, [soundOn]);
const reduced = typeof window !== 'undefined' &&
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const navigate = useCallback((id) => {
if (id === pageId || phase !== 'idle') return;
if (soundOn) playWhoosh();
setCursorTick(t=>t+1);
// Update URL hash so refresh / share-link / browser back-forward work
// Use replaceState for 'home' to avoid littering history with empty hash
if (id === 'home') {
history.replaceState(null, '', window.location.pathname + window.location.search);
} else if (window.location.hash !== `#${id}`) {
history.pushState(null, '', `#${id}`);
}
if (reduced) {
setPhase('out');
setTimeout(()=>{ setPageId(id); setPhase('in'); }, 100);
setTimeout(()=>setPhase('idle'), 300);
window.scrollTo(0,0);
return;
}
setPendingId(id);
const fast = window.matchMedia('(max-width: 640px)').matches;
const T = fast ? { out:200, mid:300, total:900 } : { out:300, mid:400, total:1200 };
setPhase('out');
setTimeout(()=>{ setPhase('mid'); }, T.out);
setTimeout(()=>{ setPageId(id); setPhase('in'); window.scrollTo(0,0); }, T.out + T.mid);
setTimeout(()=>{ setPhase('idle'); setPendingId(null); }, T.total);
},[pageId, phase, soundOn, reduced]);
// Expose navigate globally so child components in other files can trigger it
useEffect(()=>{ window.navigateTo = navigate; },[navigate]);
// Listen for browser back/forward so URL stays in sync with state
useEffect(()=>{
const onPop = () => {
const target = getPageFromHash();
if (target !== pageId && phase === 'idle') {
// Re-trigger navigate but without pushing another history entry
// (the browser already handled the URL change)
if (soundOn) playWhoosh();
if (reduced) {
setPhase('out');
setTimeout(()=>{ setPageId(target); setPhase('in'); }, 100);
setTimeout(()=>setPhase('idle'), 300);
window.scrollTo(0,0);
return;
}
setPendingId(target);
const fast = window.matchMedia('(max-width: 640px)').matches;
const T = fast ? { out:200, mid:300, total:900 } : { out:300, mid:400, total:1200 };
setPhase('out');
setTimeout(()=>{ setPhase('mid'); }, T.out);
setTimeout(()=>{ setPageId(target); setPhase('in'); window.scrollTo(0,0); }, T.out + T.mid);
setTimeout(()=>{ setPhase('idle'); setPendingId(null); }, T.total);
}
};
window.addEventListener('popstate', onPop);
window.addEventListener('hashchange', onPop);
return () => {
window.removeEventListener('popstate', onPop);
window.removeEventListener('hashchange', onPop);
};
},[pageId, phase, soundOn, reduced]);
// expose globally so existing in-page links can route
useEffect(()=>{ window.__woahNavigate = navigate; },[navigate]);
// intercept all href="#xxx" clicks where xxx matches a page id
useEffect(()=>{
const onClick = (e) => {
const a = e.target.closest && e.target.closest('a[href^="#"]');
if (!a) return;
const href = a.getAttribute('href') || '';
const id = href.slice(1);
if (PAGES.some(p=>p.id===id)) {
e.preventDefault();
navigate(id);
}
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
},[navigate]);
const page = getPage(pageId);
const pendingPage = pendingId ? getPage(pendingId) : page;
const pageIdx = PAGES.findIndex(p=>p.id===pageId)+1;
const Comp = page.Comp;
return (
setMobileOpen(true)}
soundOn={soundOn} onToggleSound={()=>setSoundOn(s=>!s)}
pageIdx={pageIdx} totalPages={PAGES.length}/>
setMobileOpen(false)} navigate={navigate}/>
{phase!=='idle' && }
);
};
ReactDOM.createRoot(document.getElementById('root')).render(
);