import { firebaseAuth, signOut, onAuthStateChanged } from '/static/js/firebase.js';
import { setTheme, getCustomThemes, saveCustomThemes } from '/static/js/utilities/theme.js';
import { LOCAL_MODE } from '/static/js/utilities/constants.js';
// ── Back button: close drawer if embedded, navigate otherwise ──────────────
document.getElementById('accountBackBtn').addEventListener('click', () => {
if (window !== window.top) {
window.parent.closeAccountDrawer?.();
} else {
window.location.href = '/app';
}
});
const BUILTIN = new Set(['auto', 'light', 'dark']);
const COLOR_VARS = [
{ group: 'Accent', var: '--accent', label: 'Primary', defaults: { dark: '#e8ff47', light: '#525600' } },
{ group: 'Accent', var: '--accent2', label: 'Secondary', defaults: { dark: '#47c8ff', light: '#006eb5' } },
{ group: 'Base', var: '--bg', label: 'Background', defaults: { dark: '#0d0d0f', light: '#f3f4f7' } },
{ group: 'Base', var: '--surface', label: 'Surface', defaults: { dark: '#141417', light: '#ffffff' } },
{ group: 'Base', var: '--surface2', label: 'Surface 2', defaults: { dark: '#1c1c21', light: '#eeeef3' } },
{ group: 'Base', var: '--border', label: 'Border', defaults: { dark: '#2a2a32', light: '#dddde8' } },
{ group: 'Base', var: '--text', label: 'Text', defaults: { dark: '#e8e8ee', light: '#0d0d1a' } },
{ group: 'Base', var: '--muted', label: 'Muted', defaults: { dark: '#6b6b7a', light: '#606280' } },
{ group: 'Waveform', var: '--waveform', label: 'Unplayed', defaults: { dark: '#3a3a48', light: '#525600' } },
{ group: 'Waveform', var: '--waveform-progress', label: 'Played', defaults: { dark: '#e8ff47', light: '#c4c5d6' } },
{ group: 'Status', var: '--danger', label: 'Danger', defaults: { dark: '#ff7777', light: '#c41818' } },
{ group: 'Status', var: '--success', label: 'Success', defaults: { dark: '#47ff8a', light: '#1a7a42' } },
{ group: 'Status', var: '--rec', label: 'Recording', defaults: { dark: '#ff4444', light: '#d42020' } },
];
/**
* Resolves the active theme to 'light' or 'dark' (custom themes return their base).
* @returns {'light'|'dark'}
*/
function resolvedBase() {
const stored = localStorage.getItem('theme') || 'auto';
if (stored === 'auto') return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (stored === 'light') return 'light';
if (stored === 'dark') return 'dark';
return getCustomThemes().find(t => t.id === stored)?.base ?? 'dark';
}
/**
* Returns the current theme's saved color map.
* @returns {object} map of CSS variable names to color values
*/
function getCustomColors() {
const stored = localStorage.getItem('theme') || 'auto';
if (BUILTIN.has(stored)) {
try { return JSON.parse(localStorage.getItem('custom-colors-' + resolvedBase()) || '{}'); } catch { return {}; }
}
return { ...(getCustomThemes().find(t => t.id === stored)?.colors ?? {}) };
}
/**
* Persists `colors` to the right storage location for the active theme.
* @param {object} colors - map of CSS variable names to color values
*/
function saveCurrentColors(colors) {
const stored = localStorage.getItem('theme') || 'auto';
if (BUILTIN.has(stored)) {
localStorage.setItem('custom-colors-' + resolvedBase(), JSON.stringify(colors));
} else {
const themes = getCustomThemes();
const idx = themes.findIndex(t => t.id === stored);
if (idx >= 0) { themes[idx].colors = colors; saveCustomThemes(themes); }
}
syncThemeToServer();
}
// ── Tab switching ──────────────────────────────────────────────────────────
const tabs = document.querySelectorAll('.account-tab[data-tab]');
const panels = document.querySelectorAll('.account-panel');
/**
* Activates the named tab and its corresponding panel.
* @param {string} target - The tab identifier matching a data-tab attribute.
*/
function switchTab(target) {
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === target));
panels.forEach(p => p.classList.toggle('active', p.id === 'tab-' + target));
}
tabs.forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
// Switch to a tab specified in the URL (?tab=subscription) or via postMessage
const _urlTab = new URLSearchParams(window.location.search).get('tab');
if (_urlTab) switchTab(_urlTab);
window.addEventListener('message', (e) => {
if (e.data?.type === 'switch-tab' && e.data.tab) switchTab(e.data.tab);
});
// ── Logout ─────────────────────────────────────────────────────────────────
document.getElementById('accountLogoutBtn')?.addEventListener('click', async () => {
if (firebaseAuth) await signOut(firebaseAuth);
window.location.href = '/';
});
// ── Theme switching ─────────────────────────────────────────────────────────
/**
* Activates a theme and refreshes all theme-dependent UI.
* @param {string} themeId - The theme ID to activate.
*/
function activateTheme(themeId) {
setTheme(themeId);
updateThemeBtns();
renderCustomThemes();
renderColorVars();
updateColorSectionLabel();
syncThemeToServer();
}
const themeBtns = document.querySelectorAll('.theme-btn');
/** Syncs the active state of the built-in theme buttons with the stored preference. */
function updateThemeBtns() {
const current = localStorage.getItem('theme') || 'auto';
themeBtns.forEach(b => b.classList.toggle('active', b.dataset.themeValue === current));
}
themeBtns.forEach(btn => btn.addEventListener('click', () => activateTheme(btn.dataset.themeValue)));
updateThemeBtns();
// ── Custom theme list ───────────────────────────────────────────────────────
/** Re-renders the custom theme buttons in the theme selector group. */
function renderCustomThemes() {
const group = document.getElementById('themeBtnGroup');
const addBtn = document.getElementById('newThemeBtn');
const themes = getCustomThemes();
const current = localStorage.getItem('theme') || 'auto';
// Remove previously rendered custom theme elements
group.querySelectorAll('.theme-btn-custom').forEach(el => el.remove());
// Insert custom themes before the + button
themes.forEach(t => {
const wrap = document.createElement('span');
wrap.className = 'theme-btn-custom' + (t.id === current ? ' active' : '');
wrap.dataset.themeId = t.id;
const nameBtn = document.createElement('button');
nameBtn.className = 'theme-btn-custom__name';
nameBtn.textContent = t.name;
nameBtn.addEventListener('click', () => activateTheme(t.id));
wrap.append(nameBtn);
if (!t.bundled) {
const delBtn = document.createElement('button');
delBtn.className = 'theme-btn-custom__del';
delBtn.title = 'Delete theme';
delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
delBtn.addEventListener('click', e => { e.stopPropagation(); deleteCustomTheme(t.id); });
wrap.append(delBtn);
}
group.insertBefore(wrap, addBtn);
});
}
/**
* Deletes a custom theme by ID and falls back to 'dark' if it was active.
* @param {string} id - The ID of the custom theme to delete.
*/
function deleteCustomTheme(id) {
saveCustomThemes(getCustomThemes().filter(t => t.id !== id));
if ((localStorage.getItem('theme') || 'auto') === id) activateTheme('dark');
else { renderCustomThemes(); syncThemeToServer(); }
}
/**
* Creates a new custom theme based on the current color state and activates it.
* @param {string} name - Display name for the new theme.
*/
function createCustomTheme(name) {
const base = resolvedBase();
const saved = getCustomColors();
const colors = {};
COLOR_VARS.forEach(v => { colors[v.var] = saved[v.var] || v.defaults[base]; });
const theme = { id: String(Date.now()), name: name.trim(), base, colors };
const all = getCustomThemes();
all.push(theme);
saveCustomThemes(all);
activateTheme(theme.id);
}
// ── New theme form ──────────────────────────────────────────────────────────
document.getElementById('newThemeBtn').addEventListener('click', () => {
document.getElementById('newThemeForm').style.display = 'flex';
document.getElementById('newThemeName').focus();
});
document.getElementById('newThemeCancel').addEventListener('click', () => {
document.getElementById('newThemeForm').style.display = 'none';
document.getElementById('newThemeName').value = '';
});
document.getElementById('newThemeCreate').addEventListener('click', () => {
const name = document.getElementById('newThemeName').value.trim();
if (!name) return;
createCustomTheme(name);
document.getElementById('newThemeForm').style.display = 'none';
document.getElementById('newThemeName').value = '';
});
document.getElementById('newThemeName').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('newThemeCreate').click();
if (e.key === 'Escape') document.getElementById('newThemeCancel').click();
});
// ── Import / export ─────────────────────────────────────────────────────────
/**
* Triggers a browser download of the given theme object as a JSON file.
* @param {{name: string, base: string, colors: object}} themeObj - Theme data to export.
*/
function downloadThemeJson(themeObj) {
const json = JSON.stringify({ name: themeObj.name, base: themeObj.base, colors: themeObj.colors }, null, 2);
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
const a = Object.assign(document.createElement('a'), {
href: url, download: themeObj.name.replace(/[^a-z0-9]/gi, '-').toLowerCase() + '.json',
});
a.click();
URL.revokeObjectURL(url);
}
document.getElementById('exportThemeBtn').addEventListener('click', () => {
const stored = localStorage.getItem('theme') || 'auto';
if (!BUILTIN.has(stored)) {
const t = getCustomThemes().find(t => t.id === stored);
if (t) return downloadThemeJson(t);
}
// Built-in: snapshot all current colors (override or default)
const base = resolvedBase();
const saved = getCustomColors();
const colors = {};
COLOR_VARS.forEach(v => { colors[v.var] = saved[v.var] || v.defaults[base]; });
downloadThemeJson({ name: { auto: 'Auto', light: 'Light', dark: 'Dark' }[stored] ?? stored, base, colors });
});
document.getElementById('importThemeInput').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
if (!data.colors || typeof data.colors !== 'object') throw new Error();
const theme = {
id: String(Date.now()),
name: typeof data.name === 'string' && data.name ? data.name : 'Imported Theme',
base: data.base === 'light' ? 'light' : 'dark',
colors: data.colors,
};
const all = getCustomThemes();
all.push(theme);
saveCustomThemes(all);
activateTheme(theme.id);
} catch { alert('Invalid theme file.'); }
e.target.value = '';
};
reader.readAsText(file);
});
// ── Color customization ─────────────────────────────────────────────────────
/** Updates the color section heading to reflect the currently active theme name. */
function updateColorSectionLabel() {
const stored = localStorage.getItem('theme') || 'auto';
const name = BUILTIN.has(stored)
? ({ auto: 'Auto', light: 'Light', dark: 'Dark' }[stored])
: (getCustomThemes().find(t => t.id === stored)?.name ?? stored);
document.getElementById('colorSectionLabel').textContent = `Colors — ${name}`;
}
/** Renders the color variable editor rows grouped by category. */
function renderColorVars() {
const container = document.getElementById('colorVarsContainer');
const colors = getCustomColors();
const base = resolvedBase();
const groups = {};
COLOR_VARS.forEach(v => { (groups[v.group] ??= []).push(v); });
container.innerHTML = '';
for (const [groupName, vars] of Object.entries(groups)) {
const groupEl = document.createElement('div');
groupEl.className = 'settings-group';
const labelEl = document.createElement('div');
labelEl.className = 'settings-label';
labelEl.textContent = groupName;
groupEl.appendChild(labelEl);
const listEl = document.createElement('div');
listEl.className = 'color-var-list';
vars.forEach(v => {
const saved = colors[v.var];
const isCustom = !!saved;
const item = document.createElement('div');
item.className = 'color-var-item';
const swatch = document.createElement('input');
swatch.type = 'color';
swatch.className = 'color-swatch';
swatch.value = saved || v.defaults[base];
swatch.title = v.var;
const nameEl = document.createElement('span');
nameEl.className = 'color-var-label';
nameEl.textContent = v.label;
const resetBtn = document.createElement('button');
resetBtn.className = 'color-var-reset' + (isCustom ? ' color-var-reset--active' : '');
resetBtn.title = 'Reset to default';
resetBtn.textContent = '↺';
swatch.addEventListener('input', () => {
const c = getCustomColors();
c[v.var] = swatch.value;
saveCurrentColors(c);
document.documentElement.style.setProperty(v.var, swatch.value);
resetBtn.classList.add('color-var-reset--active');
});
resetBtn.addEventListener('click', () => {
const c = getCustomColors();
delete c[v.var];
saveCurrentColors(c);
document.documentElement.style.removeProperty(v.var);
swatch.value = v.defaults[resolvedBase()];
resetBtn.classList.remove('color-var-reset--active');
});
item.append(swatch, nameEl, resetBtn);
listEl.appendChild(item);
});
groupEl.appendChild(listEl);
container.appendChild(groupEl);
}
}
renderCustomThemes();
renderColorVars();
updateColorSectionLabel();
// ── Startup behavior ─────────────────────────────────────────────────────
/** Syncs the radio button selection with the stored preference. */
function updateStartupBehaviorBtns() {
const current = localStorage.getItem('startup-behavior') || 'last_project';
document.querySelectorAll('#startupBehaviorGroup input[type="radio"]').forEach(r => {
r.checked = r.value === current;
});
}
document.querySelectorAll('#startupBehaviorGroup input[type="radio"]').forEach(radio => {
radio.addEventListener('change', () => {
localStorage.setItem('startup-behavior', radio.value);
syncThemeToServer();
});
});
updateStartupBehaviorBtns();
// ── Autosave ──────────────────────────────────────────────────────────────
/** Syncs the autosave checkbox with the stored preference. */
function updateAutosaveToggle() {
const enabled = localStorage.getItem('autosave') !== 'false';
document.getElementById('autosaveToggle').checked = enabled;
}
document.getElementById('autosaveToggle').addEventListener('change', (e) => {
localStorage.setItem('autosave', e.target.checked ? 'true' : 'false');
syncThemeToServer();
});
updateAutosaveToggle();
// ── Undo queue size ───────────────────────────────────────────────────────
const UNDO_QUEUE_DEFAULT = 100;
/**
* Clamps an undo queue size value to the allowed range (10–500) in multiples of 10.
* @param {number} v - The raw value to clamp.
* @returns {number} The clamped value.
*/
function clampUndoQueueSize(v) {
return Math.min(500, Math.max(10, Math.round(v / 10) * 10));
}
/** Syncs the undo queue size input with the stored preference. */
function updateUndoQueueInput() {
const size = parseInt(localStorage.getItem('undo-queue-size') || UNDO_QUEUE_DEFAULT, 10);
document.getElementById('undoQueueSizeInput').value = clampUndoQueueSize(size);
}
document.getElementById('undoQueueSizeInput').addEventListener('change', (e) => {
const clamped = clampUndoQueueSize(parseInt(e.target.value, 10) || UNDO_QUEUE_DEFAULT);
e.target.value = clamped;
localStorage.setItem('undo-queue-size', clamped);
syncThemeToServer();
});
updateUndoQueueInput();
// ── Bundled themes (always synced from static/themes/ on load) ────────────
/**
* Fetches bundled themes from the server and merges any new or updated entries
* into localStorage. Called after restoreThemeFromPrefs so server prefs don't
* overwrite newly-added bundled themes.
* @returns {Promise<void>}
*/
async function loadBundledThemes() {
try {
const resp = await fetch('/api/themes');
if (!resp.ok) return;
const bundled = await resp.json();
const themes = getCustomThemes();
let changed = false;
for (const t of bundled) {
const bid = t.bundledId;
if (!bid) continue;
const idx = themes.findIndex(x => x.bundledId === bid);
if (idx >= 0) {
// Already present — just ensure the bundled flag is set
if (!themes[idx].bundled) { themes[idx].bundled = true; changed = true; }
} else {
// New file on server — add it
themes.push({
id: 'bundled:' + bid,
name: t.name || bid,
base: t.base === 'light' ? 'light' : 'dark',
colors: t.colors || {},
bundled: true,
bundledId: bid,
});
changed = true;
}
}
if (changed) {
saveCustomThemes(themes);
renderCustomThemes();
syncThemeToServer();
}
} catch {}
}
document.getElementById('resetAllColorsBtn').addEventListener('click', () => {
const stored = localStorage.getItem('theme') || 'auto';
if (BUILTIN.has(stored)) {
localStorage.removeItem('custom-colors-' + resolvedBase());
} else {
const themes = getCustomThemes();
const idx = themes.findIndex(t => t.id === stored);
if (idx >= 0) {
const base = themes[idx].base;
COLOR_VARS.forEach(v => { themes[idx].colors[v.var] = v.defaults[base]; });
saveCustomThemes(themes);
}
}
setTheme(stored);
renderColorVars();
syncThemeToServer();
});
// ── Theme preference sync ──────────────────────────────────────────────────
let currentPreferences = {};
/**
* Collects current theme state from localStorage into a preferences patch.
* @returns {{theme: string, custom_themes: Array, custom_colors_light: object, custom_colors_dark: object, startup_behavior: string}} Current preferences patch.
*/
function buildThemePrefs() {
const patch = { theme: localStorage.getItem('theme') || 'auto', custom_themes: getCustomThemes() };
try { patch.custom_colors_light = JSON.parse(localStorage.getItem('custom-colors-light') || '{}'); } catch { patch.custom_colors_light = {}; }
try { patch.custom_colors_dark = JSON.parse(localStorage.getItem('custom-colors-dark') || '{}'); } catch { patch.custom_colors_dark = {}; }
patch.startup_behavior = localStorage.getItem('startup-behavior') || 'last_project';
patch.autosave = localStorage.getItem('autosave') !== 'false';
patch.undo_queue_size = parseInt(localStorage.getItem('undo-queue-size') || UNDO_QUEUE_DEFAULT, 10);
return patch;
}
let _syncTimer = null;
/** Debounced (500 ms): merges current theme state into preferences and PUTs to server. */
function syncThemeToServer() {
clearTimeout(_syncTimer);
_syncTimer = setTimeout(async () => {
if (!LOCAL_MODE && !authToken) return;
currentPreferences = { ...currentPreferences, ...buildThemePrefs() };
const headers = { 'Content-Type': 'application/json' };
if (!LOCAL_MODE) headers['X-Auth-Token'] = authToken;
try {
await fetch('/api/users/me/preferences', {
method: 'PUT',
headers,
body: JSON.stringify({ preferences: currentPreferences }),
});
} catch {}
}, 500);
}
/**
* Restores theme state from saved preferences into localStorage then re-applies the theme.
* @param {{theme: string=, custom_themes: Array=, custom_colors_light: object=, custom_colors_dark: object=}} prefs - Saved user preferences object from the server.
*/
function restoreThemeFromPrefs(prefs) {
if (!prefs) return;
let changed = false;
if (prefs.custom_themes !== undefined) {
localStorage.setItem('custom-themes', JSON.stringify(prefs.custom_themes));
changed = true;
}
if (prefs.custom_colors_light !== undefined) {
if (Object.keys(prefs.custom_colors_light).length)
localStorage.setItem('custom-colors-light', JSON.stringify(prefs.custom_colors_light));
else
localStorage.removeItem('custom-colors-light');
changed = true;
}
if (prefs.custom_colors_dark !== undefined) {
if (Object.keys(prefs.custom_colors_dark).length)
localStorage.setItem('custom-colors-dark', JSON.stringify(prefs.custom_colors_dark));
else
localStorage.removeItem('custom-colors-dark');
changed = true;
}
if (prefs.startup_behavior) {
localStorage.setItem('startup-behavior', prefs.startup_behavior);
updateStartupBehaviorBtns();
}
if (prefs.autosave !== undefined) {
localStorage.setItem('autosave', prefs.autosave ? 'true' : 'false');
updateAutosaveToggle();
}
if (prefs.undo_queue_size !== undefined) {
localStorage.setItem('undo-queue-size', prefs.undo_queue_size);
updateUndoQueueInput();
}
if (prefs.theme) {
setTheme(prefs.theme);
updateThemeBtns();
renderCustomThemes();
renderColorVars();
updateColorSectionLabel();
} else if (changed) {
renderCustomThemes();
renderColorVars();
}
}
// ── Save status helper ─────────────────────────────────────────────────────
/**
* Briefly displays a save status message on the given element.
* @param {HTMLElement} el - The status element to update.
* @param {string} text - Message to display.
* @param {boolean} [isError=false] - Whether to style the message as an error.
*/
function showStatus(el, text, isError = false) {
el.textContent = text;
el.classList.toggle('save-status--error', isError);
el.classList.add('save-status--visible');
setTimeout(() => el.classList.remove('save-status--visible'), 2500);
}
// ── Auth & profile ─────────────────────────────────────────────────────────
let authToken = null;
// Fetch tiers immediately (public endpoint — no auth needed, server mode only)
if (!LOCAL_MODE) {
fetch('/api/subscription-tiers')
.then(r => r.json())
.then(tiers => { allTiers = tiers; renderTierCards(null); })
.catch(() => {});
}
if (LOCAL_MODE) {
// ── Local mode: no Firebase, load profile directly ──────────────────────
fetch('/api/users/me')
.then(r => r.json())
.then(user => {
currentPreferences = user.preferences || {};
restoreThemeFromPrefs(user.preferences);
loadBundledThemes();
const avatarEl = document.getElementById('profileAvatar');
const initials = (user.display_name || 'User')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
avatarEl.textContent = initials;
document.getElementById('profileName').value = user.display_name || '';
})
.catch(() => {});
} else if (!firebaseAuth) {
window.location.href = '/';
} else {
onAuthStateChanged(firebaseAuth, async (fbUser) => {
if (!fbUser) {
window.location.href = '/';
return;
}
authToken = await fbUser.getIdToken();
let user;
try {
const res = await fetch('/api/users/me', { headers: { 'X-Auth-Token': authToken } });
user = await res.json();
} catch {
return;
}
currentPreferences = user.preferences || {};
restoreThemeFromPrefs(user.preferences);
populateSurveyFields(user.preferences?.survey_answers);
loadBundledThemes();
// Avatar
const avatarEl = document.getElementById('profileAvatar');
if (fbUser.photoURL) {
const img = document.createElement('img');
img.src = fbUser.photoURL;
img.alt = 'Profile photo';
avatarEl.classList.add('profile-avatar--photo');
avatarEl.appendChild(img);
} else {
const initials = (user.display_name || user.email || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
avatarEl.textContent = initials;
}
// Profile fields
document.getElementById('profileName').value = user.display_name || '';
document.getElementById('profileEmail').value = user.email || '';
const providerLabels = {
firebase: 'Email / Password',
google: 'Google',
github: 'GitHub',
};
document.getElementById('profileProvider').textContent =
providerLabels[user.auth_provider] || user.auth_provider || '—';
document.getElementById('profileMemberSince').textContent = user.created_at
? new Date(user.created_at).toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric',
})
: '—';
// Subscription — apply the current tier and render the tier selector
const currentTier = user.subscription_tier || null;
currentTierId = currentTier?.id ?? null;
currentUsage = user.usage || {};
renderTierCards(currentTierId);
applyTier(currentTier, user.preferences?.subscription_since ?? null, currentUsage);
});
}
// ── Profile save ───────────────────────────────────────────────────────────
document.getElementById('profileSaveBtn').addEventListener('click', async () => {
if (!LOCAL_MODE && !authToken) return;
const name = document.getElementById('profileName').value.trim();
const btn = document.getElementById('profileSaveBtn');
const status = document.getElementById('profileSaveStatus');
btn.disabled = true;
try {
const headers = { 'Content-Type': 'application/json' };
if (!LOCAL_MODE) headers['X-Auth-Token'] = authToken;
const res = await fetch('/api/users/me', {
method: 'PUT',
headers,
body: JSON.stringify({ display_name: name }),
});
if (res.ok) {
showStatus(status, 'Saved!');
} else {
showStatus(status, 'Save failed.', true);
}
} catch {
showStatus(status, 'Save failed.', true);
} finally {
btn.disabled = false;
}
});
// ── Survey responses ───────────────────────────────────────────────────────
const _surveyFields = [
{ selectId: 'surveyProfession', otherId: 'surveyProfessionOther', key: 'profession' },
{ selectId: 'surveyUseCase', otherId: 'surveyUseCaseOther', key: 'use_case' },
{ selectId: 'surveySource', otherId: 'surveySourceOther', key: 'source' },
{ selectId: 'surveyOtherTools', otherId: 'surveyOtherToolsOther', key: 'other_tools' },
];
/** Wire up "Other" free-text reveal for all survey dropdowns. */
if (document.getElementById('surveyProfession')) {
_surveyFields.forEach(({ selectId, otherId }) => {
const sel = document.getElementById(selectId);
const input = document.getElementById(otherId);
sel.addEventListener('change', () => {
input.style.display = sel.value === '__other__' ? '' : 'none';
if (sel.value === '__other__') input.focus();
});
});
}
/**
* Populate the survey dropdowns from saved survey_answers preferences.
* @param {object} surveyAnswers - The preferences.survey_answers object.
*/
function populateSurveyFields(surveyAnswers) {
if (!surveyAnswers || !document.getElementById('surveyProfession')) return;
const knownOptions = {
profession: ['Journalist','Doctor / Physician','Nurse','Accountant','Lawyer','Content Editor','Researcher','Student'],
use_case: ['Editing interviews','Sharing transcriptions','Generating transcriptions','Creating captions & subtitles','Meeting notes','Academic research'],
source: ['Web search','Advertisement','Friend or colleague','Social media','Blog or article'],
other_tools: ['Rev','Otter.ai','Descript','Sonix','Trint','None — this is my first!'],
};
_surveyFields.forEach(({ selectId, otherId, key }) => {
const val = surveyAnswers[key] || '';
const sel = document.getElementById(selectId);
const input = document.getElementById(otherId);
if (knownOptions[key].includes(val)) {
sel.value = val;
input.style.display = 'none';
} else if (val) {
sel.value = '__other__';
input.value = val;
input.style.display = '';
} else {
sel.value = '';
input.style.display = 'none';
}
});
const emailCheck = document.getElementById('surveyEmailUpdates');
if (emailCheck) emailCheck.checked = !!surveyAnswers.email_updates;
}
if (document.getElementById('surveySaveBtn')) {
document.getElementById('surveySaveBtn').addEventListener('click', async () => {
if (!authToken) return;
const btn = document.getElementById('surveySaveBtn');
const status = document.getElementById('surveySaveStatus');
btn.disabled = true;
const surveyAnswers = {};
_surveyFields.forEach(({ selectId, otherId, key }) => {
const sel = document.getElementById(selectId);
if (sel.value === '__other__') {
const v = document.getElementById(otherId).value.trim();
if (v) surveyAnswers[key] = v;
} else if (sel.value) {
surveyAnswers[key] = sel.value;
}
});
surveyAnswers.email_updates = document.getElementById('surveyEmailUpdates').checked;
currentPreferences = { ...currentPreferences, survey_answers: surveyAnswers };
try {
const res = await fetch('/api/users/me/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': authToken },
body: JSON.stringify({ preferences: currentPreferences }),
});
if (res.ok) {
showStatus(status, 'Saved!');
} else {
showStatus(status, 'Save failed.', true);
}
} catch {
showStatus(status, 'Save failed.', true);
} finally {
btn.disabled = false;
}
});
}
// ── Subscription tiers ─────────────────────────────────────────────────────
let allTiers = [];
let billingMode = 'monthly';
let currentTierId = null;
let currentUsage = {};
/**
* Formats a minute value for display, returning 'Unlimited' for null.
* @param {number|null} mins - Minutes to format.
* @returns {string}
*/
function fmtMins(mins) {
if (mins == null) return 'Unlimited';
return mins >= 60 ? `${mins / 60} hr` : `${mins} min`;
}
/**
* Formats an hour value for display, returning 'Unlimited' for null.
* @param {number|null} hrs - Hours to format.
* @returns {string}
*/
function fmtHrs(hrs) { return hrs == null ? 'Unlimited' : `${hrs} hr`; }
/**
* Formats a gigabyte value for display, returning 'Unlimited' for null.
* @param {number|null} gb - Gigabytes to format.
* @returns {string}
*/
function fmtGb(gb) { return gb == null ? 'Unlimited' : `${gb} GB`; }
/**
* Populates the subscription info panel with the given tier's details.
* @param {object|null} tier - The active subscription tier object, or null.
* @param {string|null} [subscriptionSince=null] - ISO date string of when the subscription started.
* @param {object} [usage={}] - Current usage counters (e.g. transcription_mins, storage_bytes).
*/
function applyTier(tier, subscriptionSince = null, usage = {}) {
const f = tier?.features || {};
document.getElementById('planName').textContent = f.display_name || '—';
document.getElementById('planTagline').textContent = tier?.description || '';
document.getElementById('planBadge').textContent = f.display_name || '—';
// Transcription usage
const usedMins = parseFloat(usage.transcription_mins || 0);
const limitHrs = f.transcription_hrs_month ?? null;
const usedHrsDisplay = usedMins < 60
? `${Math.round(usedMins)} min`
: `${(usedMins / 60).toFixed(1)} hr`;
document.getElementById('usageTranscription').textContent =
`${usedHrsDisplay} / ${fmtHrs(limitHrs)}`;
const transcriptionPct = limitHrs != null
? Math.min(100, (usedMins / (limitHrs * 60)) * 100) : 0;
document.getElementById('usageTranscriptionBar').style.width = `${transcriptionPct}%`;
// Storage usage
const usedBytes = parseFloat(usage.storage_bytes || 0);
const limitGb = f.storage_gb ?? null;
const usedGb = usedBytes / (1024 ** 3);
const usedGbDisplay = usedGb < 1
? `${(usedBytes / (1024 ** 2)).toFixed(0)} MB`
: `${usedGb.toFixed(2)} GB`;
document.getElementById('usageStorage').textContent =
`${usedGbDisplay} / ${fmtGb(limitGb)}`;
const storagePct = limitGb != null
? Math.min(100, (usedGb / limitGb) * 100) : 0;
document.getElementById('usageStorageBar').style.width = `${storagePct}%`;
document.getElementById('allowanceAudioLen').textContent = fmtMins(f.max_audio_mins);
document.getElementById('allowanceTranscriptionLen').textContent = fmtMins(f.max_transcription_mins);
document.getElementById('allowanceWhisperModels').textContent = (f.whisper_models || []).join(', ') || '—';
document.getElementById('cancelPlanBtn').style.display = (f.price_usd_month > 0) ? '' : 'none';
const isPaid = f.price_usd_month > 0;
const periodLabels = { monthly: 'Monthly', yearly: 'Yearly' };
document.getElementById('billingPeriod').textContent =
(isPaid && tier) ? (periodLabels[tier.billing_period] || tier.billing_period) : '—';
if (isPaid && subscriptionSince && tier) {
const next = new Date(subscriptionSince);
if (tier.billing_period === 'yearly') next.setFullYear(next.getFullYear() + 1);
else next.setMonth(next.getMonth() + 1);
document.getElementById('billingNextPayment').textContent =
next.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
} else {
document.getElementById('billingNextPayment').textContent = '—';
}
updateTierCards(tier?.id);
}
/**
* Builds and renders the tier selection cards into the tier grid.
* @param {string|null} activeTierId - The ID of the currently active tier, or null.
*/
function renderTierCards(activeTierId) {
const grid = document.getElementById('tierGrid');
grid.innerHTML = '';
const visibleTiers = allTiers.filter(t => t.billing_period === billingMode);
visibleTiers.forEach((tier, i) => {
const f = tier.features || {};
const isCurrent = tier.id === activeTierId;
const isFeatured = i === 1;
const features = [
`${fmtMins(f.max_audio_mins)} max audio length`,
`${fmtMins(f.max_transcription_mins)} max transcription`,
`${fmtHrs(f.transcription_hrs_month)} transcription / month`,
`${fmtGb(f.storage_gb)} storage`,
`Whisper: ${(f.whisper_models || []).join(', ')}`,
];
let priceHtml;
if (f.price_usd_month == null) {
priceHtml = `<span class="tier-price-amount">Custom</span>`;
} else if (f.price_usd_month === 0) {
priceHtml = `<span class="tier-price-amount">$0</span><span class="tier-price-period">/mo</span>`;
} else if (tier.billing_period === 'yearly') {
const saving = (f.price_usd_month_base - f.price_usd_month).toFixed(2);
priceHtml = `
<span class="tier-price-amount">$${f.price_usd_year.toFixed(2)}</span><span class="tier-price-period">/yr</span>
<div class="tier-price-breakdown">$${f.price_usd_month.toFixed(2)}/mo · saves $${saving}/mo</div>`;
} else {
priceHtml = `<span class="tier-price-amount">$${f.price_usd_month}</span><span class="tier-price-period">/mo</span>`;
}
const card = document.createElement('div');
card.className = ['tier-card', isFeatured && 'tier-card--featured', isCurrent && 'tier-card--current']
.filter(Boolean).join(' ');
card.innerHTML = `
${isFeatured ? '<div class="tier-card-highlight">Most Popular</div>' : ''}
<div class="tier-name">${f.display_name || tier.name}</div>
<div class="tier-price">${priceHtml}</div>
<ul class="tier-features">${features.map(l => `<li>${l}</li>`).join('')}</ul>
<button class="btn tier-select-btn" data-tier-id="${tier.id}" ${isCurrent ? 'disabled' : ''}>
${isCurrent ? 'Current Plan' : 'Select'}
</button>`;
card.querySelector('.tier-select-btn').addEventListener('click', () => selectTier(tier));
grid.appendChild(card);
});
}
/**
* Updates the active/disabled state of existing tier cards without re-rendering.
* @param {string|null} currentTierId - The ID of the currently active tier.
*/
function updateTierCards(currentTierId) {
document.querySelectorAll('.tier-card').forEach(card => {
const btn = card.querySelector('.tier-select-btn');
const tierId = btn?.dataset.tierId;
const current = tierId === currentTierId;
card.classList.toggle('tier-card--current', current);
if (btn) { btn.textContent = current ? 'Current Plan' : 'Select'; btn.disabled = current; }
});
}
/**
* Sends a subscription update request for the selected tier.
* @param {object} tier - The tier object to subscribe to.
*/
async function selectTier(tier) {
if (!authToken) return;
const btn = document.querySelector(`.tier-select-btn[data-tier-id="${tier.id}"]`);
if (btn) btn.disabled = true;
try {
const res = await fetch('/api/users/me/subscription', {
method: 'PUT',
headers: { 'X-Auth-Token': authToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_id: tier.id }),
});
if (res.ok) {
currentTierId = tier.id;
applyTier(tier, new Date().toISOString(), currentUsage);
closeTierModal();
}
} finally {
if (btn) btn.disabled = false;
}
}
// ── Tier modal ─────────────────────────────────────────────────────────────
const tierModalOverlay = document.getElementById('tierModalOverlay');
/** Opens the tier selection modal. */
function openTierModal() { tierModalOverlay.style.display = 'flex'; }
/** Closes the tier selection modal. */
function closeTierModal() { tierModalOverlay.style.display = 'none'; }
document.querySelectorAll('.billing-btn').forEach(btn => {
btn.addEventListener('click', () => {
billingMode = btn.dataset.billing;
document.querySelectorAll('.billing-btn').forEach(b => b.classList.toggle('active', b === btn));
renderTierCards(currentTierId);
});
});
document.getElementById('upgradePlanBtn').addEventListener('click', openTierModal);
document.getElementById('tierModalClose').addEventListener('click', closeTierModal);
let _tierModalMouseDown = false;
tierModalOverlay.addEventListener('mousedown', (e) => { _tierModalMouseDown = e.target === tierModalOverlay; });
tierModalOverlay.addEventListener('click', e => { if (e.target === tierModalOverlay && _tierModalMouseDown) closeTierModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeTierModal(); });
// ── Delete account ─────────────────────────────────────────────────────────
document.getElementById('deleteAccountBtn')?.addEventListener('click', async () => {
if (!confirm(
'This will permanently delete your account and all associated data.\n\n' +
'This action cannot be undone. Are you sure?'
)) return;
if (!authToken) return;
try {
await fetch('/api/users/me', {
method: 'DELETE', headers: { 'X-Auth-Token': authToken },
});
} finally {
if (firebaseAuth) await signOut(firebaseAuth);
window.location.href = '/';
}
});