PRO
. We escape the < to \u003c.
* Forward slashes get escaped too as a defense-in-depth measure.
*/
function escapeForScriptTag(jsonStr){
return jsonStr
.replace(/\u003c/g, '\\u003c')
.replace(/\u2028/g, '\\u2028') // line separator (breaks JS literals)
.replace(/\u2029/g, '\\u2029'); // paragraph separator
}
/**
* Wires drag-reorder onto an existing list of rows.
* - container: the parent that holds the rows
* - rowSelector: e.g. '.list-item', '.photo-thumb' — children of container
* - getArray: () => the underlying array (e.g. () => state.epk.pressQuotes)
* - onChange: () => called after reorder; should re-render UI + preview + persist
*
* Adds a small drag handle to each row (caller must include `.drag-grip` element
* inside the row OR set draggable=true on the whole row).
*/
function wireReorder(container, rowSelector, getArray, onChange){
let dragIdx = null;
container.querySelectorAll(rowSelector).forEach((row, i) => {
row.draggable = true;
row.addEventListener('dragstart', e => {
dragIdx = i;
row.style.opacity = '.4';
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
dragIdx = null;
row.style.opacity = '';
container.querySelectorAll(rowSelector).forEach(r => r.style.outline = '');
});
row.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if(dragIdx !== null && dragIdx !== i) row.style.outline = '2px solid var(--lh-lime)';
});
row.addEventListener('dragleave', () => { row.style.outline = ''; });
row.addEventListener('drop', e => {
e.preventDefault();
row.style.outline = '';
if(dragIdx === null || dragIdx === i) return;
const arr = getArray();
const [moved] = arr.splice(dragIdx, 1);
arr.splice(i, 0, moved);
onChange();
});
});
}
/* ════════════════════════════════════════════════════════════════════
WIRING
════════════════════════════════════════════════════════════════════ */
document.getElementById('editor-tabs').addEventListener('click', e => {
const b = e.target.closest('button[data-section]');
if(b) setActiveSection(b.dataset.section);
});
document.querySelector('.preview-modes').addEventListener('click', e => {
const b = e.target.closest('button[data-device]');
if(b){
state.device = b.dataset.device;
document.querySelectorAll('.preview-modes button[data-device]').forEach(x => x.classList.toggle('active', x===b));
renderPreview();
return;
}
if(e.target.closest('#btn-compact')){
state.compactMode = !state.compactMode;
document.getElementById('btn-compact').classList.toggle('active', state.compactMode);
renderPreview();
return;
}
if(e.target.closest('#btn-print-preview')){
state.printPreview = !state.printPreview;
document.getElementById('btn-print-preview').classList.toggle('active', state.printPreview);
// Force device to desktop in print preview (paper is paper)
if(state.printPreview){
state.device = 'desktop';
document.querySelectorAll('.preview-modes button[data-device]').forEach(x => x.classList.toggle('active', x.dataset.device === 'desktop'));
}
renderPreview();
}
});
document.getElementById('btn-new-slot').onclick = newSlot;
document.getElementById('btn-help').onclick = showOnboarding;
document.getElementById('btn-shortcuts').onclick = showShortcuts;
document.getElementById('btn-feedback').onclick = () => window.open('mailto:gonz@lahacienda.media?subject=StageKit%20Feedback&body=Hi%20Gonz%2C%0A%0AHere%27s%20my%20feedback%20on%20StageKit%3A%0A%0A', '_blank');
document.getElementById('btn-signup').onclick = () => showSignupModal();
document.getElementById('btn-gopro').onclick = () => showUpgrade('native-pdf');
document.getElementById('btn-publish').onclick = () => showPublishModal();
document.getElementById('btn-analytics').onclick = () => showAnalyticsModal();
document.getElementById('btn-history').onclick = showHistory;
document.getElementById('btn-import').onclick = showImport;
document.getElementById('btn-quickfill').onclick = showQuickFill;
document.getElementById('toggle-aside').onclick = () => {
// Desktop: collapse class on aside; mobile: show-aside class on body
if(window.innerWidth <= 720){
document.body.classList.toggle('show-aside');
} else {
document.getElementById('aside').classList.toggle('collapsed');
}
};
// Mobile editor/preview switcher
document.getElementById('mobile-switch').onclick = () => {
const inPreview = document.body.classList.toggle('preview-mode');
document.getElementById('mobile-switch-label').textContent = inPreview ? 'Editor' : 'Preview';
};
/* Fix C — swipe left/right to toggle editor↔preview on mobile */
(function(){
let _tx = 0, _ty = 0;
const app = document.getElementById('app');
app.addEventListener('touchstart', e => {
_tx = e.touches[0].clientX;
_ty = e.touches[0].clientY;
}, { passive: true });
app.addEventListener('touchend', e => {
if(window.innerWidth > 720) return;
const dx = e.changedTouches[0].clientX - _tx;
const dy = e.changedTouches[0].clientY - _ty;
if(Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5){
const inPreview = document.body.classList.toggle('preview-mode');
document.getElementById('mobile-switch-label').textContent = inPreview ? 'Editor' : 'Preview';
}
}, { passive: true });
})();
/* Fix D — tap outside aside overlay to close it on mobile */
document.addEventListener('click', e => {
if(window.innerWidth > 720) return;
const aside = document.getElementById('aside');
if(document.body.classList.contains('show-aside') && !aside.contains(e.target) && e.target.id !== 'toggle-aside'){
document.body.classList.remove('show-aside');
}
});
document.getElementById('btn-print').onclick = printPdf;
document.getElementById('btn-export-html').onclick = exportHtml;
document.getElementById('btn-export-pdf').onclick = handleExportPdf;
function handleExportPdf(){
if(!isPro()){ showUpgrade('native-pdf'); return; }
const slot = activeSlot();
const info = slot && getPublished(slot.id);
if(!info){
toast('Publish the EPK first — native PDF renders the live page', 'error');
return;
}
toast('Rendering PDF…');
API.pdf({ slug: info.slug, editToken: info.editToken, html: buildExportHtml().html, format: 'a4' })
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${info.slug}.pdf`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
toast('PDF downloaded');
})
.catch(err => toast(`PDF failed: ${err.message}`, 'error'));
}
const PRO_FEATURE_LABELS = {
'native-pdf': 'Native PDF export',
'no-footer': 'Hide the "Built with StageKit" footer',
'unlimited-slots': 'Unlimited EPK slots',
'custom-domain': 'Custom domain for published EPKs',
};
/* Upgrade modal — the real Pro purchase flow.
- "Go Pro" → Stripe Checkout (one-time). On return, #pro-success unlocks.
- "Enter a license key" → validate + unlock immediately (post-purchase,
or activating Pro on a second device).
- "Email me my key" → recovery for a buyer who lost their key.
If billing isn't configured yet (no Stripe keys on the backend), the buy
button gracefully falls back to the waitlist capture. */
function showUpgrade(featureKey){
const label = PRO_FEATURE_LABELS[featureKey] || 'This';
const slots = state.workspace.slots.length;
const capLine = featureKey === 'unlimited-slots'
? `You're at ${slots} of ${FREE_SLOT_CAP} free EPKs. `
: '';
openModal('Upgrade to StageKit Pro',
`${capLine}${escapeHtml(label)} is part of StageKit Pro.
One-time purchase — yours forever. Unlocks native PDF export, footer removal, unlimited EPKs, and custom domains.
`,
`
`
);
const status = () => document.getElementById('up-status');
document.getElementById('up-cancel').onclick = closeModal;
// Toggle the key-entry row; the primary button becomes "Activate".
document.getElementById('up-show-key').onclick = () => {
const row = document.getElementById('up-key-row');
const show = row.style.display === 'none';
row.style.display = show ? 'block' : 'none';
document.getElementById('up-email-row').style.display = 'none';
const buy = document.getElementById('up-buy');
buy.textContent = show ? 'Activate' : 'Go Pro →';
buy.dataset.mode = show ? 'activate' : 'buy';
if(show) document.getElementById('up-key').focus();
};
// Toggle the recovery row.
document.getElementById('up-show-recover').onclick = () => {
const row = document.getElementById('up-email-row');
const show = row.style.display === 'none';
row.style.display = show ? 'block' : 'none';
document.getElementById('up-key-row').style.display = 'none';
const buy = document.getElementById('up-buy');
buy.textContent = show ? 'Send my key' : 'Go Pro →';
buy.dataset.mode = show ? 'recover' : 'buy';
if(show) document.getElementById('up-email').focus();
};
document.getElementById('up-buy').onclick = async (e) => {
const mode = e.currentTarget.dataset.mode || 'buy';
if(mode === 'activate') return doActivate(status);
if(mode === 'recover') return doRecover(status);
return startCheckout(featureKey, status);
};
}
async function startCheckout(featureKey, status){
status().innerHTML = `Opening checkout…`;
try{
const res = await API.checkout({});
if(res && res.url){ location.href = res.url; return; }
throw new Error('no-url');
}catch(err){
// 503 notConfigured (or any failure) → fall back to the waitlist.
showWaitlist(featureKey);
}
}
async function doActivate(status){
const key = (document.getElementById('up-key').value || '').trim().toUpperCase();
if(!/^SK-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(key)){
status().innerHTML = `That doesn't look like a StageKit key.`;
return;
}
status().innerHTML = `Checking…`;
try{
const r = await activateLicense(key);
if(r.ok){
status().innerHTML = `Pro unlocked. Thank you.`;
document.body.classList.add('is-pro');
renderAll();
setTimeout(closeModal, 900);
}else{
status().innerHTML = `${r.reason === 'not-found' ? 'Key not found.' : 'That key isn\'t active.'}`;
}
}catch(err){
status().innerHTML = `Couldn't reach the server. Try again.`;
}
}
async function doRecover(status){
const email = (document.getElementById('up-email').value || '').trim();
if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){
status().innerHTML = `Need the email you bought with.`;
return;
}
status().innerHTML = `Sending…`;
try{
await API.license({ email, recover: true });
status().innerHTML = `If that email bought Pro, your key is on the way.`;
}catch(err){
status().innerHTML = `Couldn't send. Try again.`;
}
}
/* Waitlist fallback — used when billing isn't live yet. Captures intent email
to the backend (survives cache clear) plus a local cache. */
function showWaitlist(featureKey){
const label = PRO_FEATURE_LABELS[featureKey] || 'Pro';
openModal('Pro is almost here',
`${escapeHtml(label)} is part of StageKit Pro — launching soon. Drop your email for early-access pricing.
`,
`
`
);
document.getElementById('pro-cancel').onclick = closeModal;
document.getElementById('pro-go').onclick = async () => {
const email = document.getElementById('pro-email').value.trim();
const status = document.getElementById('pro-status');
if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){
status.innerHTML = `Need a real email to notify you.`;
return;
}
setProIntentEmail(email);
status.innerHTML = `Saving…`;
try{ await API.intent({ email, feature: featureKey }); }catch{}
status.innerHTML = `Saved. We'll let you know.`;
setTimeout(closeModal, 800);
};
}
/* Stripe Checkout return handler — runs at boot. */
function handleProReturn(){
const h = location.hash || '';
if(h.startsWith('#pro-success')){
history.replaceState(null, '', location.pathname + location.search);
showProSuccess();
} else if(h.startsWith('#pro-cancel')){
history.replaceState(null, '', location.pathname + location.search);
toast('Checkout canceled — no charge made.');
}
}
/* Post-purchase: the webhook emailed the key. Let the buyer paste it to
unlock immediately. Reuses doActivate() (reads #up-key / #up-status). */
function showProSuccess(){
openModal('Payment complete 🎉',
`Thank you — you're a Pro now.
Your license key is on its way to your email. Paste it here to unlock right away:
`,
`
`
);
document.getElementById('ps-later').onclick = closeModal;
document.getElementById('ps-go').onclick = () => doActivate(() => document.getElementById('up-status'));
document.getElementById('up-key').focus();
}
document.getElementById('btn-vibe').onclick = openVibePicker;
document.getElementById('btn-export').onclick = () => openModal(
'Export EPK',
`Three ways to share or save this kit:
- Download HTML — a self-contained file. Email, host, share. Embeds work live. Re-importable: drop it back into StageKit to edit.
- Print to PDF — for press, bookers who want a static page.
- Backup workspace — JSON of all your EPKs, themes, and templates. Disaster recovery / move to a new machine.
Print/HTML use the current preview as-is.
`,
`
`
);
document.getElementById('modal-backdrop').addEventListener('click', e => {
if(e.target.id === 'm-print-clean'){
closeModal();
setTimeout(() => printPdf({ clean: true }), 80);
}
if(e.target.id === 'm-print'){
const coverPage = document.getElementById('opt-cover')?.checked;
const compact = document.getElementById('opt-compact')?.checked;
const defCompact = document.getElementById('opt-compact-default')?.checked;
if(defCompact !== undefined && defCompact !== !!state.workspace.settings?.defaultCompact){
state.workspace.settings = state.workspace.settings || {};
state.workspace.settings.defaultCompact = defCompact;
saveWorkspace(state.workspace);
}
if(compact !== undefined && compact !== state.compactMode){
state.compactMode = compact;
document.getElementById('btn-compact').classList.toggle('active', state.compactMode);
renderPreview();
}
closeModal();
setTimeout(() => printPdf({ coverPage }), 80);
}
if(e.target.id === 'm-html'){
const compact = document.getElementById('opt-compact')?.checked;
const defCompact = document.getElementById('opt-compact-default')?.checked;
if(defCompact !== undefined && defCompact !== !!state.workspace.settings?.defaultCompact){
state.workspace.settings = state.workspace.settings || {};
state.workspace.settings.defaultCompact = defCompact;
saveWorkspace(state.workspace);
}
if(compact !== undefined && compact !== state.compactMode){
state.compactMode = compact;
document.getElementById('btn-compact').classList.toggle('active', state.compactMode);
renderPreview();
}
closeModal();
exportHtml();
}
if(e.target.id === 'm-backup'){
closeModal();
exportWorkspaceBackup();
}
if(e.target.id === 'm-share'){
closeModal();
showShareKit();
}
});
document.getElementById('lang-select').value = currentLang;
document.getElementById('lang-select').addEventListener('change', e => {
currentLang = e.target.value;
try{ localStorage.setItem(LS_LANG, currentLang); }catch{}
applyI18n();
});
window.addEventListener('keydown', e => {
// Determine if focus is in an editable field — most shortcuts should NOT fire there
const t = e.target;
const inEditable = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
const meta = e.metaKey || e.ctrlKey;
// ── Always-on shortcuts (work even inside inputs) ──
if(e.key === 'Escape'){ closeModal(); return; }
// ── Cmd/Ctrl combos (work everywhere) ──
if(meta && e.key.toLowerCase() === 'k'){
e.preventDefault();
showQuickFill();
return;
}
if(meta && e.key.toLowerCase() === 'b'){
e.preventDefault();
document.getElementById('aside').classList.toggle('collapsed');
return;
}
if(meta && e.key.toLowerCase() === 'n'){
e.preventDefault();
newSlot();
return;
}
if(meta && e.key.toLowerCase() === 's'){
e.preventDefault();
persistAll();
toast('Saved');
return;
}
if(meta && e.key.toLowerCase() === 'e'){
e.preventDefault();
document.getElementById('btn-export').click();
return;
}
if(meta && e.key.toLowerCase() === 'p'){
e.preventDefault();
printPdf({coverPage: !!(state.epk.coverPhoto && state.epk.artistName)});
return;
}
// ── Single-key shortcuts (only fire OUTSIDE inputs) ──
if(inEditable) return;
// ? opens shortcuts overlay
if(e.key === '?'){
e.preventDefault();
showShortcuts();
return;
}
// 1-9 jumps to editor tabs
if(/^[1-9]$/.test(e.key)){
const tabs = document.querySelectorAll('#editor-tabs button[data-section]');
const idx = parseInt(e.key, 10) - 1;
if(tabs[idx]){
e.preventDefault();
setActiveSection(tabs[idx].dataset.section);
}
}
});
window.addEventListener('beforeunload', () => {
// Commit any pending soft-deletes — once the page is gone, undo is too
Object.keys(PENDING_DELETES).forEach(id => {
clearTimeout(PENDING_DELETES[id].timeoutId);
deleteEpkStorage(id);
});
persistAll();
});
document.addEventListener('visibilitychange', () => { if(document.visibilityState === 'hidden') persistAll(); });
/* ════════════════════════════════════════════════════════════════════
BOOT
════════════════════════════════════════════════════════════════════ */
/* ════════════════════════════════════════════════════════════════════
VERSION CHECK — show "what's new" toast when the app updates
────────────────────────────────────────────────────────────────────
The current version + a short changelog blurb live here. On boot, we
compare against the version stored in workspace.settings.lastSeenVersion
and toast the new bits if they differ. Skipped on first run (the
onboarding modal handles that case).
════════════════════════════════════════════════════════════════════ */
const APP_VERSION = '2.13.0';
const VERSION_HIGHLIGHT = "v2.13.0: StageKit Pro — unlock unlimited EPKs, native PDF, footer removal, and custom domains with a one-time purchase. Free tier now includes 3 EPKs.";
const LANDING_URL = 'https://stagekit.studio';
function checkVersionUpdate(){
const seen = state.workspace.settings?.lastSeenVersion;
if(seen === APP_VERSION) return; // up to date
// Skip the toast on truly-first-run; onboarding handles that
if(!seen && !localStorage.getItem(LS_FIRST_RUN)) {
state.workspace.settings = state.workspace.settings || {};
state.workspace.settings.lastSeenVersion = APP_VERSION;
saveWorkspace(state.workspace);
return;
}
// Real update — show the highlight, store the new version
state.workspace.settings = state.workspace.settings || {};
state.workspace.settings.lastSeenVersion = APP_VERSION;
saveWorkspace(state.workspace);
setTimeout(() => toast(VERSION_HIGHLIGHT, 'success', 6000), 800);
}
/* Bug fix #4: Sweep stranded EPK + version blobs left behind by failed
soft-delete commits. On mobile (especially Safari), beforeunload often
doesn't fire — so PENDING_DELETES never get committed and the data sits
in storage forever, eating quota. On every boot we reconcile: any
stagekit:epk:* or stagekit:versions:* key whose ID is NOT in the current
workspace's slot list is an orphan and gets removed. */
function sweepStrandedEpks(){
const liveIds = new Set(state.workspace.slots.map(s => s.id));
const toRemove = [];
try{
for(let i = 0; i < localStorage.length; i++){
const k = localStorage.key(i);
if(!k) continue;
let id = null;
if(k.startsWith(LS_EPK_PREFIX)) id = k.slice(LS_EPK_PREFIX.length);
else if(k.startsWith(LS_VERSIONS_PREFIX)) id = k.slice(LS_VERSIONS_PREFIX.length);
if(id && !liveIds.has(id)) toRemove.push(k);
}
}catch{}
toRemove.forEach(k => { try{ localStorage.removeItem(k); }catch{} });
if(toRemove.length){
console.info(`[StageKit] Cleaned ${toRemove.length} stranded storage key(s).`);
}
}
function boot(){
applyI18n();
// v2.9: inject Deck-specific CSS once at boot. Must run before any
// renderPreview() — the deck CSS classes reference these rules.
// Idempotent: re-entry on hot-reload is a no-op.
injectDeckStylesOnce();
// Sweep before loading so the storage usage indicator reflects truth
sweepStrandedEpks();
// Email recovery handoff (#recover=slug|editToken). When the user clicks a
// magic link from a different browser, recover.js redirects them here.
// Boot consumes the hash, hydrates a slot from the published page, and
// wires the editToken so re-publish updates the same URL.
if(typeof tryConsumeRecoveryHash === 'function'){
try{ tryConsumeRecoveryHash(); }catch(e){ console.error('recovery boot', e); }
}
// Stripe Checkout return — #pro-success= or #pro-cancel.
try{ handleProReturn(); }catch(e){ console.error('pro return', e); }
// Background license re-check (self-healing; non-blocking).
revalidateLicense();
state.epk = loadEpk(state.workspace.activeSlotId);
// Honor workspace-wide compact default
state.compactMode = !!(state.workspace.settings?.defaultCompact);
// First run: prepopulate with the demo template + show template picker
const isFirstRun = !localStorage.getItem(LS_FIRST_RUN);
if(isFirstRun){
try{ localStorage.setItem(LS_FIRST_RUN, '1'); }catch{}
// Replace the default empty slot with the demo template, but ONLY if the slot is truly empty.
if(!state.epk.artistName && !state.epk.bio && !state.epk.photos.length){
const demo = TEMPLATES.find(t => t.id === 'demo');
state.epk = demo.apply();
const slot = activeSlot();
slot.name = 'Sample EPK'; slot.type = demo.tag; slot.updatedAt = now();
persistAll();
}
// First-run users start at the current version
state.workspace.settings = state.workspace.settings || {};
state.workspace.settings.lastSeenVersion = APP_VERSION;
saveWorkspace(state.workspace);
}
renderAll();
updateKitMeter();
// Reflect compact state in the toolbar button
document.getElementById('btn-compact')?.classList.toggle('active', state.compactMode);
// Onboarding modal — once per browser, gated by stagekit:onboarding-seen
if(!localStorage.getItem(LS_ONBOARDING)){
setTimeout(showOnboarding, 400);
} else {
checkVersionUpdate();
// Resume missions if any are incomplete (user closed browser mid-guide)
setTimeout(() => {
const next = nextUncompletedMission();
if(next){ state.missionActive = true; state.currentMission = next.id; renderEditorBody(); }
}, 600);
}
}
boot();