export default async function(req) { const url = new URL(req.url); if (url.pathname === "/cal") { const cal = await fetch("https://calendar.google.com/calendar/ical/oe8lheh8hkrrjvsagf8ih3dmf0%40group.calendar.google.com/public/basic.ics"); const text = await cal.text(); return new Response(text, { headers: { "Content-Type": "text/calendar", "Access-Control-Allow-Origin": "" }}); } const html = "\n<html lang="en">\n\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">\n<meta name="apple-mobile-web-app-capable" content="yes">\n<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">\nALEPH GIGS\n<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,300;0,400;1,300&family=DM+Sans:wght@300;400;500&display=swap\" rel="stylesheet">\n\n:root {\n --black:#0a0a0a; --deep:#111; --card:#181818; --border:#2a2a2a;\n --gold:#4f9cf9; --gold-dim:#1e4a7a; --red:#c0392b; --green:#27ae60;\n --white:#f0ede8; --muted:#666;\n}\n{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}\nbody{background:var(--black);color:var(--white);font-family:'DM Sans',sans-serif;min-height:100vh;padding-bottom:80px;overscroll-behavior:none;}\nbody::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");pointer-events:none;z-index:9999;opacity:.4;}\nheader{display:flex;align-items:center;justify-content:space-between;padding:52px 18px 14px;border-bottom:1px solid var(--border);position:sticky;top:0;background:rgba(10,10,10,.97);backdrop-filter:blur(10px);z-index:100;}\n.logo{font-family:'Bebas Neue',sans-serif;font-size:28px;letter-spacing:4px;color:var(--gold);line-height:1;}\n.logo span{color:var(--white);}\n.icon-btn{background:var(--card);border:1px solid var(--border);color:var(--white);width:36px;height:36px;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;}\nnav{position:fixed;bottom:0;left:0;right:0;z-index:100;display:flex;background:rgba(10,10,10,.97);backdrop-filter:blur(12px);border-top:1px solid var(--border);padding-bottom:env(safe-area-inset-bottom);}\n.nav-btn{flex:1;border:none;background:none;color:var(--muted);padding:14px 4px 12px;font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;cursor:pointer;border-top:2px solid transparent;transition:color .2s;}\n.nav-btn.active{color:var(--gold);border-top-color:var(--gold);}\n.page{display:none;padding:16px;}\n.page.active{display:block;animation:fadeIn .15s ease;}\n@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}\n.section-label{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin:18px 0 8px;}\n.section-label:first-child{margin-top:4px;}\n.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:12px;}\n.card-title{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--gold);margin-bottom:12px;}\n.gig-card{position:relative;overflow:hidden;}\n.gig-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;}\n.gig-card.h-red::before{background:var(--red);}\n.gig-card.h-gold::before{background:var(--gold);}\n.gig-card.h-green::before{background:var(--green);}\n.gig-card.h-tbc::before{background:var(--muted);}\n.gig-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;}\n.gig-title{font-family:'Bebas Neue',sans-serif;font-size:20px;letter-spacing:1.5px;line-height:1.1;flex:1;}\n.state-badge{font-family:'DM Mono',monospace;font-size:9px;letter-spacing:1px;padding:3px 8px;border-radius:20px;cursor:pointer;border:1px solid;white-space:nowrap;flex-shrink:0;}\n.s-ticketed{color:var(--gold);border-color:var(--gold-dim);background:rgba(201,168,76,.08);}\n.s-free{color:var(--green);border-color:#1e7a46;background:rgba(39,174,96,.08);}\n.s-tbc{color:var(--muted);border-color:#333;background:rgba(255,255,255,.04);}\n.gig-meta{margin-top:6px;font-family:'DM Mono',monospace;font-size:11px;color:var(--muted);line-height:1.8;}\n.gig-meta a{color:var(--muted);text-decoration:none;border-bottom:1px solid #333;}\n.days-badge{position:absolute;top:14px;right:14px;font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:1px;}\n.days-badge.red{color:var(--red);}.days-badge.gold{color:var(--gold);}.days-badge.green{color:var(--green);}\n.ticket-bar{margin-top:10px;}\n.ticket-bar-track{height:3px;background:var(--border);border-radius:2px;overflow:hidden;}\n.ticket-bar-fill{height:100%;border-radius:2px;transition:width .4s;}\n.ticket-bar-label{margin-top:4px;font-family:'DM Mono',monospace;font-size:10px;color:var(--muted);display:flex;justify-content:space-between;}\n.gig-actions{margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;}\n.gig-btn{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1px;padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:none;color:var(--white);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;}\n.gig-btn.primary{background:var(--gold);color:var(--black);border-color:var(--gold);font-weight:600;}\n.gig-btn.danger{color:var(--red);border-color:#3a1a1a;margin-left:auto;}\n.promo-hero{text-align:center;padding-bottom:4px;}\n.promo-title{font-family:'Bebas Neue',sans-serif;font-size:26px;letter-spacing:2px;line-height:1.1;}\n.promo-sub{font-family:'DM Mono',monospace;font-size:11px;color:var(--muted);margin-top:4px;}\n.promo-date{font-family:'DM Mono',monospace;font-size:13px;margin-top:6px;}\n.caption-box{background:var(--deep);border:1px solid var(--border);border-radius:10px;padding:14px;margin:12px 0;font-family:'DM Sans',sans-serif;font-size:14px;line-height:1.7;color:var(--white);white-space:pre-wrap;min-height:120px;transition:opacity .15s;}\n.caption-actions{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px;}\n.caption-tab{flex:1;background:var(--deep);border:1px solid var(--border);color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1px;text-transform:uppercase;padding:9px 4px;border-radius:8px;cursor:pointer;transition:all .15s;text-align:center;}\n.caption-tab.active-tab{background:var(--gold);color:var(--black);border-color:var(--gold);font-weight:700;}\n.caption-generating{text-align:center;padding:24px;color:var(--muted);font-family:'DM Mono',monospace;font-size:11px;letter-spacing:1px;}\n.caption-generating::after{content:'';display:inline-block;width:6px;height:6px;background:var(--gold);border-radius:50%;margin-left:8px;animation:pulse 1s infinite;}\n@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}\n.copy-pill{background:var(--deep);border:1px solid var(--border);color:var(--gold);font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1px;padding:8px 12px;border-radius:20px;cursor:pointer;text-align:center;}\n.caption-tab{flex:1;background:var(--deep);border:1px solid var(--border);color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;padding:8px;border-radius:8px;cursor:pointer;transition:all .15s;}\n.caption-tab.active-tab{background:var(--gold);color:var(--black);border-color:var(--gold);font-weight:700;}\n.copy-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px;}\n.contact-name{font-size:14px;font-weight:500;}\n.contact-city{font-family:'DM Mono',monospace;font-size:11px;color:var(--muted);margin-top:2px;}\n.form-group{margin-bottom:12px;}\n.form-label{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);display:block;margin-bottom:6px;}\n.form-input{width:100%;background:var(--deep);border:1px solid var(--border);color:var(--white);padding:10px 12px;border-radius:8px;font-family:'DM Sans',sans-serif;font-size:15px;outline:none;-webkit-appearance:none;}\n.form-input:focus{border-color:var(--gold);}\n.form-input::placeholder{color:#444;}\ntextarea.form-input{resize:vertical;min-height:80px;}\n.btn-full{width:100%;padding:13px;border-radius:10px;font-family:'Bebas Neue',sans-serif;font-size:16px;letter-spacing:2px;cursor:pointer;border:none;background:var(--gold);color:var(--black);margin-top:4px;}\n.btn-full.secondary{background:none;border:1px solid var(--border);color:var(--white);margin-top:8px;}\n.btn-full.danger{background:none;border:1px solid #3a1a1a;color:var(--red);margin-top:8px;}\n#story-overlay{display:none;position:fixed;inset:0;z-index:200;background:#000;flex-direction:column;align-items:center;justify-content:center;padding:50px 24px;}\n#story-overlay.active{display:flex;}\n.story-inner{width:100%;max-width:360px;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:20px;padding:36px 28px;text-align:center;}\n.story-kicker{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:var(--gold);margin-bottom:8px;}\n.story-artist{font-family:'Bebas Neue',sans-serif;font-size:44px;letter-spacing:6px;line-height:1;color:var(--white);margin-bottom:4px;}\n.story-presents{font-size:11px;color:var(--muted);letter-spacing:2px;margin-bottom:24px;}\n.story-gig-title{font-family:'Bebas Neue',sans-serif;font-size:28px;letter-spacing:2px;color:var(--gold);margin-bottom:18px;line-height:1.1;}\n.story-detail{font-family:'DM Mono',monospace;font-size:12px;color:var(--muted);letter-spacing:1px;margin-bottom:6px;}\n.story-detail strong{color:var(--white);}\n.story-divider{border:none;border-top:1px solid #2a2a2a;margin:18px 0;}\n.story-cta{font-family:'Bebas Neue',sans-serif;font-size:14px;letter-spacing:3px;color:var(--gold);}\n.story-close{position:fixed;top:20px;right:20px;background:#1a1a1a;border:1px solid #333;color:var(--white);width:40px;height:40px;border-radius:50%;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;z-index:201;}\n.modal-overlay{display:none;position:fixed;inset:0;z-index:150;background:rgba(0,0,0,.85);backdrop-filter:blur(4px);align-items:flex-end;justify-content:center;}\n.modal-overlay.active{display:flex;}\n.modal{background:var(--card);border:1px solid var(--border);border-radius:20px 20px 0 0;padding:24px 20px 44px;width:100%;max-width:520px;animation:slideUp .25s ease;max-height:90vh;overflow-y:auto;}\n@keyframes slideUp{from{transform:translateY(40px);opacity:0}}\n.modal-title{font-family:'Bebas Neue',sans-serif;font-size:22px;letter-spacing:2px;color:var(--gold);margin-bottom:16px;}\n.modal-close{float:right;background:none;border:none;color:var(--muted);cursor:pointer;font-size:20px;margin-top:-4px;}\n.ics-status{display:inline-flex;align-items:center;gap:5px;margin-top:8px;font-family:'DM Mono',monospace;font-size:10px;padding:4px 10px;border-radius:20px;}\n.ics-status.ok{color:var(--green);background:rgba(39,174,96,.1);border:1px solid #1e7a46;}\n.ics-status.err{color:var(--red);background:rgba(192,57,43,.1);border:1px solid #7a2920;}\n.ics-status.loading{color:var(--muted);background:rgba(255,255,255,.05);border:1px solid var(--border);}\n.empty{text-align:center;padding:48px 20px;color:var(--muted);font-family:'DM Mono',monospace;font-size:12px;letter-spacing:1px;}\n.empty-icon{font-size:32px;margin-bottom:12px;opacity:.4;}\n.venue-tag{display:inline-block;font-family:'DM Mono',monospace;font-size:10px;background:rgba(201,168,76,.1);color:var(--gold);border:1px solid var(--gold-dim);padding:2px 8px;border-radius:20px;margin:2px;}\n.split-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}\n#toast{position:fixed;bottom:90px;left:50%;transform:translateX(-50%);background:var(--gold);color:var(--black);font-family:'DM Mono',monospace;font-size:12px;letter-spacing:1px;padding:10px 20px;border-radius:20px;opacity:0;transition:opacity .3s;pointer-events:none;z-index:300;white-space:nowrap;}\n#toast.show{opacity:1;}\n::-webkit-scrollbar{width:0;}\n.install-banner{background:linear-gradient(135deg,rgba(201,168,76,.15),rgba(201,168,76,.05));border:1px solid var(--gold-dim);border-radius:12px;padding:14px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px;}\n.install-banner-text{flex:1;font-family:'DM Mono',monospace;font-size:11px;line-height:1.6;color:var(--white);}\n.install-banner-text strong{color:var(--gold);}\n.install-banner-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:0;flex-shrink:0;}\n.venue-suggestion{padding:12px 14px;font-family:'DM Sans',sans-serif;font-size:14px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}\n.venue-suggestion:last-child{border-bottom:none;}\n.venue-suggestion:active{background:rgba(201,168,76,.1);}\n.venue-sug-name{font-weight:500;color:var(--white);}\n.venue-sug-city{font-family:'DM Mono',monospace;font-size:10px;color:var(--muted);}\n\n\n\n\n<div id="story-overlay">\n <button class="story-close" onclick="closeStory()">\u2715\n <div class="story-inner" id="story-inner">\n\n\n
\n <div class="logo">ALEPH GIGS <span style="font-family:'DM Mono',monospace;font-size:11px;letter-spacing:2px;color:#4f9cf9;vertical-align:middle">v2\n <div style="display:flex;gap:8px">\n <button class="icon-btn" onclick="openAddGig()">\uff0b\n \n\n\n<div id="page-promo" class="page active">\n<div id="page-gigs" class="page">\n<div id="page-invite" class="page">\n<div id="page-setup" class="page">\n\n\n <button class="nav-btn active" id="nav-promo" onclick="switchTab('promo',this)">Promo\n <button class="nav-btn" id="nav-gigs" onclick="switchTab('gigs',this)">Gigs\n <button class="nav-btn" id="nav-invite" onclick="switchTab('invite',this)">Invite\n <button class="nav-btn" id="nav-setup" onclick="switchTab('setup',this)">Setup\n\n\n\n<div class="modal-overlay" id="modal-add-gig">\n <div class="modal">\n <div class="modal-title">Add Gig <button class="modal-close" onclick="closeModal('modal-add-gig')">\u2715\n <div class="form-group"><label class="form-label">Title<input class="form-input" id="ag-title" placeholder="Aleph Quartet @ Vortex">\n <div class="form-group"><label class="form-label">Date<input class="form-input" id="ag-date" type="date">\n <div class="form-group"><label class="form-label">Time<input class="form-input" id="ag-time" type="time" value="20:00">\n <div class="form-group" style="position:relative">\n <label class="form-label">Venue\n <input class="form-input" id="ag-venue" placeholder="Start typing venue name\u2026" autocomplete="off" oninput="venueAutocomplete(this.value)">\n <div id="venue-suggestions" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:200;background:var(--deep);border:1px solid var(--gold-dim);border-radius:0 0 10px 10px;overflow:hidden;">\n \n <div class="form-group"><label class="form-label">Ticket URL<input class="form-input" id="ag-ticketurl" placeholder="https://...">\n <button class="btn-full" id="ag-save-btn" onclick="saveNewGig()">Save Gig\n \n\n\n\n<div class="modal-overlay" id="modal-sales">\n <div class="modal">\n <div class="modal-title">Update Sales <button class="modal-close" onclick="closeModal('modal-sales')">\u2715\n <input type="hidden" id="et-id">\n <div class="form-group"><label class="form-label">Sold<input class="form-input" id="et-sold" type="number">\n <div class="form-group"><label class="form-label">Capacity<input class="form-input" id="et-cap" type="number">\n <button class="btn-full" onclick="saveSales()">Update\n \n\n\n\n<div class="modal-overlay" id="modal-invite-msg">\n <div class="modal">\n <div class="modal-title">Message <button class="modal-close" onclick="closeModal('modal-invite-msg')">\u2715\n <div class="caption-box" id="invite-msg-body" style="min-height:80px">\n <div class="split-row" style="margin-top:10px">\n <button class="btn-full" onclick="copyInviteMsg()">Copy\n <button class="btn-full secondary" onclick="mailInviteMsg()">Open in Mail\n \n \n\n\n\n<div class="modal-overlay" id="modal-add-contact">\n <div class="modal">\n <div class="modal-title">Add Contact <button class="modal-close" onclick="closeModal('modal-add-contact')">\u2715\n <div class="form-group"><label class="form-label">Name<input class="form-input" id="ac-name" placeholder="Sarah">\n <div class="form-group"><label class="form-label">City<input class="form-input" id="ac-city" placeholder="Brighton">\n <div class="form-group"><label class="form-label">Email (optional)<input class="form-input" id="ac-email" type="email" placeholder="sarah@email.com">\n <button class="btn-full" onclick="saveContact()">Save\n \n\n\n<div id="toast">\n\n {name:"606 Club",aliases:["606","606 club"],city:"London",url:"https://www.606club.co.uk",capacity:120},\n {name:"Vortex Jazz Club",aliases:["vortex","vortex jazz"],city:"London",url:"https://www.vortexjazz.co.uk",capacity:100},\n {name:"PizzaExpress Live",aliases:["pizzaexpress","pizza express"],city:"London",url:"https://www.pizzaexpresslive.com",capacity:120},\n {name:"The Verdict",aliases:["verdict","the verdict"],city:"Brighton",url:"https://www.verdictjazz.com",capacity:70},\n {name:"Peggy's Skylight",aliases:["peggy","peggys","peggy's"],city:"Nottingham",url:"https://www.peggysskylight.co.uk",capacity:100},\n {name:"Matt & Phreds",aliases:["matt and phreds","matt phreds"],city:"Manchester",url:"https://mattandphreds.com",capacity:80},\n {name:"Band on the Wall",aliases:["band on the wall"],city:"Manchester",url:"https://bandonthewall.org",capacity:340},\n {name:"Jazz Bar Edinburgh",aliases:["jazz bar edinburgh","the jazz bar"],city:"Edinburgh",url:"https://www.thejazzbar.co.uk",capacity:100},\n {name:"St George's Bristol",aliases:["st george","st george's bristol"],city:"Bristol",url:"https://www.stgeorgesbristol.co.uk",capacity:500},\n];\nfunction matchVenue(t=''){const q=t.toLowerCase();for(const v of VENUES){if(q.includes(v.name.toLowerCase()))return v;for(const a of(v.aliases||[]))if(q.includes(a))return v;}return null;}\n\n// \u2500\u2500 STORAGE \u2500\u2500\nfunction loadGigs(){try{return JSON.parse(localStorage.getItem('aleph_gigs'))||[];}catch{return[];}}\nfunction saveGigs(g){localStorage.setItem('aleph_gigs',JSON.stringify(g));}\nfunction loadSetup(){try{return JSON.parse(localStorage.getItem('aleph_setup'))||{};}catch{return {};}}\nfunction saveSetup_(s){localStorage.setItem('aleph_setup',JSON.stringify(s));}\nfunction loadContacts(){try{return JSON.parse(localStorage.getItem('aleph_contacts'))||[];}catch{return[];}}\nfunction saveContacts(c){localStorage.setItem('aleph_contacts',JSON.stringify(c));}\n\n// \u2500\u2500 DATE HELPERS \u2500\u2500\nfunction today(){const d=new Date();return${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')};}\nfunction fmtShort(d){if(!d)return'';return new Date(d+'T12:00:00').toLocaleDateString('en-GB',{weekday:'short',day:'numeric',month:'short'});}\nfunction fmtLong(d){if(!d)return'';return new Date(d+'T12:00:00').toLocaleDateString('en-GB',{weekday:'long',day:'numeric',month:'long',year:'numeric'});}\nfunction daysTo(d){return Math.ceil((new Date(d)-new Date(today()))/86400000);}\nfunction daysLabel(d){const n=daysTo(d);if(n<0)return null;if(n===0)return'TONIGHT';if(n===1)return'TOMORROW';return${n}D;}\nfunction calcHealth(g){\n if(g.state==='tbc')return'tbc';\n if(g.state==='free')return'green';\n const d=daysTo(g.date),pct=g.capacity?(g.sold||0)/g.capacity:0;\n if(d<14&&pct<0.3)return'red';\n if(pct>=0.7)return'green';\n return'gold';\n}\nfunction upcomingGigs(){return loadGigs().filter(g=>(g.date||'')>=today()).sort((a,b)=>new Date(a.date)-new Date(b.date));}\nfunction escH(s=''){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}\nfunction toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2200);}\n\n// \u2500\u2500 TAB ROUTING \u2500\u2500\nfunction switchTab(name,el){\n document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));\n document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));\n document.getElementById('page-'+name).classList.add('active');\n (el||document.getElementById('nav-'+name)).classList.add('active');\n if(name==='promo')renderPromo();\n if(name==='gigs')renderGigs();\n if(name==='invite')renderInvite();\n if(name==='setup')renderSetup();\n}\n\n// \u2500\u2500 PROMO \u2500\u2500\nlet _captureCache={};\nlet _captionGigId=null;\nlet _activeCaptionMode='hype';\n\nconst CAPTION_STYLES={\n hype: {label:'Hype & Energy', prompt:'Write an energetic, hype Instagram caption. Bold, punchy, exciting. Use 1-2 emojis max. Short sentences. Build anticipation.'},\n cool: {label:'Cool & Minimal', prompt:'Write a cool, understated Instagram caption. Minimal words, lots of style. No excessive punctuation. Confident and effortless tone.'},\n warm: {label:'Warm & Personal', prompt:'Write a warm, personal Instagram caption. Like talking directly to fans. Genuine, inviting, human. Make people feel welcomed.'},\n};\n\nasync function generateCaptions(g){\n const setup=loadSetup();\n const title=g.title||'Aleph';\n const venue=g.venue||'the venue';\n const city=g.city||'';\n const date=fmtLong(g.date);\n const time=g.time||'';\n const lineup=setup.lineup||'';\n const artist=setup.artist||'Aleph';\n const type=g.state==='ticketed'?'ticketed (tickets required)':g.state==='tbc'?'TBC (details coming soon)':'free entry';\n const ticketUrl=g.ticketUrl||setup.ticketLink||'';\n\n const gigInfo=Artist/Band: ${artist}\nGig title: ${title}\nVenue: ${venue}${city?' in '+city:''}\nDate: ${date}${time?' at '+time:''}\nEntry: ${type}${ticketUrl?'\\nTicket link: '+ticketUrl:''}${lineup?'\\nLineup: '+lineup:''};\n\n _captureCache={};\n _captionGigId=g.id;\n\n // Generate all 3 styles\n for(const [key,style] of Object.entries(CAPTION_STYLES)){\n updateCaptionBox(key,'generating');\n try{\n const res=await fetch('https://api.anthropic.com/v1/messages',{\n method:'POST',\n headers:{'Content-Type':'application/json'},\n body:JSON.stringify({\n model:'claude-sonnet-4-20250514',\n max_tokens:300,\n messages:[{role:'user',content:You are writing Instagram captions for a jazz musician promoting a live gig.\n\n${gigInfo}\n\nStyle instruction: ${style.prompt}\n\nWrite ONE Instagram caption only. Include relevant hashtags at the end (3-5 max, jazz-focused). No preamble, no explanation \u2014 just the caption itself.}]\n })\n });\n const data=await res.json();\n const text=data.content?.[0]?.text||'';\n _captureCache[key]=text;\n if(_activeCaptionMode===key) updateCaptionBox(key,'done');\n }catch(e){\n const msg=isStandalone()\n ?'Generation failed. Check your connection and try again.'\n :'Install as app first: tap Share \u2192 Add to Home Screen, then open from your home screen.';\n _captureCache[key]=msg;\n if(_activeCaptionMode===key) updateCaptionBox(key,'done');\n }\n }\n}\n\nfunction updateCaptionBox(mode,state){\n if(_activeCaptionMode!==mode)return;\n const box=document.getElementById('caption-box');\n if(!box)return;\n if(state==='generating'){\n box.innerHTML=<div class=\"caption-generating\">Writing ${CAPTION_STYLES[mode].label} caption</div>;\n }else{\n box.textContent=_captureCache[mode]||'';\n }\n}\n\nfunction isStandalone(){\n return window.navigator.standalone===true||window.matchMedia('(display-mode: standalone)').matches;\n}\n\nfunction renderPromo(){\n const el=document.getElementById('page-promo');\n const list=upcomingGigs();\n if(!list.length){el.innerHTML=<div class=\"empty\"><div class=\"empty-icon\">\ud83c\udfb7</div>No upcoming gigs.<br>Tap \uff0b to add one.</div>;return;}\n const g=list[0],setup=loadSetup();\n const caption='';\n const venue=matchVenue((g.venue||'')+(g.title||''));\n const venueHtml=venue?<a href=\"${venue.url}\" target=\"_blank\">${escH(g.venue||venue.name)}</a>:escH(g.venue||'');\n const showBanner=!isStandalone()&&!localStorage.getItem('install_dismissed');\n el.innerHTML=\n ${showBanner?<div class="install-banner">\n <div class="install-banner-text">\n Install as app for AI captions & calendar sync.\n Tap Share \u2192 then Add to Home Screen\n \n <button class="install-banner-close" onclick="dismissBanner()">\u2715\n
:''}\n <div class=\"card\">\n <div class=\"promo-hero\">\n <div class=\"promo-title\">${escH(g.title)}</div>\n <div class=\"promo-sub\">${venueHtml}${g.city? \u00b7 ${escH(g.city)}:''}</div>\n <div class=\"promo-date\">${fmtShort(g.date)}${g.time?' \u00b7 '+g.time:''}</div>\n </div>\n <div style=\"margin-top:12px;display:flex;gap:8px\">\n <select class=\"form-input\" style=\"flex:1\" onchange=\"setGigState('${g.id}',this.value)\">\n <option value=\"ticketed\" ${g.state==='ticketed'?'selected':''}>TICKETED</option>\n <option value=\"free\" ${g.state==='free'?'selected':''}>FREE ENTRY</option>\n <option value=\"tbc\" ${g.state==='tbc'?'selected':''}>TBC</option>\n </select>\n <button class=\"gig-btn\" onclick=\"openStory('${g.id}')\">\ud83d\udcf1 Story</button>\n </div>\n </div>\n <div class=\"card\" id=\"caption-card\">\n <div class=\"card-title\">AI Caption for Instagram</div>\n <div style=\"display:flex;gap:6px;margin-bottom:12px\">\n <button class=\"caption-tab active-tab\" id=\"ctab-hype\" onclick=\"switchCaption('hype')\">Hype</button>\n <button class=\"caption-tab\" id=\"ctab-cool\" onclick=\"switchCaption('cool')\">Cool</button>\n <button class=\"caption-tab\" id=\"ctab-warm\" onclick=\"switchCaption('warm')\">Warm</button>\n </div>\n <div class=\"caption-box\" id=\"caption-box\"><div class=\"caption-generating\">Tap Generate to create captions</div></div>\n <div style=\"display:flex;gap:8px;margin-top:10px\">\n <button class=\"btn-full\" onclick=\"triggerGenerate()\" style=\"flex:1\">\u2726 Generate</button>\n <button class=\"btn-full secondary\" onclick=\"copyActiveCaption()\" style=\"flex:1\">Copy</button>\n </div>\n </div>\n ${list.length>1?<p class="section-label">Next Up${list.slice(1,3).map(g2=>\n <div class=\"card gig-card h-${calcHealth(g2)}\">\n <div class=\"gig-header\"><div class=\"gig-title\">${escH(g2.title)}</div><span class=\"state-badge s-${g2.state||'free'}\">${{ticketed:'TICKETED',free:'FREE',tbc:'TBC'}[g2.state||'free']}</span></div>\n <div class=\"gig-meta\">${fmtShort(g2.date)}${g2.venue?' \u00b7 '+escH(g2.venue):''}</div>\n </div>).join('')}:''}\n ;\n}\n\nfunction switchCaption(mode){\n _activeCaptionMode=mode;\n document.querySelectorAll('.caption-tab').forEach(t=>t.classList.remove('active-tab'));\n const tab=document.getElementById('ctab-'+mode);\n if(tab)tab.classList.add('active-tab');\n const box=document.getElementById('caption-box');\n if(!box)return;\n if(_captureCache[mode]){\n box.style.opacity='0.4';\n setTimeout(()=>{box.textContent=_captureCache[mode];box.style.opacity='1';},120);\n }else if(Object.keys(_captureCache).length>0){\n // Other styles generated but not this one yet\n box.innerHTML=<div class=\"caption-generating\">Writing ${CAPTION_STYLES[mode].label} caption</div>;\n }else{\n box.innerHTML=<div class=\"caption-generating\">Tap Generate to create captions</div>;\n }\n}\nfunction copyActiveCaption(){\n const text=_captureCache[_activeCaptionMode];\n if(!text||text.startsWith('Could not'))return toast('Generate a caption first');\n navigator.clipboard.writeText(text).then(()=>toast('Caption copied!')).catch(()=>toast('Long-press to copy'));\n}\nfunction triggerGenerate(){\n const g=upcomingGigs()[0];\n if(!g)return toast('No upcoming gig to generate for');\n _captureCache={};\n _activeCaptionMode='hype';\n document.querySelectorAll('.caption-tab').forEach(t=>t.classList.remove('active-tab'));\n const tab=document.getElementById('ctab-hype');\n if(tab)tab.classList.add('active-tab');\n generateCaptions(g);\n}\nfunction copyCaption(mode){\n const text=_captureCache[mode];\n if(!text)return;\n navigator.clipboard.writeText(text).then(()=>toast('Copied!')).catch(()=>toast('Long-press to copy'));\n}\nfunction copyText(t){navigator.clipboard.writeText(t).then(()=>toast('Copied!')).catch(()=>toast('Long-press to copy'));}\nfunction dismissBanner(){localStorage.setItem('install_dismissed','1');renderPromo();}\nfunction setGigState(id,state){const gigs=loadGigs();const g=gigs.find(x=>String(x.id)===String(id));if(g){g.state=state;saveGigs(gigs);renderPromo();}}\n\n// \u2500\u2500 GIGS TAB \u2500\u2500\nfunction renderGigs(){\n const el=document.getElementById('page-gigs');\n const list=upcomingGigs();\n let html=<button class=\"btn-full\" onclick=\"openAddGig()\" style=\"margin-bottom:16px\">+ Add Gig</button>;\n if(!list.length){el.innerHTML=html+<div class=\"empty\"><div class=\"empty-icon\">\ud83c\udfb7</div>No upcoming gigs.</div>;return;}\n let lastMonth='';\n list.forEach(g=>{\n const d=new Date(g.date+'T12:00:00');\n const month=d.toLocaleString('en-GB',{month:'long',year:'numeric'}).toUpperCase();\n if(month!==lastMonth){html+=<p class=\"section-label\">${month}</p>;lastMonth=month;}\n html+=buildGigCard(g);\n });\n el.innerHTML=html;\n}\n\nfunction buildGigCard(g){\n const h=calcHealth(g),dl=daysLabel(g.date),v=matchVenue((g.venue||'')+(g.title||''));\n const cap=g.capacity||0,sold=g.sold||0,pct=cap?Math.min(100,Math.round(sold/cap100)):0;\n const setup=loadSetup();\n const venueHtml=v?<a href=\"${v.url}\" target=\"_blank\">${escH(g.venue||v.name)}</a>:escH(g.venue||'');\n const daysHtml=dl?<div class=\"days-badge ${h}\">${dl}</div>:'';\n const barHtml=(g.state==='ticketed'&&cap)?<div class=\"ticket-bar\"><div class=\"ticket-bar-track\"><div class=\"ticket-bar-fill\" style=\"width:${pct}%;background:${h==='red'?'var(--red)':h==='green'?'var(--green)':'var(--gold)'}\"></div></div><div class=\"ticket-bar-label\"><span>${sold} sold</span><span>${pct}% of ${cap}</span></div></div>:'';\n const salesBtn=g.state==='ticketed'?<button class=\"gig-btn\" onclick=\"openSales('${g.id}')\">\ud83d\udcca Sales</button>:'';\n const buyBtn=(g.state==='ticketed'&&(g.ticketUrl||setup.ticketLink))?<a href=\"${escH(g.ticketUrl||setup.ticketLink)}\" target=\"_blank\" class=\"gig-btn primary\">\ud83c\udf9f Buy</a>:'';\n const stL={ticketed:'TICKETED',free:'FREE',tbc:'TBC'};const stC={ticketed:'s-ticketed',free:'s-free',tbc:'s-tbc'};const st=g.state||'free';\n return <div class=\"card gig-card h-${h}\">\n ${daysHtml}\n <div class=\"gig-header\"><div class=\"gig-title\">${escH(g.title)}</div><span class=\"state-badge ${stC[st]}\" onclick=\"cycleState('${g.id}')\">${stL[st]}</span></div>\n <div class=\"gig-meta\">\ud83d\uddd3 ${fmtShort(g.date)}${g.time?' \u00b7 \u23f1 '+g.time:''}${venueHtml?' \u00b7 \ud83d\udccd ':''} ${venueHtml}</div>\n ${barHtml}\n <div class=\"gig-actions\">${salesBtn}<button class=\"gig-btn\" onclick=\"openStory('${g.id}')\">\ud83d\udcf1 Story</button>${buyBtn}<button class=\"gig-btn\" onclick=\"openEditGig('${g.id}')\">Edit</button><button class=\"gig-btn danger\" onclick=\"deleteGig('${g.id}')\">\u2715</button></div>\n </div>;\n}\n\nfunction cycleState(id){const gigs=loadGigs();const g=gigs.find(x=>String(x.id)===String(id));if(!g)return;const s=['ticketed','free','tbc'];g.state=s[(s.indexOf(g.state||'free')+1)%3];saveGigs(gigs);renderGigs();renderPromo();}\nfunction deleteGig(id){if(!confirm('Delete this gig?'))return;saveGigs(loadGigs().filter(g=>String(g.id)!==String(id)));renderGigs();renderPromo();renderInvite();}\n\n// \u2500\u2500 VENUE AUTOCOMPLETE \u2500\u2500\nfunction venueAutocomplete(val){\n const box=document.getElementById('venue-suggestions');\n if(!val||val.length<2){box.style.display='none';return;}\n const q=val.toLowerCase();\n const matches=VENUES.filter(v=>\n v.name.toLowerCase().includes(q)||\n (v.aliases||[]).some(a=>a.includes(q))||\n v.city.toLowerCase().includes(q)\n ).slice(0,5);\n if(!matches.length){box.style.display='none';return;}\n box.innerHTML=matches.map(v=>\n <div class=\"venue-suggestion\" onclick=\"selectVenue('${v.name.replace(/'/g,\"\\'\")}')\">\n <div>\n <div class=\"venue-sug-name\">${v.name}</div>\n <div class=\"venue-sug-city\">${v.city}</div>\n </div>\n <div style=\"font-family:'DM Mono',monospace;font-size:10px;color:var(--gold)\">${v.capacity} cap</div>\n </div>).join('');\n box.style.display='block';\n}\n\nfunction selectVenue(name){\n const v=VENUES.find(x=>x.name===name);\n if(!v)return;\n document.getElementById('ag-venue').value=v.name;\n document.getElementById('ag-ticketurl').value=v.url||'';\n document.getElementById('venue-suggestions').style.display='none';\n // Store venue data for save\n window._selectedVenue=v;\n}\n\n// Hide suggestions when tapping outside\ndocument.addEventListener('click',e=>{\n if(!e.target.closest('#modal-add-gig')) return;\n if(!e.target.closest('#ag-venue')&&!e.target.closest('#venue-suggestions')){\n const box=document.getElementById('venue-suggestions');\n if(box)box.style.display='none';\n }\n});\n\n// \u2500\u2500 ADD/EDIT GIG \u2500\u2500\nfunction openAddGig(){\n ['ag-title','ag-venue','ag-ticketurl'].forEach(id=>document.getElementById(id).value='');\n document.getElementById('ag-date').value=today();\n document.getElementById('ag-time').value='20:00';\n document.getElementById('venue-suggestions').style.display='none';\n window._selectedVenue=null;\n const btn=document.getElementById('ag-save-btn');\n btn.textContent='Save Gig';btn.onclick=saveNewGig;\n openModal('modal-add-gig');\n}\nfunction saveNewGig(){\n const title=document.getElementById('ag-title').value.trim();\n const date=document.getElementById('ag-date').value;\n if(!title||!date)return toast('Title & date required');\n const venueStr=document.getElementById('ag-venue').value.trim();\n const sv=window._selectedVenue||matchVenue(venueStr);\n const gigs=loadGigs();\n gigs.push({\n id:Date.now().toString(),title,date,\n time:document.getElementById('ag-time').value,\n venue:venueStr,\n city:sv?sv.city:'',\n capacity:sv?sv.capacity:0,\n sold:0,\n ticketUrl:document.getElementById('ag-ticketurl').value.trim(),\n state:'free'\n });\n saveGigs(gigs);closeModal('modal-add-gig');renderGigs();renderPromo();renderInvite();toast('Gig added \u2713');\n}\nfunction openEditGig(id){\n const g=loadGigs().find(x=>String(x.id)===String(id));if(!g)return;\n document.getElementById('ag-title').value=g.title||'';\n document.getElementById('ag-date').value=g.date||'';\n document.getElementById('ag-time').value=g.time||'';\n document.getElementById('ag-venue').value=g.venue||'';\n document.getElementById('ag-cap').value=g.capacity||'';\n document.getElementById('ag-sold').value=g.sold||'';\n document.getElementById('ag-ticketurl').value=g.ticketUrl||'';\n const btn=document.getElementById('ag-save-btn');\n btn.textContent='Update Gig';\n btn.onclick=()=>{\n const gigs=loadGigs(),idx=gigs.findIndex(x=>String(x.id)===String(id));if(idx<0)return;\n gigs[idx]={...gigs[idx],title:document.getElementById('ag-title').value.trim(),date:document.getElementById('ag-date').value,time:document.getElementById('ag-time').value,venue:document.getElementById('ag-venue').value.trim(),capacity:parseInt(document.getElementById('ag-cap').value)||0,sold:parseInt(document.getElementById('ag-sold').value)||0,ticketUrl:document.getElementById('ag-ticketurl').value.trim()};\n saveGigs(gigs);closeModal('modal-add-gig');btn.textContent='Save Gig';btn.onclick=saveNewGig;renderGigs();renderPromo();toast('Updated');\n };\n openModal('modal-add-gig');\n}\n\n// \u2500\u2500 SALES \u2500\u2500\nfunction openSales(id){const g=loadGigs().find(x=>String(x.id)===String(id));if(!g)return;document.getElementById('et-id').value=id;document.getElementById('et-sold').value=g.sold||0;document.getElementById('et-cap').value=g.capacity||0;openModal('modal-sales');}\nfunction saveSales(){const id=document.getElementById('et-id').value;const gigs=loadGigs();const g=gigs.find(x=>String(x.id)===String(id));if(!g)return;g.sold=parseInt(document.getElementById('et-sold').value)||0;g.capacity=parseInt(document.getElementById('et-cap').value)||0;saveGigs(gigs);closeModal('modal-sales');renderGigs();renderPromo();toast('Updated');}\n\n// \u2500\u2500 STORY \u2500\u2500\nfunction openStory(id){\n const g=loadGigs().find(x=>String(x.id)===String(id));const setup=loadSetup();if(!g)return;\n const v=matchVenue((g.venue||'')+(g.title||''));const artist=setup.artist||'ALEPH';\n const cta=g.state==='ticketed'?'TICKETS AVAILABLE NOW':g.state==='free'?'FREE ENTRY \u2014 ALL WELCOME':'DATE TO BE CONFIRMED';\n document.getElementById('story-inner').innerHTML=\n <div class=\"story-kicker\">LIVE MUSIC</div>\n <div class=\"story-artist\">${escH(artist)}</div>\n <div class=\"story-presents\">PRESENTS</div>\n <div class=\"story-gig-title\">${escH(g.title)}</div>\n <div class=\"story-detail\">\ud83d\uddd3 <strong>${fmtShort(g.date)}</strong></div>\n ${g.time?<div class="story-detail">\ud83d\udd57 ${g.time}:''}\n <div class=\"story-detail\">\ud83d\udccd <strong>${escH(g.venue||(v?v.name:'TBC'))}</strong></div>\n <hr class=\"story-divider\">\n <div class=\"story-cta\">${cta}</div>;\n document.getElementById('story-overlay').classList.add('active');\n}\nfunction closeStory(){document.getElementById('story-overlay').classList.remove('active');}\n\n// \u2500\u2500 INVITE TAB \u2500\u2500\nlet _invContact=null,_invGig=null;\n\nfunction renderInvite(){\n const el=document.getElementById('page-invite');\n const gigs=upcomingGigs();\n const contacts=loadContacts();\n let opts=gigs.map(g=><option value=\"${g.id}\">${escH(g.title)} \u2014 ${fmtShort(g.date)}</option>).join('');\n el.innerHTML=\n <p class=\"section-label\">Select Gig</p>\n <select class=\"form-input\" id=\"invite-gig-sel\" onchange=\"renderInviteContacts()\">\n <option value=\"\">\u2014 choose a gig \u2014</option>${opts}\n </select>\n <div id=\"invite-contacts-panel\"></div>\n <p class=\"section-label\">Contacts</p>\n <button class=\"btn-full secondary\" onclick=\"openModal('modal-add-contact')\" style=\"margin-bottom:12px\">+ Add Contact</button>\n <div id=\"all-contacts-list\"></div>;\n renderAllContacts();\n}\n\nfunction renderInviteContacts(){\n const id=document.getElementById('invite-gig-sel').value;\n const panel=document.getElementById('invite-contacts-panel');\n if(!id){panel.innerHTML='';return;}\n const g=loadGigs().find(x=>String(x.id)===String(id));if(!g)return;\n const contacts=loadContacts();\n const v=matchVenue((g.venue||'')+(g.title||''));\n const gigCity=(v?v.city:g.city||'').toLowerCase();\n const nearby=contacts.filter(c=>c.city&&c.city.toLowerCase()===gigCity);\n const others=contacts.filter(c=>!c.city||c.city.toLowerCase()!==gigCity);\n let html='';\n if(!contacts.length){html=<div class=\"empty\" style=\"padding:20px\">Add contacts below to send invites.</div>;}\n else{\n if(nearby.length){html+=<p class=\"section-label\">\ud83d\udccd In ${v?v.city:g.city||'this city'}</p>;html+=nearby.map(c=>inviteRow(c,g)).join('');}\n if(others.length){html+=<p class=\"section-label\">Other Contacts</p>;html+=others.map(c=>inviteRow(c,g)).join('');}\n }\n panel.innerHTML=html;\n}\n\nfunction inviteRow(c,g){\n return <div class=\"card\" style=\"padding:12px 14px;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px\">\n <div><div class=\"contact-name\">${escH(c.name)}</div><div class=\"contact-city\">${c.city?'\ud83d\udccd '+escH(c.city):''}</div></div>\n <div style=\"display:flex;gap:8px\">\n ${c.email?<button class="gig-btn primary" onclick="quickMail('${c.id}','${g.id}')">Mail:''}\n <button class=\"gig-btn\" onclick=\"showInviteMsg('${c.id}','${g.id}')\">Message</button>\n </div>\n </div>;\n}\n\nfunction buildInviteMsg(c,g){\n const setup=loadSetup();\n const venue=g.venue||(matchVenue(g.title||'')||{}).name||'the venue';\n const link=g.ticketUrl||setup.ticketLink||'';\n let msg=Hey ${c.name}! \ud83c\udfb7\\n\\nWe're playing ${g.title} on ${fmtShort(g.date)}${g.time?' at '+g.time:''} at ${venue}.\\n\\n;\n if(g.state==='ticketed'){msg+=Would love to see you there \u2014 grab a ticket before they go!;if(link)msg+=\\n\ud83d\udc49 ${link};}\n else if(g.state==='free'){msg+=It's free entry \u2014 just show up and bring a friend!;}\n else{msg+=Date to be confirmed but save it \u2014 it'll be a special one.;}\n msg+=\\n\\nHope you can make it! \ud83c\udfb6;\n return msg;\n}\n\nfunction showInviteMsg(cid,gid){\n const c=loadContacts().find(x=>String(x.id)===String(cid));\n const g=loadGigs().find(x=>String(x.id)===String(gid));\n if(!c||!g)return;\n _invContact=c;_invGig=g;\n document.getElementById('invite-msg-body').textContent=buildInviteMsg(c,g);\n openModal('modal-invite-msg');\n}\n\nfunction copyInviteMsg(){\n const text=document.getElementById('invite-msg-body').textContent;\n navigator.clipboard.writeText(text).then(()=>toast('Copied!')).catch(()=>toast('Long-press to copy'));\n}\n\nfunction mailInviteMsg(){\n if(!_invContact||!_invGig)return;\n const text=buildInviteMsg(_invContact,invGig);\n const subject=encodeURIComponent(${_invGig.title} \u2014 ${fmtShort(_invGig.date)});\n const body=encodeURIComponent(text);\n const to=invContact.email?encodeURIComponent(invContact.email):'';\n window.location.href=mailto:${to}?subject=${subject}&body=${body};\n}\n\nfunction quickMail(cid,gid){\n const c=loadContacts().find(x=>String(x.id)===String(cid));\n const g=loadGigs().find(x=>String(x.id)===String(gid));\n if(!c||!g||!c.email)return toast('No email for this contact');\n const text=buildInviteMsg(c,g);\n const subject=encodeURIComponent(${g.title} \u2014 ${fmtShort(g.date)});\n window.location.href=mailto:${encodeURIComponent(c.email)}?subject=${subject}&body=${encodeURIComponent(text)};\n}\n\nfunction renderAllContacts(){\n const el=document.getElementById('all-contacts-list');if(!el)return;\n const contacts=loadContacts();\n if(!contacts.length){el.innerHTML=<div class=\"empty\" style=\"padding:20px\">No contacts yet.</div>;return;}\n el.innerHTML=contacts.map(c=>\n <div class=\"card\" style=\"padding:12px 14px;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px\">\n <div><div class=\"contact-name\">${escH(c.name)}</div><div class=\"contact-city\">${c.city?'\ud83d\udccd '+escH(c.city):''}${c.email?' \u00b7 \ud83d\udce7 '+escH(c.email):''}</div></div>\n <button class=\"gig-btn danger\" onclick=\"deleteContact('${c.id}')\">\u2715</button>\n </div>).join('');\n}\n\nfunction saveContact(){\n const name=document.getElementById('ac-name').value.trim();\n const city=document.getElementById('ac-city').value.trim();\n const email=document.getElementById('ac-email').value.trim();\n if(!name)return toast('Name required');\n const contacts=loadContacts();\n contacts.push({id:Date.now().toString(),name,city,email});\n saveContacts(contacts);\n closeModal('modal-add-contact');\n ['ac-name','ac-city','ac-email'].forEach(id=>document.getElementById(id).value='');\n renderInvite();toast('Contact added');\n}\nfunction deleteContact(id){saveContacts(loadContacts().filter(c=>String(c.id)!==String(id)));renderInvite();toast('Deleted');}\n\n// \u2500\u2500 SETUP TAB \u2500\u2500\nfunction renderSetup(){\n const s=loadSetup();\n document.getElementById('page-setup').innerHTML=\n <p class=\"section-label\">Artist</p>\n <div class=\"card\">\n <div class=\"form-group\"><label class=\"form-label\">Artist / Band Name</label><input class=\"form-input\" id=\"s-artist\" value=\"${escH(s.artist||'')}\" placeholder=\"e.g. Aleph Quartet\"></div>\n <div class=\"form-group\"><label class=\"form-label\">Default Lineup</label><textarea class=\"form-input\" id=\"s-lineup\" placeholder=\"Piano: ...\">${escH(s.lineup||'')}</textarea></div>\n <div class=\"form-group\"><label class=\"form-label\">Default Ticket Link</label><input class=\"form-input\" id=\"s-ticketlink\" value=\"${escH(s.ticketLink||'')}\" placeholder=\"https://...\"></div>\n <button class=\"btn-full\" onclick=\"saveSetupForm()\">Save</button>\n </div>\n <p class=\"section-label\">iCal Sync</p>\n <div class=\"card\">\n <div id=\"ics-step1\" style=\"${s.icsUrl?'display:none':''}\">\n <p style=\"font-family:'DM Mono',monospace;font-size:11px;color:var(--muted);margin-bottom:12px;line-height:1.7\">\n Google Calendar \u2192 your gigs calendar \u2192 <strong style=\"color:var(--white)\">Settings and sharing</strong> \u2192 scroll down to <strong style=\"color:var(--white)\">Secret address in iCal format</strong> \u2192 copy and paste below.\n </p>\n <div class=\"form-group\"><label class=\"form-label\">Secret iCal URL</label><input class=\"form-input\" id=\"s-ics-url\" placeholder=\"https://calendar.google.com/calendar/ical/...\"></div>\n <button class=\"btn-full\" onclick=\"saveICSUrl()\">Save URL & Sync</button>\n </div>\n <div id=\"ics-step2\" style=\"${s.icsUrl?'':'display:none'}\">\n <p style=\"font-family:'DM Mono',monospace;font-size:11px;color:var(--muted);margin-bottom:12px;line-height:1.7\">Tap Sync to fetch your latest gigs from Google Calendar.</p>\n <button class=\"btn-full\" onclick=\"syncNow()\">\u27f3 Sync Now</button>\n <div id=\"ics-status\" style=\"margin-top:10px\"></div>\n <div id=\"ics-fallback\" style=\"display:none;margin-top:14px\">\n <p style=\"font-family:'DM Mono',monospace;font-size:11px;color:var(--gold);margin-bottom:10px\">Sync failed. Check your internet connection and try again.</p>\n <textarea class=\"form-input\" id=\"s-ics-paste\" rows=\"5\" placeholder=\"BEGIN:VCALENDAR...\"></textarea>\n <button class=\"btn-full\" onclick=\"syncFromPaste()\" style=\"margin-top:8px\">Import Pasted</button>\n </div>\n <button class=\"btn-full secondary\" onclick=\"resetICSUrl()\" style=\"margin-top:8px\">Change URL</button>\n </div>\n </div>\n <p class=\"section-label\">Known Venues</p>\n <div class=\"card\">\n ${VENUES.map(v=><span class="venue-tag">${escH(v.name)}).join('')}\n <p style=\"margin-top:10px;font-family:'DM Mono',monospace;font-size:11px;color:var(--muted)\">Auto-matched. All venue links are direct <a> tags \u2014 Safari-safe.</p>\n </div>\n <p class=\"section-label\">Data</p>\n <div class=\"card\"><button class=\"btn-full danger\" onclick=\"clearAllData()\">Reset All Data</button></div>\n ;\n}\n\nfunction saveSetupForm(){\n const s=loadSetup();\n s.artist=document.getElementById('s-artist').value.trim();\n s.lineup=document.getElementById('s-lineup').value.trim();\n s.ticketLink=document.getElementById('s-ticketlink').value.trim();\n saveSetup(s);toast('Saved');\n}\n\nfunction saveICSUrl(){\n let url=(document.getElementById('s-ics-url')||{}).value||'';\n url=url.trim().replace(/^webcal:\/\//i,'https://');\n if(!url)return toast('Paste your iCal URL first');\n const s=loadSetup();s.icsUrl=url;saveSetup(s);\n renderSetup();syncNow();\n}\nfunction resetICSUrl(){const s=loadSetup();delete s.icsUrl;saveSetup(s);renderSetup();}\n\n// \u2500\u2500 ICS SYNC \u2500\u2500\nconst PROXY_URL='https://alephaguiar--190272742bbc11f1b68842dde27851f2.web.val.run';\n\nasync function syncNow(){\n setICSStatus('\u27f3 Syncing\u2026','loading');\n try{\n const res=await fetch(PROXY_URL,{cache:'no-store'});\n const text=await res.text();\n if(!text.includes('BEGIN:VCALENDAR'))throw new Error('Invalid calendar data');\n const added=importICSText(text);\n setICSStatus(\u2713 Synced \u2014 ${added} new gig${added!==1?'s':''} imported,'ok');\n renderGigs();renderPromo();renderInvite();\n toast(Synced! ${added} new gig${added!==1?'s':''} added);\n hideFallback();\n }catch(e){\n setICSStatus('\u2715 Sync failed: '+e.message,'err');\n showFallback();\n }\n}\n\nfunction syncFromPaste(){\n const raw=(document.getElementById('s-ics-paste')||{}).value||'';\n if(raw.trim().startsWith('http')){\n toast('That is the URL \u2014 open it in Safari, Select All, Copy, then paste here');\n return;\n }\n if(!raw.includes('BEGIN:VCALENDAR'))return toast('Paste full .ics content (starts with BEGIN:VCALENDAR)');\n const added=importICSText(raw);\n setICSStatus(\u2713 ${added} gig${added!==1?'s':''} imported,'ok');\n renderGigs();renderPromo();renderInvite();hideFallback();\n toast(${added} gig${added!==1?'s':''} imported);\n}\n\nfunction setICSStatus(msg,type){const el=document.getElementById('ics-status');if(el)el.innerHTML=<span class=\"ics-status ${type}\">${msg}</span>;}\nfunction showFallback(){const el=document.getElementById('ics-fallback');if(el)el.style.display='block';}\nfunction hideFallback(){const el=document.getElementById('ics-fallback');if(el)el.style.display='none';}\n\nfunction importICSText(raw){\n const text=raw.replace(/\r\n[ \t]/g,'').replace(/\n[ \t]/g,'');\n const blocks=text.split('BEGIN:VEVENT');blocks.shift();\n const gigs=loadGigs();\n const existUIDs=new Set(gigs.map(g=>g.icsUid).filter(Boolean));\n let added=0;\n blocks.forEach(block=>{\n const lines=block.replace(/\r/g,'').split('\n');\n const getF=key=>{const l=lines.find(x=>x.startsWith(key+':')||x.startsWith(key+';'));return l?l.slice(l.indexOf(':')+1).replace(/\\,/g,',').replace(/\\n/g,'\n').trim():'';};\n const getD=key=>{const l=lines.find(x=>x.startsWith(key+':')||x.startsWith(key+';'));if(!l)return'';const v=l.slice(l.indexOf(':')+1).trim();return/^\d{8}/.test(v)?${v.slice(0,4)}-${v.slice(4,6)}-${v.slice(6,8)}:'';}\n const uid=getF('UID'),title=getF('SUMMARY'),location=getF('LOCATION'),date=getD('DTSTART');\n const timeMatch=block.match(/DTSTART[^:]:(\d{8}T\d{6})/);\n const time=timeMatch?${timeMatch[1].slice(9,11)}:${timeMatch[1].slice(11,13)}:'';\n if(!title||!date||date<today())return;\n if(uid&&existUIDs.has(uid))return;\n const v=matchVenue(location+' '+title);\n gigs.push({id:uid?String(Math.abs(uid.split('').reduce((h,c)=>((h<<5)-h)+c.charCodeAt(0),0))):Date.now().toString()+Math.random(),icsUid:uid||null,title,date,time,venue:location||'',city:v?v.city:'',capacity:0,sold:0,ticketUrl:'',state:'free'});\n added++;\n });\n saveGigs(gigs);return added;\n}\n\n// \u2500\u2500 MODALS \u2500\u2500\nfunction openModal(id){document.getElementById(id).classList.add('active');}\nfunction closeModal(id){document.getElementById(id).classList.remove('active');}\ndocument.querySelectorAll('.modal-overlay').forEach(o=>{o.addEventListener('click',e=>{if(e.target===o)o.classList.remove('active');});});\n\nfunction clearAllData(){if(!confirm('Reset ALL data?'))return;['aleph_gigs','aleph_setup','aleph_contacts'].forEach(k=>localStorage.removeItem(k));renderPromo();renderGigs();renderInvite();renderSetup();toast('Cleared');}\n\n// \u2500\u2500 INIT \u2500\u2500\nrenderPromo();\nrenderGigs();\n\n\n\n";
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" }});
}