// user.jsx — Central logged-in-user model. // Every screen reads name + stats from here. // // TWO MODES, same public API: // • LIVE — config.js has real Supabase keys → reads/writes the `profiles` // table for the signed-in auth user. // • DEMO — no keys yet → local-only, deterministic per-handle stats so the // preview works instantly. (Great for design review / offline.) // // Public API (unchanged for the screens): // useUser() React hook → the current profile object // loadProfile() synchronous best-known profile (cache) // saveProfile(p) merge + persist (DB in live mode, localStorage in demo) // clearProfile() wipe local cache // signUp/signIn/signInWithProvider/signOut (live auth; no-ops in demo) // isConfigured() → boolean const { useState: useStateU, useEffect: useEffectU } = React; const USER_COLORS = ['#C72820', '#7A5AE0', '#CAFF33', '#FBBF24', '#22C55E', '#EC4899', '#06B6D4', '#F97316', '#A855F7']; function sb() { return window.LAUNCH_SUPABASE || null; } function isConfigured() { return !!window.LAUNCH_CONFIGURED && !!sb(); } // ── helpers ──────────────────────────────────────────────────────────────── function hashStr(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function seededRand(seed) { let s = seed % 233280; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } function pick(rand, lo, hi) { return Math.floor(lo + rand() * (hi - lo + 1)); } function nameFromHandle(handle) { return (handle || 'new.artist').split(/[.\s_]+/).filter(Boolean) .map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); } function initialsFrom(displayName, handle) { const src = (displayName || nameFromHandle(handle) || '').trim(); const parts = src.split(/\s+/).filter(Boolean); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); if (parts.length === 1 && parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase(); return (handle || 'NA').slice(0, 2).toUpperCase(); } function colorFor(handle) { return USER_COLORS[hashStr(handle || 'x') % USER_COLORS.length]; } const GENRE_BIO = { pop: "pop / hyperpop. big hooks, bigger feelings. writing for the front row 🎤", rb: "bedroom pop / soul. brooklyn. writing songs you'd play on a slow drive home 🌒", hiphop: "hip hop / rap. bars first, everything else after. studio rat 🎧", electronic: "electronic / house. late-night sound design, 4am bounces 🌌", indie: "indie / alt. fuzzy guitars and honest lyrics. diy forever 🎸", country: "country / americana. nashville. three chords and the truth 🤠", rock: "rock. loud amps, real drums, no autotune 🤘", jazz: "jazz / neo-soul. chords nobody else would dare. live takes only 🎹", }; const TIERS = [ { min: 0, tier: 'ROOKIE', sub: 'DAY ONE · WELCOME ABOARD' }, { min: 400, tier: 'IGNITION', sub: 'BUILDING MOMENTUM' }, { min: 1200, tier: 'RISING ARTIST', sub: 'KEEP COOKING' }, { min: 3000, tier: 'IN ORBIT', sub: 'THE SQUAD KNOWS YOUR NAME' }, { min: 6000, tier: 'HEADLINER', sub: 'STOPPING ROOMS' }, ]; function tierFor(xp) { return [...TIERS].reverse().find(t => xp >= t.min) || TIERS[0]; } function subForTier(tier) { return (TIERS.find(t => t.tier === tier) || TIERS[0]).sub; } // Canonical demo account — keeps curated content coherent in demo mode. const MAYA = { firstName: 'Maya', lastName: 'Stokes', displayName: 'Maya Stokes', handle: 'maya.sounds', initials: 'MS', color: '#C72820', genre: 'rb', experience: 'releasing', goal: 'release', tier: 'RISING ARTIST', tierSub: '11 MONTHS IN · KEEP COOKING', bio: GENRE_BIO.rb, streak: 37, dayOfPath: 37, pathLength: 90, xpThisWeek: 2640, xpEarnedToday: 847, reviewsPending: 3, sessionsThisWeek: 2, reviewsReceived: 12, sessionsTotal: 12, releases: 4, upcoming: 2, followers: 312, isNew: false, }; // Decorate a partial profile into the full shape every screen expects. function decorate(p) { const handle = (p.handle || 'new.artist').toLowerCase(); const displayName = (p.displayName || nameFromHandle(handle)).trim(); const tier = p.tier || tierFor(p.xpThisWeek || 0).tier; return { firstName: displayName.split(' ')[0], lastName: displayName.split(' ').slice(1).join(' '), displayName, handle, initials: p.initials || initialsFrom(displayName, handle), color: p.color || colorFor(handle), avatarUrl: avatarUrlFor(p.avatarUrl || p.avatar_path), genre: p.genre || 'pop', genres: (p.genres && p.genres.length) ? p.genres : [p.genre || 'pop'], experience: p.experience || 'starting', goal: p.goal || 'release', goals: (p.goals && p.goals.length) ? p.goals : [p.goal || 'release'], tier, tierSub: p.tierSub || subForTier(tier), bio: p.bio || GENRE_BIO[p.genre] || GENRE_BIO.pop, streak: num(p.streak, 1), dayOfPath: num(p.dayOfPath, 1), pathLength: num(p.pathLength, 90), xpThisWeek: num(p.xpThisWeek, 0), xpEarnedToday: num(p.xpEarnedToday, 0), reviewsPending: num(p.reviewsPending, 0), sessionsThisWeek: num(p.sessionsThisWeek, 0), reviewsReceived: num(p.reviewsReceived, 0), sessionsTotal: num(p.sessionsTotal, 0), releases: num(p.releases, 0), upcoming: num(p.upcoming, 0), followers: num(p.followers, 0), role: p.role || 'member', isNew: !!p.isNew, }; } function num(v, d) { return (v === null || v === undefined || isNaN(v)) ? d : Number(v); } // DEMO: build a deterministic, tailored profile from a handle. function buildProfile(raw) { const handle = (raw.handle || 'new.artist').toLowerCase(); if (handle === MAYA.handle) { // Allow overriding the canonical demo account's name/bio from edits, and // recompute the derived fields (firstName/lastName/initials) so the change // actually shows up instead of staying "Maya". const over = {}; if (raw.displayName) { over.displayName = raw.displayName; over.initials = undefined; } if (raw.bio) over.bio = raw.bio; if (raw.color) over.color = raw.color; if (raw.avatarUrl || raw.avatar_path) over.avatarUrl = raw.avatarUrl || raw.avatar_path; return decorate({ ...MAYA, ...over }); } const displayName = raw.displayName || nameFromHandle(handle); const seed = hashStr(handle); const rand = seededRand(seed); const isNew = !!raw.isNew; const xpThisWeek = isNew ? pick(rand, 0, 60) : pick(rand, 220, 3400); const t = tierFor(isNew ? 0 : xpThisWeek + pick(rand, 200, 2000)); return decorate({ handle, displayName, color: USER_COLORS[seed % USER_COLORS.length], avatarUrl: raw.avatarUrl || raw.avatar_path, genre: raw.genre || 'pop', experience: raw.experience || 'starting', goal: raw.goal || 'release', tier: isNew ? 'ROOKIE' : t.tier, tierSub: isNew ? 'DAY ONE · WELCOME ABOARD' : t.sub, bio: GENRE_BIO[raw.genre] || GENRE_BIO.pop, streak: isNew ? 1 : pick(rand, 2, 58), dayOfPath: isNew ? 1 : pick(rand, 2, 84), pathLength: 90, xpThisWeek, xpEarnedToday: isNew ? pick(rand, 0, 40) : pick(rand, 60, 900), reviewsPending: isNew ? 0 : pick(rand, 0, 5), sessionsThisWeek: isNew ? 0 : pick(rand, 0, 3), reviewsReceived: isNew ? 0 : pick(rand, 0, 18), sessionsTotal: isNew ? 0 : pick(rand, 0, 16), releases: isNew ? 0 : pick(rand, 0, 6), upcoming: isNew ? 0 : pick(rand, 0, 3), followers: isNew ? pick(rand, 0, 12) : pick(rand, 30, 900), isNew, }); } // LIVE: map a DB row (snake_case) → the profile shape. function mapRow(row) { return decorate({ handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_path, genre: row.genre, experience: row.experience, goal: row.goal, tier: row.tier, bio: row.bio, streak: row.streak, dayOfPath: row.day_of_path, pathLength: row.path_length, xpThisWeek: row.xp_this_week, xpEarnedToday: row.xp_today, reviewsPending: row.reviews_pending, sessionsThisWeek: row.sessions_this_week, reviewsReceived: row.reviews_received, sessionsTotal: row.sessions_total, releases: row.releases, upcoming: row.upcoming, followers: row.followers, role: row.role || 'member', isNew: (row.xp_this_week || 0) < 50, }); } // profile shape → DB columns (snake_case) for writes. function toRow(p) { const out = {}; const m = { handle: 'handle', displayName: 'display_name', genre: 'genre', experience: 'experience', goal: 'goal', tier: 'tier', bio: 'bio', streak: 'streak', dayOfPath: 'day_of_path', pathLength: 'path_length', xpThisWeek: 'xp_this_week', xpEarnedToday: 'xp_today', reviewsPending: 'reviews_pending', sessionsThisWeek: 'sessions_this_week', reviewsReceived: 'reviews_received', sessionsTotal: 'sessions_total', releases: 'releases', upcoming: 'upcoming', followers: 'followers', }; Object.keys(m).forEach(k => { if (p[k] !== undefined) out[m[k]] = p[k]; }); return out; } // ── cache (so synchronous loadProfile works in both modes) ─────────────────── const STORE_KEY = 'launch.profile'; let _cached = null; function loadProfile() { if (_cached) return _cached; try { const raw = localStorage.getItem(STORE_KEY); if (raw) { _cached = buildProfile(JSON.parse(raw)); return _cached; } } catch (e) {} _cached = { ...MAYA }; return _cached; } function emitChange() { try { window.dispatchEvent(new Event('launch.profilechange')); } catch (e) {} } // Local edits (display name / handle / bio / avatar color) saved by the user. // Applied on top of the DB row so changes always show even if a write was // blocked by RLS or there's no confirmed session yet. Cleared on sign out. function localOverride() { try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch (e) { return {}; } } // ── LIVE fetch ─────────────────────────────────────────────────────────────── async function fetchLiveProfile() { const client = sb(); if (!client) return null; const { data: { user } } = await client.auth.getUser(); if (!user) return null; const { data, error } = await client.from('profiles').select('*').eq('id', user.id).single(); const ov = localOverride(); const hasOv = ov && Object.keys(ov).length > 0; if (error || !data) { // No row yet — still personalize from any local edits. if (hasOv) { _cached = buildProfile(ov); return _cached; } return null; } const row = mapRow(data); if (hasOv) { const merged = { ...row, ...ov }; if (ov.displayName) merged.initials = undefined; // recompute from new name _cached = decorate(merged); } else { _cached = row; } return _cached; } // ── public: saveProfile ─────────────────────────────────────────────────────── async function saveProfile(partial) { // Always record the edit locally first, so the change shows immediately and // survives a remount no matter the backend state (demo, RLS, unconfirmed // email…). fetchLiveProfile layers this on top of the DB row. let merged = {}; try { const existing = (() => { try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch (e) { return {}; } })(); merged = { ...existing, ...partial }; localStorage.setItem(STORE_KEY, JSON.stringify(merged)); if (merged.handle) localStorage.setItem('launch.handle', merged.handle); } catch (e) {} if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (user) { await client.from('profiles').update(toRow(partial)).eq('id', user.id); await fetchLiveProfile(); // merges the local edit on top of the row emitChange(); return _cached; } } catch (e) { console.warn('[LAUNCH] saveProfile (live) failed:', e); } } // demo mode (or not signed in yet): build from the merged local profile _cached = buildProfile(merged); emitChange(); return _cached; } function clearProfile() { _cached = null; try { localStorage.removeItem(STORE_KEY); } catch (e) {} emitChange(); } // ── public: auth (live). In demo mode these just persist locally. ───────────── async function signUp({ email, password, handle, displayName }) { if (isConfigured()) { const client = sb(); const { data, error } = await client.auth.signUp({ email, password, options: { data: { handle: (handle || '').toLowerCase(), display_name: displayName || '' } }, }); if (error) return { error }; // If email confirmation is OFF, a session exists now and the trigger made the row. if (data.session) { await fetchLiveProfile(); emitChange(); } return { data, needsConfirmation: !data.session }; } await saveProfile({ handle, displayName, isNew: true }); try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {} return { data: { demo: true } }; } async function signIn({ email, password }) { if (isConfigured()) { const client = sb(); const { data, error } = await client.auth.signInWithPassword({ email, password }); if (error) return { error }; await fetchLiveProfile(); emitChange(); return { data }; } try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {} return { data: { demo: true } }; } async function signInWithProvider(provider) { if (isConfigured()) { // OAuth can only redirect back to an http(s) origin that's whitelisted in // Supabase. Opening the app from a file:// URL (double-clicking index.html) // can never complete the round-trip, so fail with a clear message instead // of bouncing the browser to a dead end. if (window.location.protocol === 'file:') { return { error: { message: 'OAuth needs the app served over http. Open it with Live Server (http://localhost:5500), not by double-clicking the file.' } }; } const client = sb(); // Use origin + path (no hash/query) so it matches the Redirect URL you add // in Supabase → Authentication → URL Configuration. const redirectTo = window.location.origin + window.location.pathname; // Leave a breadcrumb so that when the browser lands back on the app after // the provider round-trip, app.jsx knows to finish sign-in (seed profile, // skip onboarding) instead of showing the welcome flow again. try { localStorage.setItem('launch.oauthPending', provider); } catch (e) {} const { data, error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo } }); if (error) { try { localStorage.removeItem('launch.oauthPending'); } catch (e) {} const enableHint = ' — make sure the "' + provider + '" provider is enabled in Supabase → Authentication → Providers, and that ' + redirectTo + ' is listed under URL Configuration → Redirect URLs.'; return { error: { message: (error.message || 'OAuth failed') + enableHint } }; } return { data }; } try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {} return { data: { demo: true } }; } // Call once on app load after returning from a provider (Google/Apple) redirect. // The Supabase client parses the OAuth tokens out of the URL during init, so a // session is available shortly after. We confirm it, make sure the provider's // real name lands on the auto-created profile row, and report success so the // shell can drop the user straight into the app. async function completeOAuthSignIn() { if (!isConfigured()) return { signedIn: false }; const client = sb(); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); let session = null; for (let i = 0; i < 8 && !session; i++) { try { const r = await client.auth.getSession(); session = r.data.session; } catch (e) {} if (!session) await wait(250); } if (!session || !session.user) return { signedIn: false }; const meta = session.user.user_metadata || {}; // The DB trigger already created a profiles row (handle derived from the // email). Providers expose the display name under full_name/name — push it // onto the row so the profile shows the real name, not the email handle. const realName = meta.full_name || meta.name || meta.display_name || ''; if (realName) { await saveProfile({ displayName: String(realName).trim() }); } else { await fetchLiveProfile(); } try { localStorage.setItem('launch.signedIn', '1'); } catch (e) {} emitChange(); return { signedIn: true }; } async function signOut() { if (isConfigured()) { try { await sb().auth.signOut(); } catch (e) {} } clearProfile(); _pointsCache = null; _planCache = null; try { localStorage.removeItem('launch.signedIn'); localStorage.removeItem('launch.handle'); localStorage.removeItem('launch.points'); localStorage.removeItem('launch.plan'); localStorage.removeItem('launch.benefitusage'); sessionStorage.removeItem('launch.appOpened'); } catch (e) {} } // ── public: permanently delete the signed-in user's account ────────────────── // Irreversible. Verifies the password by re-authenticating, then calls the // `delete_account` RPC (SECURITY DEFINER — see supabase/delete-account.sql), // which removes the auth.users row and cascades EVERY table that references it // (profiles → submissions → evaluations, habit_logs, posts, follows, points, // messages, tracks, song_agreements, subscriptions, bookings, credits) plus the // user's stored files. Finally clears the local session. // Returns { ok:true } | { error }. // Which login methods the current user has. { hasPassword, providers }. // Used by the delete-account sheet to decide whether to ask for a password // (email/password accounts) or a typed "DELETE" (OAuth-only, e.g. Google). async function authProviders() { if (!isConfigured()) return { hasPassword: true, providers: ['email'] }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); const ids = (user && user.identities) || []; const providers = ids.map(i => i.provider); return { hasPassword: providers.includes('email'), providers }; } catch (e) { return { hasPassword: true, providers: [] }; } } async function deleteAccount(password) { if (!isConfigured()) { // Demo mode: no backend — just clear local state so the UI returns to login. await signOut(); return { ok: true, demo: true }; } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user || !user.email) return { error: 'You are not signed in.' }; // Email/password accounts confirm with their password (re-authenticating // validates it without changing anything). OAuth-only accounts (e.g. Google) // have NO password, so the client confirms with a typed "DELETE" instead and // we skip the password re-auth here. const hasPassword = ((user.identities) || []).some(i => i.provider === 'email'); if (hasPassword) { if (!password) return { error: 'Enter your password to confirm.' }; const { error: pwErr } = await client.auth.signInWithPassword({ email: user.email, password }); if (pwErr) return { error: 'Incorrect password. Please try again.' }; } // Remove the user's stored files first. Supabase blocks direct SQL deletes on // storage.objects, so the delete_account RPC no longer touches storage — we // clean the files here via the Storage API (owner-scoped). Best-effort: any // bucket without a delete policy just leaves harmless orphaned files. try { const uid = user.id; for (const bucket of ['submissions', 'tracks', 'avatars', 'eval-voice-notes']) { try { const { data: files } = await client.storage.from(bucket).list(uid, { limit: 100 }); if (files && files.length) { await client.storage.from(bucket).remove(files.map(f => `${uid}/${f.name}`)); } } catch (e) { /* ignore — orphaned files are harmless */ } } } catch (e) { /* ignore */ } // Wipe the account + all data server-side. const { error: delErr } = await client.rpc('delete_account'); if (delErr) return { error: delErr.message || 'Could not delete your account.' }; // Drop the local session + caches. await signOut(); return { ok: true }; } catch (e) { return { error: String((e && e.message) || e) }; } } // ── hook ────────────────────────────────────────────────────────────────────── function useUser() { const [user, setUser] = useStateU(loadProfile); useEffectU(() => { let alive = true; const refresh = () => { if (alive) setUser(loadProfile()); }; // In live mode, pull the real row + subscribe to auth changes. if (isConfigured()) { fetchLiveProfile().then(p => { if (alive && p) setUser(p); }); const { data: sub } = sb().auth.onAuthStateChange(() => { fetchLiveProfile().then(p => { if (alive && p) setUser(p); }); }); window.addEventListener('launch.profilechange', refresh); return () => { alive = false; window.removeEventListener('launch.profilechange', refresh); try { sub.subscription.unsubscribe(); } catch (e) {} }; } // Demo mode: react to local changes. window.addEventListener('launch.profilechange', refresh); window.addEventListener('storage', refresh); return () => { alive = false; window.removeEventListener('launch.profilechange', refresh); window.removeEventListener('storage', refresh); }; }, []); return user; } // ── public: live leaderboard ───────────────────────────────────────────────── // Returns competitors ranked by points earned in `week` ('current'|'previous'). // Null in demo mode (caller uses its own seeded list). // Each row: { handle, initials, color, xp, streak, you }. async function fetchLeaderboard(limit = 20, week = 'current') { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); // Preferred: REAL weekly ranking (weekly-leaderboard.sql RPC). const wk = await client.rpc('weekly_leaderboard', { p_week: week, p_limit: limit }); if (!wk.error && wk.data) { return wk.data.map(r => ({ handle: r.handle, initials: initialsFrom(r.display_name, r.handle), color: colorFor(r.handle), genre: r.genre || '', xp: num(r.earned, 0), streak: num(r.streak, 0), delta: '', you: user && r.id === user.id, })); } // Fallback: all-time points ranking (leaderboard.sql RPC). const rpc = await client.rpc('leaderboard_points', { p_limit: limit }); if (!rpc.error && rpc.data) { return rpc.data.map(r => ({ handle: r.handle, initials: initialsFrom(r.display_name, r.handle), color: colorFor(r.handle), genre: r.genre || '', xp: num(r.earned, 0), streak: num(r.streak, 0), delta: '', you: user && r.id === user.id, })); } // Last resort (no RPCs deployed): old xp_this_week ordering. const { data, error } = await client .from('profiles') .select('id, handle, display_name, genre, xp_this_week, streak, xp_today') .order('xp_this_week', { ascending: false }) .limit(limit); if (error || !data) return null; return data.map(r => ({ handle: r.handle, initials: initialsFrom(r.display_name, r.handle), color: colorFor(r.handle), genre: r.genre || '', xp: num(r.xp_this_week, 0), streak: num(r.streak, 0), delta: '+' + Math.max(1, Math.round(num(r.xp_today, 0) / 80)), you: user && r.id === user.id, })); } catch (e) { return null; } } // The signed-in member's per-category XP for the week ('current'|'previous'). // Returns [{ action, points }] (real ledger) or null in demo mode. async function fetchMyWeeklyXp(week = 'current') { if (!isConfigured()) return null; const client = sb(); try { const { data, error } = await client.rpc('my_weekly_xp', { p_week: week }); if (error || !data) return null; return data.map(r => ({ action: r.action, points: num(r.points, 0) })); } catch (e) { return null; } } // Current week's [start, end) as ms timestamps, so the UI can count down to the // Monday-00:00-ET reset. Null in demo mode (UI shows a static countdown). async function fetchWeekBounds() { if (!isConfigured()) return null; const client = sb(); try { const { data, error } = await client.rpc('week_bounds'); if (error || !data) return null; const row = Array.isArray(data) ? (data[0] || {}) : data; if (!row.w_end) return null; return { start: new Date(row.w_start).getTime(), end: new Date(row.w_end).getTime() }; } catch (e) { return null; } } // Weekly prize: was the member top-3 LAST (finished) week, and did they claim? // Returns { eligible, rank, alreadyClaimed, weekStart, credit } or null in demo. async function weeklyPrizeStatus() { if (!isConfigured()) return null; const client = sb(); try { const { data, error } = await client.rpc('weekly_prize_status'); if (error || !data) return null; const r = Array.isArray(data) ? (data[0] || {}) : data; return { eligible: !!r.eligible, rank: r.rank == null ? null : num(r.rank, 0), alreadyClaimed: !!r.already_claimed, weekStart: r.week_start || null, credit: num(r.credit, 0), }; } catch (e) { return null; } } // Claim the top-3 prize → grants $40 of review-only credit (once per won week). // Returns { ok, message, credit } or { error }. Emits creditchange on success. async function claimWeeklyPrize() { if (!isConfigured()) return { error: 'Only available in live mode.' }; const client = sb(); try { const { data, error } = await client.rpc('claim_weekly_prize'); if (error) return { error: error.message }; const r = Array.isArray(data) ? (data[0] || {}) : data; if (r && r.ok) { try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {} return { ok: true, credit: num(r.credit, 0) }; } return { ok: false, message: (r && r.message) || 'Could not claim the prize.' }; } catch (e) { return { error: String(e.message || e) }; } } // ── public: community feed (live) ──────────────────────────────────────────── // All of these are no-ops / null in demo mode so the FeedScreen falls back to // its built-in sample posts. In live mode they read/write the posts, likes and // comments tables created by supabase/feed.sql. // Returns an array of feed posts (newest first) or null in demo mode. // Each row: { id, kind, title, body, tag, audio, cover_pal, cover_lbl, // cover_sub, author_handle, author_name, like_count, // comment_count, liked_by_me }. async function fetchFeed(limit = 50) { if (!isConfigured()) return null; const client = sb(); try { const { data, error } = await client .from('feed_posts') .select('*') .limit(limit); if (error || !data) return null; return data; } catch (e) { return null; } } // Toggle the current user's like on a post. Returns { liked } or { error }. async function togglePostLike(postId, currentlyLiked) { if (!isConfigured()) return { liked: !currentlyLiked }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; if (currentlyLiked) { const { error } = await client.from('likes') .delete().eq('post_id', postId).eq('user_id', user.id); if (error) return { error: error.message }; return { liked: false }; } else { const { error } = await client.from('likes') .insert({ post_id: postId, user_id: user.id }); if (error) return { error: error.message }; return { liked: true }; } } catch (e) { return { error: 'Like failed' }; } } // Fetch comments for a post (oldest first). Returns array or null in demo mode. // Each row: { id, body, created_at, author_handle, author_name }. async function fetchComments(postId) { if (!isConfigured()) return null; const client = sb(); try { const { data, error } = await client .from('feed_comments') .select('id, body, created_at, author_handle, author_name') .eq('post_id', postId) .order('created_at', { ascending: true }); if (error || !data) return null; return data.map(c => ({ id: c.id, body: c.body, created_at: c.created_at, author_handle: c.author_handle || '', author_name: c.author_name || '', })); } catch (e) { return null; } } // Add a comment. Returns { comment } or { error }. async function addComment(postId, body) { if (!isConfigured()) return { comment: { body, author_handle: 'you' } }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; const { data, error } = await client.from('comments') .insert({ post_id: postId, author_id: user.id, body }) .select('id, body, created_at') .single(); if (error) return { error: error.message }; return { comment: data }; } catch (e) { return { error: 'Comment failed' }; } } // Create a post. Returns { post } or { error }. async function createPost(post) { if (!isConfigured()) return { post }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; const { data, error } = await client.from('posts') .insert({ author_id: user.id, ...post }) .select('*').single(); if (error) return { error: error.message }; return { post: data }; } catch (e) { return { error: 'Post failed' }; } } // ── public: peer tracks + ratings ──────────────────────────────────────────── // A member uploads an unreleased song (audio + title) as a feed post // (kind='track'); any OTHER member rates it on the SAME /50 rubric as the $11 // evaluation. +10 pts to the uploader, +10 to each rater. This is separate from // coach evaluations. Live → posts + `tracks` storage bucket + track_ratings // (see supabase/tracks.sql). Demo → local-only so the preview still works. // ── public: profile photo (avatar) ─────────────────────────────────────────── // Public bucket `avatars` (see supabase/avatars.sql). One photo per user, stored // at avatars//avatar-.; profiles.avatar_path remembers it. // Build the public URL for a stored avatar path. Passes through full URLs / data // URLs untouched (demo mode stores a data URL). function avatarUrlFor(path) { if (!path) return null; if (/^(https?:|data:|blob:)/.test(path)) return path; const base = (window.LAUNCH_CONFIG && window.LAUNCH_CONFIG.SUPABASE_URL) || ''; return base ? base + '/storage/v1/object/public/avatars/' + path : null; } // Upload (or replace) the signed-in user's profile photo. Returns { url } or { error }. async function uploadAvatar(file) { if (!file) return { error: 'Pick an image first.' }; if (!String(file.type || '').startsWith('image/')) return { error: 'That file is not an image.' }; if (file.size > 5 * 1024 * 1024) return { error: 'Image too large (max 5 MB).' }; // Demo / not configured: keep a local data URL so the preview still works. if (!isConfigured()) { return await new Promise((resolve) => { const r = new FileReader(); r.onload = (ev) => { const url = ev.target.result; _persistAvatar(url, url); resolve({ url, demo: true }); }; r.onerror = () => resolve({ error: 'Could not read that image.' }); r.readAsDataURL(file); }); } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in to set a photo.' }; const ext = ((file.name && file.name.split('.').pop()) || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg'; const path = user.id + '/avatar-' + Date.now() + '.' + ext; const up = await client.storage.from('avatars').upload(path, file, { upsert: true, contentType: file.type || undefined }); if (up.error) return { error: 'Upload failed: ' + up.error.message }; const { error: dbErr } = await client.from('profiles').update({ avatar_path: up.data.path }).eq('id', user.id); if (dbErr) return { error: 'Saved the file but could not update your profile: ' + dbErr.message }; const url = avatarUrlFor(up.data.path); _persistAvatar(url, up.data.path); return { url }; } catch (e) { return { error: String((e && e.message) || e) }; } } // Remove the signed-in user's photo (back to initials). Returns { ok } or { error }. async function removeAvatar() { if (!isConfigured()) { _persistAvatar(null, null); return { ok: true, demo: true }; } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in first.' }; await client.from('profiles').update({ avatar_path: null }).eq('id', user.id); _persistAvatar(null, null); return { ok: true }; } catch (e) { return { error: String((e && e.message) || e) }; } } // Persist the avatar locally (so it shows instantly + survives reload) and notify. function _persistAvatar(url, path) { try { const existing = JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); existing.avatarUrl = url || undefined; existing.avatar_path = path || undefined; localStorage.setItem(STORE_KEY, JSON.stringify(existing)); } catch (e) {} _cached = null; emitChange(); } // ── Song Submission Agreement (consent gate before any audio upload) ───────── // The user must READ the agreement PDF and tick "I agree" before they can // upload a track for evaluation. We persist the acceptance in Supabase // (`song_agreements`) so we have proof they consented to how their audio is // handled. Bump SONG_AGREEMENT_VERSION whenever the document changes — that // re-prompts everyone for fresh consent. const SONG_AGREEMENT_VERSION = '2026-06-29'; const SONG_AGREEMENT_URL = 'documents/Song_Submission_Agreement_Launch.pdf'; const SONG_AGREEMENT_KEY = 'launch.songagreement'; // demo / offline cache function _localAgreedVersion() { try { return localStorage.getItem(SONG_AGREEMENT_KEY) || null; } catch (e) { return null; } } function _setLocalAgreed(version) { try { localStorage.setItem(SONG_AGREEMENT_KEY, version); } catch (e) {} } // Has the signed-in user accepted the CURRENT agreement version? // → true | false. Falls back to the local cache in demo / on error. async function hasAcceptedSongAgreement() { if (_localAgreedVersion() === SONG_AGREEMENT_VERSION) return true; if (!isConfigured()) return false; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return false; const { data, error } = await client .from('song_agreements') .select('version') .eq('user_id', user.id) .eq('version', SONG_AGREEMENT_VERSION) .maybeSingle(); if (error) return false; if (data) { _setLocalAgreed(SONG_AGREEMENT_VERSION); return true; } return false; } catch (e) { return false; } } // Record that the user read + agreed to the current agreement. Returns { ok } // or { error }. Idempotent (unique on user_id+version → duplicate is fine). async function acceptSongAgreement() { if (!isConfigured()) { _setLocalAgreed(SONG_AGREEMENT_VERSION); return { ok: true, demo: true }; } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in first.' }; const ua = (typeof navigator !== 'undefined' && navigator.userAgent) ? navigator.userAgent.slice(0, 400) : null; const { error } = await client .from('song_agreements') .upsert({ user_id: user.id, version: SONG_AGREEMENT_VERSION, user_agent: ua }, { onConflict: 'user_id,version', ignoreDuplicates: true }); // A duplicate (already accepted this version) is success, not an error. if (error && !/duplicate|unique|conflict/i.test(error.message)) return { error: error.message }; _setLocalAgreed(SONG_AGREEMENT_VERSION); return { ok: true }; } catch (e) { return { error: String((e && e.message) || e) }; } } async function uploadTrack(opts) { const { title, audioFile } = opts || {}; if (!title || !String(title).trim()) return { error: 'Give your song a title.' }; // Consent gate: no agreement, no upload. if (!(await hasAcceptedSongAgreement())) return { error: 'AGREEMENT_REQUIRED' }; if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in to upload.' }; let audio_path = null; if (audioFile) { const ext = ((audioFile.name && audioFile.name.split('.').pop()) || 'mp3').toLowerCase(); const path = user.id + '/' + Date.now() + '.' + ext; const up = await client.storage.from('tracks') .upload(path, audioFile, { upsert: false, contentType: audioFile.type || undefined }); if (up.error) return { error: 'Upload failed: ' + up.error.message }; audio_path = up.data.path; } const { data, error } = await client.from('posts').insert({ author_id: user.id, kind: 'track', title: String(title).trim(), body: '', audio: true, audio_path, agreement_version: SONG_AGREEMENT_VERSION, agreed_at: new Date().toISOString(), }).select('*').single(); if (error) return { error: error.message }; try { await awardPoints('track', { meta: { postId: data.id } }); } catch (e) {} return { post: data }; } catch (e) { return { error: String(e.message || e) }; } } // demo: no storage — award + return a local post shell try { await awardPoints('track'); } catch (e) {} return { post: { id: 'demo-track-' + Date.now(), kind: 'track', title: String(title).trim(), audio: true, audio_name: audioFile ? audioFile.name : null } }; } // Submit a peer rating. scores = { structure, hook, idea, lyrics, melody } 1–10. // Returns { ok, total } or { error }. async function rateTrack(postId, scores, notes) { const total = Object.values(scores || {}).reduce((a, b) => a + (Number(b) || 0), 0); if (isConfigured() && !String(postId).startsWith('demo-')) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in to rate.' }; const { error } = await client.from('track_ratings').insert({ post_id: postId, rater_id: user.id, scores: scores || {}, notes: notes || {}, total, }); if (error) { if (/duplicate|unique/i.test(error.message)) return { error: 'You already rated this song.' }; if (/row-level|violates|policy/i.test(error.message)) return { error: "You can't rate your own song." }; return { error: error.message }; } try { await awardPoints('rate', { meta: { postId } }); } catch (e) {} return { ok: true, total }; } catch (e) { return { error: String(e.message || e) }; } } // demo try { await awardPoints('rate'); } catch (e) {} return { ok: true, total }; } // Per-track rating summary + which tracks the current user already rated. // → { summary: { [postId]: { votes, avg } }, mine: { [postId]: total } } | null (demo). async function fetchTrackRatings() { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); const summary = {}; const { data: sum } = await client.from('track_rating_summary').select('post_id, votes, avg_total'); (sum || []).forEach(r => { summary[r.post_id] = { votes: r.votes, avg: r.avg_total }; }); const mine = {}; if (user) { const { data: mr } = await client.from('track_ratings').select('post_id, total').eq('rater_id', user.id); (mr || []).forEach(r => { mine[r.post_id] = r.total; }); } return { summary, mine }; } catch (e) { return null; } } // Short-lived signed URL to stream an uploaded peer track (live only). async function trackAudioUrl(audioPath) { if (!isConfigured() || !audioPath) return null; const client = sb(); try { const { data, error } = await client.storage.from('tracks').createSignedUrl(audioPath, 3600); return (error || !data) ? null : data.signedUrl; } catch (e) { return null; } } // Delete an uploaded track. Removes the post row (RLS limits this to its author) // and its audio file, then takes back the +10 points earned for uploading it. // Returns { ok } or { error }. async function deleteTrack(post) { const postId = post && (typeof post === 'object' ? post.id : post); const audioPath = post && typeof post === 'object' ? post.audioPath : null; if (!postId) return { error: 'Nothing to delete.' }; if (isConfigured() && !String(postId).startsWith('demo-')) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in to delete.' }; const { error } = await client.from('posts').delete().eq('id', postId).eq('author_id', user.id); if (error) return { error: error.message }; if (audioPath) { try { await client.storage.from('tracks').remove([audioPath]); } catch (e) {} } // Take back the points awarded on upload. try { await awardPoints('track', { undo: true, meta: { postId, deleted: true } }); } catch (e) {} return { ok: true }; } catch (e) { return { error: String(e.message || e) }; } } // demo (or not signed in): just take back the points locally try { await awardPoints('track', { undo: true }); } catch (e) {} return { ok: true }; } // ── public: song submissions → coaches ─────────────────────────────────────── // A member submits a song (audio + title + notes) addressed to a coach. Coaches // read the queue, listen, and post an evaluation. Dual-mode like everything else: // • LIVE — Supabase: audio → 'submissions' storage bucket, rows in the // `submissions` / `evaluations` tables (see supabase/setup.sql). // • DEMO — one shared localStorage list so you can submit as a member and then // open the coach queue in the same preview. (Audio can't persist in // demo, so only the file name is kept.) const SUBS_KEY = 'launch.songsubmissions'; function _loadSubs() { try { return JSON.parse(localStorage.getItem(SUBS_KEY) || '[]'); } catch (e) { return []; } } function _saveSubs(list) { try { localStorage.setItem(SUBS_KEY, JSON.stringify(list)); } catch (e) {} } // Member submits a song. opts: { title, notes, tier, coach, audioFile (File) }. // Returns { submission } or { error }. async function submitSongForReview(opts) { const { title, notes, tier, coach, audioFile } = opts || {}; if (!title || !String(title).trim()) return { error: 'A track title is required.' }; // Consent gate: no agreement, no upload. if (!(await hasAcceptedSongAgreement())) return { error: 'AGREEMENT_REQUIRED' }; if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in.' }; let audio_path = null; if (audioFile) { const ext = (audioFile.name && audioFile.name.split('.').pop() || 'dat').toLowerCase(); const path = user.id + '/' + Date.now() + '.' + ext; const up = await client.storage.from('submissions') .upload(path, audioFile, { upsert: false, contentType: audioFile.type || undefined }); if (up.error) return { error: 'Upload failed: ' + up.error.message }; audio_path = up.data.path; } const { data, error } = await client.from('submissions').insert({ member_id: user.id, coach: coach || null, title: String(title).trim(), notes: notes || null, tier: tier || 'quick', audio_path, status: 'pending', agreement_version: SONG_AGREEMENT_VERSION, agreed_at: new Date().toISOString(), }).select('*').single(); if (error) return { error: error.message }; try { await awardPoints('song', { meta: { title: String(title).trim() } }); } catch (e) {} return { submission: data }; } catch (e) { return { error: 'Submission failed: ' + (e.message || e) }; } } // demo: keep a local row (audio can't persist — store the file name only) const list = _loadSubs(); const me = loadProfile(); const sub = { id: 's-' + Date.now(), member_id: 'demo', member_handle: me.handle || 'you', member_name: me.displayName || 'You', coach: coach || null, title: String(title).trim(), notes: notes || null, tier: tier || 'quick', audio_path: null, audio_name: audioFile ? audioFile.name : null, status: 'pending', created_at: new Date().toISOString(), }; list.unshift(sub); _saveSubs(list); try { await awardPoints('song', { meta: { title: String(title).trim() } }); } catch (e) {} return { submission: sub }; } // Coach/admin: pending-first queue of submissions. Array (RLS scopes it in live). async function fetchCoachQueue() { if (isConfigured()) { const client = sb(); try { const { data, error } = await client .from('submissions') .select('*, profiles (handle, display_name)') .eq('paid', true) // only tracks the member actually paid for reach a coach .order('status', { ascending: true }) .order('created_at', { ascending: true }); if (error || !data) return []; return data.map(s => ({ ...s, member_handle: s.profiles ? s.profiles.handle : '', member_name: s.profiles ? s.profiles.display_name : '', })); } catch (e) { return []; } } return _loadSubs(); } // Member: their own submissions (newest first) with current status. async function fetchMySubmissions() { if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return []; const { data, error } = await client .from('submissions').select('*') .eq('member_id', user.id) .order('created_at', { ascending: false }); if (error || !data) return []; return data; } catch (e) { return []; } } return _loadSubs(); } // ── public: coaches directory (bookable mentors / reviewers) ───────────────── // Live mode → the admin-managed `coaches` table (see supabase/coaches.sql). // Demo mode → the built-in two-coach list so the preview works with no backend. // Consumed by the "PICK YOUR COACH" picker; each entry also carries the email + // google_calendar_id the scheduling backend will use to book sessions. const DEMO_COACHES = [ { id: 'jay', lbl: 'JB', name: 'Jay Brunswick', displayName: 'Jay', email: 'jay@launch.app', role: 'PRODUCER · A&R · VINTAGE MUSIC GROUP', color: '#C72820', palette: ['#C72820', '#7A1A0A'], rate: 100, availability: '2 SLOTS THIS WEEK', googleCalendarId: 'jay@launch.app', bio: "RIAA Gold certified songwriter with 170+ cuts and two Billboard Top 40 country singles. Hears arrangement and structure first.", credits: ['RIAA Gold certified songwriter', '170+ cuts to his name', '2 Billboard Top 40 country singles'] }, { id: 'pg', lbl: 'PG', name: 'PG Banker', displayName: 'PG', email: 'pg@launch.app', role: 'SONGWRITER · PRODUCER · LOGIC PRO CERTIFIED', color: '#7A5AE0', palette: ['#7A5AE0', '#150A33'], rate: 100, availability: '4 SLOTS THIS WEEK', googleCalendarId: 'pg@launch.app', bio: "Songwriter, Logic Pro certified engineer and producer with 1,000+ placements. Best for sharpening lyrics, topline and production.", credits: ['Logic Pro certified engineer', '1,000+ track placements', 'Placed on Fox Sports & major networks'] }, ]; // Last successful fetch, cached so screens that need a coach synchronously // (e.g. the payment label) can resolve one without re-querying. let _coachesCache = null; async function fetchCoaches() { if (isConfigured()) { const client = sb(); try { const { data, error } = await client .from('coaches') .select('id, lbl, name, display_name, email, role, color, rate, bio, credits, availability, google_calendar_id, booking_url') .eq('active', true) .order('sort_order', { ascending: true }) .order('name', { ascending: true }); if (!error && data && data.length) { const mapped = data.map(c => ({ id: c.id, lbl: c.lbl || initialsFrom(c.name, c.email), name: c.name, displayName: c.display_name || c.name, // shown only in the PICK YOUR COACH picker email: c.email, role: c.role || '', color: c.color || '#C72820', palette: [c.color || '#C72820', '#0A0A0A'], rate: num(c.rate, 100), bio: c.bio || '', credits: c.credits || [], availability: c.availability || '', googleCalendarId: c.google_calendar_id || c.email, bookingUrl: c.booking_url || '', })); _coachesCache = mapped; return mapped; } } catch (e) { /* fall through to demo list */ } } _coachesCache = DEMO_COACHES.map(c => ({ ...c })); return _coachesCache.map(c => ({ ...c })); } // Resolve a coach from a 'mentor-' payment/booking item using the cache. // Returns null if coaches haven't loaded yet (callers fall back to a default). function coachByItem(item) { const id = String(item || '').replace(/^mentor-/, ''); const list = _coachesCache || DEMO_COACHES; return list.find(c => String(c.id) === id) || null; } // A short-lived signed URL to stream a submission's audio (live only). async function audioUrlFor(audioPath) { if (!isConfigured() || !audioPath) return null; const client = sb(); try { const { data, error } = await client.storage .from('submissions').createSignedUrl(audioPath, 3600); return (error || !data) ? null : data.signedUrl; } catch (e) { return null; } } // A short-lived signed URL to stream a coach's voice note (live only). The // evaluations SELECT policy + the 'eval-voice-notes' bucket policy together mean // only the submitting member (and coaches/admins) can mint this URL. async function evalVoiceUrl(voicePath) { if (!isConfigured() || !voicePath) return null; const client = sb(); try { const { data, error } = await client.storage .from('eval-voice-notes').createSignedUrl(voicePath, 3600); return (error || !data) ? null : data.signedUrl; } catch (e) { return null; } } // Read a recorded Blob into a data URL (demo-mode voice-note persistence). function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('read failed')); r.readAsDataURL(blob); }); } // Coach/admin: save an evaluation for a submission and mark it reviewed. // payload: { scores:{...}, notes:{...}, verdict, total, voiceNote?(Blob), memberId? }. // A voice note (In-Depth / $40 tier) is stored privately for that member only. // Returns { ok } or { error }. async function saveEvaluation(submissionId, payload) { const { scores, notes, verdict, total, voiceNote, memberId } = payload || {}; if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in.' }; // Optional coach voice note → private 'eval-voice-notes' bucket, filed // under the member's id so RLS lets only that member (+ coaches) play it. let voicePath = null; if (voiceNote && memberId) { const path = `${memberId}/${submissionId}.webm`; const up = await client.storage.from('eval-voice-notes') .upload(path, voiceNote, { upsert: true, contentType: voiceNote.type || 'audio/webm' }); if (up.error) return { error: 'Voice note upload failed: ' + up.error.message }; voicePath = path; } const { error: insErr } = await client.from('evaluations').insert({ submission_id: submissionId, coach_id: user.id, scores: scores || {}, notes: notes || {}, verdict: verdict || null, total: total ?? null, voice_note_path: voicePath, }); if (insErr) return { error: insErr.message }; await client.from('submissions').update({ status: 'reviewed' }).eq('id', submissionId); return { ok: true }; } catch (e) { return { error: 'Save failed: ' + (e.message || e) }; } } // demo: attach the evaluation to the local row (voice note kept as a data URL) let voiceUrl = null; if (voiceNote) { try { voiceUrl = await blobToDataUrl(voiceNote); } catch (e) {} } const next = _loadSubs().map(s => s.id === submissionId ? { ...s, status: 'reviewed', evaluation: { scores, notes, verdict, total, voiceUrl } } : s); _saveSubs(next); return { ok: true }; } // Coach directory — maps the label stored on a submission ('JB'/'PG') to the // reviewer identity the evaluation screens render. const COACHES = { JB: { lbl: 'JB', name: 'Jay Brunswick', role: 'PROD · A&R VINTAGE MUSIC GROUP', color: '#C72820' }, PG: { lbl: 'PG', name: 'PG Banker', role: 'SONGWRITER · NASHVILLE', color: '#7A5AE0' }, }; function fmtEvalDate(ts) { try { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }).toUpperCase(); } catch (e) { return ''; } } function tierLabel(t) { return t === 'full' ? 'IN-DEPTH · $40' : t === 'ar' ? 'A&R PANEL · $149' : 'QUICK SCORE · $11'; } // Shape an evaluation (+ its submission) into what evaluations.jsx renders. function mapEval(row, sub) { const coach = COACHES[(sub && sub.coach) || 'JB'] || COACHES.JB; const title = String((sub && sub.title) || 'UNTITLED').toUpperCase(); const letters = title.replace(/[^A-Z]/g, ''); return { id: row.id, track: title, coverPal: [coach.color, '#FBBF24'], coverLbl: letters.slice(0, 2) || 'LN', submitted: fmtEvalDate(row.created_at), reviewer: coach, tier: tierLabel(sub && sub.tier), scores: row.scores || {}, notes: row.notes || {}, verdict: row.verdict || '', voicePath: row.voice_note_path || null, // live: signed on demand for the member voiceUrl: row.voiceUrl || null, // demo: inline data URL }; } // Coach/admin: one artist's full submission history with scores, newest first. // Each row: { id, title, tier, status, created_at, audio_path, total, scores, verdict }. async function fetchArtistHistory(memberId) { if (!memberId) return []; if (isConfigured()) { const client = sb(); try { const { data, error } = await client .from('submissions') .select('id, title, tier, status, created_at, audio_path, evaluations ( total, scores, notes, verdict, created_at )') .eq('member_id', memberId) .order('created_at', { ascending: false }); if (error || !data) return []; return data.map(s => { const ev = (Array.isArray(s.evaluations) && s.evaluations.length) ? s.evaluations[0] : null; return { id: s.id, title: s.title, tier: s.tier, status: s.status, created_at: s.created_at, audio_path: s.audio_path, total: ev ? ev.total : null, scores: ev ? ev.scores : null, verdict: ev ? ev.verdict : '', }; }); } catch (e) { return []; } } // demo: local submissions (no per-member split in demo) with any attached eval. return _loadSubs().map(s => ({ id: s.id, title: s.title, tier: s.tier, status: s.status, created_at: s.created_at || s.ts, audio_path: null, total: s.evaluation ? s.evaluation.total : null, scores: s.evaluation ? s.evaluation.scores : null, verdict: s.evaluation ? s.evaluation.verdict : '', })); } // Member: their completed coach evaluations, newest first. [] when none. async function fetchMyEvaluations() { if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return []; const { data, error } = await client .from('evaluations') .select('*, submissions!inner (title, coach, tier, member_id, created_at)') .eq('submissions.member_id', user.id) .order('created_at', { ascending: false }); if (error || !data) return []; return data.map(r => mapEval(r, r.submissions)); } catch (e) { return []; } } // demo: read evaluations attached to local submissions return _loadSubs() .filter(s => s.evaluation) .map(s => mapEval({ id: s.id, scores: s.evaluation.scores, notes: s.evaluation.notes, verdict: s.evaluation.verdict, total: s.evaluation.total, voiceUrl: s.evaluation.voiceUrl, created_at: s.created_at, }, s)); } // ── public: Stripe checkout (live only) ────────────────────────────────────── // Redirects the browser to a Stripe Checkout Session created by the // `create-checkout` Edge Function. Returns { demo:true } in demo mode (caller // keeps its simulated flow) or { error } / { redirecting:true }. async function startCheckout({ item, submissionId, bookingId }) { if (!isConfigured()) return { demo: true }; const client = sb(); try { const base = window.location.origin + window.location.pathname; const { data, error } = await client.functions.invoke('create-checkout', { body: { item, submissionId: submissionId || '', bookingId: bookingId || '', success_url: base + '?checkout=success', cancel_url: base + '?checkout=cancel', }, }); if (error) return { error: error.message || 'Checkout request failed.' }; if (data && data.error) return { error: data.error }; // Session fully covered by account credit → no Stripe redirect needed. if (data && data.done) return { done: true, freebie: !!data.freebie }; if (data && data.url) { window.location.href = data.url; return { redirecting: true }; } return { error: 'No checkout URL returned.' }; } catch (e) { return { error: String(e.message || e) }; } } // Called on return from Stripe to verify + record the payment server-side. async function confirmCheckout(sessionId) { if (!isConfigured() || !sessionId) return { demo: true }; const client = sb(); try { const { data, error } = await client.functions.invoke('confirm-checkout', { body: { session_id: sessionId }, }); if (error) return { error: error.message }; return data || {}; } catch (e) { return { error: String(e.message || e) }; } } // ── public: request a 1:1 session → creates a PENDING booking ──────────────── // The Google Calendar event is NOT created here — it's created after payment is // verified (confirm-checkout → schedule-session). This just records the intent // so the booking id can travel through Stripe. payload: { coachId, startsAt(ISO), // durationMin, topic }. Returns { booking } (with id) or { error }. async function requestBooking({ coachId, startsAt, durationMin, topic }) { if (!isConfigured()) { // Demo: no backend — pretend a pending booking exists so the flow continues. return { ok: true, demo: true, booking: { id: 'demo-booking' } }; } if (!coachId || !startsAt) return { error: 'Pick a coach and a time first.' }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Sign in to book a session.' }; const dur = durationMin || 60; const start = new Date(startsAt); const end = new Date(start.getTime() + dur * 60 * 1000); const { data, error } = await client.from('bookings').insert({ member_id: user.id, coach_id: coachId, starts_at: start.toISOString(), ends_at: end.toISOString(), duration_min: dur, topic: topic || null, status: 'pending', }).select().single(); if (error) return { error: error.message }; return { ok: true, booking: data }; } catch (e) { return { error: String(e.message || e) }; } } // Real busy intervals for a coach from Google Calendar (freebusy). LIVE → the // coach-availability Edge Function. DEMO / no data → null so the booking grid // keeps its simulated busy blocks. Returns [{ start, end }] (ISO strings). async function fetchCoachAvailability(coachId, timeMin, timeMax) { if (!isConfigured() || !coachId) return null; const client = sb(); try { const { data, error } = await client.functions.invoke('coach-availability', { body: { coachId, timeMin, timeMax }, }); if (error || !data || data.error) return null; return Array.isArray(data.busy) ? data.busy : null; } catch (e) { return null; } } // Cancel a booked session. Enforced server-side to be ≥24h before the start. // LIVE → cancel-session (deletes the Google event + marks cancelled). // DEMO → simulated. Returns { ok } or { error } (with the server's message). async function cancelBooking(bookingId, resolution) { if (!isConfigured()) return { ok: true, demo: true, resolution: resolution || 'refund' }; if (!bookingId) return { error: 'Missing booking.' }; const client = sb(); try { const { data, error } = await client.functions.invoke('cancel-session', { body: { bookingId, resolution: resolution || 'refund' }, }); if (error) { // Surface the function's error message (e.g. the 24h rule) if present. try { const b = await error.context.json(); if (b && b.error) return { error: b.error }; } catch (e) {} return { error: error.message || 'Could not cancel the session.' }; } if (data && data.error) return { error: data.error }; return data || { ok: true }; } catch (e) { return { error: String(e.message || e) }; } } // Member's account credit balance, in cents (0 in demo / when none). async function fetchMyCredit() { if (!isConfigured()) return 0; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return 0; const { data } = await client.from('profiles').select('credit_cents').eq('id', user.id).single(); return Math.max(0, Number((data && data.credit_cents) || 0)); } catch (e) { return 0; } } // Review-only credit (cents) from redeemed points — usable ONLY for the $40 // coach review, never for sessions. 0 in demo / when none. async function fetchMyReviewCredit() { if (!isConfigured()) return 0; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return 0; const { data } = await client.from('profiles').select('review_credit_cents').eq('id', user.id).single(); return Math.max(0, Number((data && data.review_credit_cents) || 0)); } catch (e) { return 0; } } // Member: their upcoming/past sessions (newest first). Null in demo mode so the // caller can fall back to its own placeholder. async function fetchMyBookings() { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return []; const { data, error } = await client .from('bookings') .select('*, coaches (name, lbl, color)') .eq('member_id', user.id) .order('starts_at', { ascending: true }); if (error || !data) return []; return data; } catch (e) { return []; } } // ── public: songwriting habit log ──────────────────────────────────────────── // A map { 'YYYY-MM-DD': 1 } of days the user marked. LIVE → the `habit_logs` // table (one row per marked day). DEMO → a shared localStorage object so the // preview keeps working offline. const HABIT_LS_KEY = 'launch.habit'; function _loadHabitLS() { try { const r = JSON.parse(localStorage.getItem(HABIT_LS_KEY) || 'null'); if (r && typeof r === 'object' && !Array.isArray(r)) return r; } catch (e) {} return {}; } function _saveHabitLS(map) { try { localStorage.setItem(HABIT_LS_KEY, JSON.stringify(map)); } catch (e) {} } async function fetchHabitLog() { if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (user) { const { data, error } = await client.from('habit_logs').select('day').eq('user_id', user.id); if (!error && data) { const map = {}; data.forEach(r => { map[r.day] = 1; }); _saveHabitLS(map); // keep a local cache in sync return map; } } } catch (e) {} // not signed in / query failed → fall back to the local copy so marks // never just vanish. return _loadHabitLS(); } return _loadHabitLS(); } // Mark (on=true) or clear (on=false) a single day. dayKey is 'YYYY-MM-DD'. // Writes the local copy FIRST (instant, never vanishes) then mirrors to Supabase // when signed in. Returns { ok } or { error } (the error is advisory — the local // copy already holds the change). async function setHabitDay(dayKey, on) { const local = _loadHabitLS(); if (on) local[dayKey] = 1; else delete local[dayKey]; _saveHabitLS(local); if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in — saved locally only.' }; if (on) { const { error } = await client.from('habit_logs').upsert({ user_id: user.id, day: dayKey }); if (error) return { error: error.message }; } else { const { error } = await client.from('habit_logs').delete().eq('user_id', user.id).eq('day', dayKey); if (error) return { error: error.message }; } return { ok: true }; } catch (e) { return { error: String(e.message || e) }; } } return { ok: true }; } // ── public: community points economy ──────────────────────────────────────── // Members earn points for community actions and redeem them for a $40 song // review. Dual-mode like everything else: // • LIVE — Supabase: one row per earn in `points_ledger`, one row per redeem // in `redemptions`; balance = earned − spent (see supabase/points.sql). // • DEMO — a single localStorage object so the preview works with no backend. // // Earn rules (per the product spec): // like a post → +1 // comment on a post → +2 // share a post → +5 (a post that "aporta al grupo") // submit a song → +10 const POINTS_RULES = { like: 1, comment: 2, post: 5, song: 10, track: 10, rate: 10 }; const POINTS_LABELS = { like: 'Liked a post', comment: 'Commented on a post', post: 'Shared a post', song: 'Submitted a song for review', track: 'Uploaded an unreleased song', rate: 'Rated a song', redeem: 'Redeemed a $40 review', }; const REVIEW_COST = 300; // points needed to redeem one $40 review const REVIEW_CREDIT_CENTS = 4000; // $40 account credit granted on redeem (live) const POINTS_KEY = 'launch.points'; function _emptyPoints() { return { balance: 0, earned: 0, spent: 0, history: [] }; } function _loadPointsLS() { try { const r = JSON.parse(localStorage.getItem(POINTS_KEY) || 'null'); if (r && typeof r === 'object' && !Array.isArray(r)) return { ..._emptyPoints(), ...r }; } catch (e) {} return _emptyPoints(); } function _savePointsLS(p) { try { localStorage.setItem(POINTS_KEY, JSON.stringify(p)); } catch (e) {} } let _pointsCache = null; // Sync best-known points (cache). In live mode refreshPoints() updates it. function loadPoints() { if (_pointsCache) return _pointsCache; _pointsCache = _loadPointsLS(); return _pointsCache; } function pointsRules() { return { ...POINTS_RULES }; } function reviewCost() { return REVIEW_COST; } function emitPointsChange() { try { window.dispatchEvent(new Event('launch.pointschange')); } catch (e) {} } // LIVE: recompute the balance from the ledger + redemptions via an RPC. async function refreshPoints() { if (!isConfigured()) { _pointsCache = _loadPointsLS(); return _pointsCache; } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) { _pointsCache = _loadPointsLS(); return _pointsCache; } const { data, error } = await client.rpc('my_points_balance'); if (!error && data) { const row = Array.isArray(data) ? (data[0] || {}) : data; _pointsCache = { balance: num(row.balance, 0), earned: num(row.earned, 0), spent: num(row.spent, 0), history: (_pointsCache && _pointsCache.history) || [], }; } } catch (e) {} return _pointsCache || _emptyPoints(); } // Award (or, with opts.undo, take back) points for a community action. // action ∈ like | comment | post | song. Returns the new points object. async function awardPoints(action, opts) { const o = opts || {}; const base = POINTS_RULES[action] || 0; const pts = (o.undo ? -1 : 1) * base; if (!pts) return loadPoints(); if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (user) { await client.from('points_ledger').insert({ user_id: user.id, action, points: pts, meta: o.meta || null, }); await refreshPoints(); emitPointsChange(); return _pointsCache; } } catch (e) { /* fall through to local so points never just vanish */ } } // demo (or not signed in): keep a local running balance const p = _loadPointsLS(); p.balance = Math.max(0, num(p.balance, 0) + pts); p.earned = Math.max(0, num(p.earned, 0) + pts); if (pts > 0) { p.history = [{ action, points: pts, label: POINTS_LABELS[action] || action, ts: new Date().toISOString() }] .concat(p.history || []).slice(0, 50); } _savePointsLS(p); _pointsCache = p; emitPointsChange(); return p; } // Redeem the current balance for a $40 review. Visual flow for now — it records // the redemption and debits the points, but doesn't move real money yet. // Returns { ok, redemption } or { error }. async function redeemReview() { const cost = REVIEW_COST; if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (user) { // Secure server-side redeem: validates the balance, records the // redemption AND grants $40 of real account credit, all atomically. const { data, error } = await client.rpc('redeem_review'); if (error) return { error: error.message }; const row = Array.isArray(data) ? (data[0] || {}) : (data || {}); if (!row.ok) return { error: row.message || "Couldn't redeem." }; await refreshPoints(); emitPointsChange(); try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {} return { ok: true, redemption: { kind: 'review', cost_points: cost, status: 'granted' }, creditCents: num(row.credit, 0), grantedCents: REVIEW_CREDIT_CENTS, }; } } catch (e) { return { error: String(e.message || e) }; } } const p = _loadPointsLS(); if (num(p.balance, 0) < cost) return { error: "You don't have enough points yet." }; p.balance = num(p.balance, 0) - cost; p.spent = num(p.spent, 0) + cost; p.history = [{ action: 'redeem', points: -cost, label: POINTS_LABELS.redeem, ts: new Date().toISOString() }] .concat(p.history || []).slice(0, 50); _savePointsLS(p); _pointsCache = p; emitPointsChange(); return { ok: true, redemption: { kind: 'review', cost_points: cost, status: 'granted' } }; } // Pay a review submission using account credit (from redeemed points) — no Stripe. // Returns { ok, covered, credit } or { ok:false, error }. In demo mode it just // reports covered so the flow completes. async function payReviewWithCredit(submissionId, amountCents) { if (!isConfigured()) return { ok: true, covered: true }; if (!submissionId || !amountCents) return { ok: false, error: 'Missing submission/amount.' }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { ok: false, error: 'Not signed in' }; const { data, error } = await client.rpc('pay_review_with_credit', { p_submission_id: submissionId, p_amount_cents: amountCents, }); if (error) return { ok: false, error: error.message }; const row = Array.isArray(data) ? (data[0] || {}) : (data || {}); if (row.ok) { try { window.dispatchEvent(new Event('launch.creditchange')); } catch (e) {} } return { ok: !!row.ok, covered: !!row.covered, error: row.ok ? null : row.message, credit: num(row.credit, 0) }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } // React hook → the current points object, reactive to earns/redeems. function usePoints() { const [pts, setPts] = useStateU(loadPoints); useEffectU(() => { let alive = true; const refresh = () => { if (alive) setPts({ ...loadPoints() }); }; if (isConfigured()) { refreshPoints().then(() => { if (alive) setPts({ ...(_pointsCache || loadPoints()) }); }); } window.addEventListener('launch.pointschange', refresh); window.addEventListener('storage', refresh); return () => { alive = false; window.removeEventListener('launch.pointschange', refresh); window.removeEventListener('storage', refresh); }; }, []); return pts; } // ── public: direct messages + presence (live) ─────────────────────────────── // Real 1:1 chat backed by the `messages` table (see supabase/messages.sql) plus // Supabase Realtime Presence for online status. All of these return null / no-op // in demo mode so social.jsx keeps its built-in sample threads + members. // // Shapes are kept identical to the demo data so the UI needs no special-casing: // member: { id, handle, name, lbl, color, pal, bio, tier, city, genres, // followers, following, releases, streak, online } // thread: { id:, member:, otherId, unread, lastTime, // messages:[{ from:'me'|'them', t, ts }] } function fmtMsgTime(iso) { try { return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } catch (e) { return 'now'; } } // Map a profiles row → the member shape the chat/profile screens expect. function mapMember(r) { const name = r.display_name || nameFromHandle(r.handle); const color = colorFor(r.handle); return { id: r.id, handle: r.handle, name, lbl: initialsFrom(name, r.handle), color, pal: [color, '#0A0A0A'], avatarUrl: avatarUrlFor(r.avatar_path), bio: r.bio || '', tier: r.tier || 'ROOKIE', city: '', genres: r.genre ? [String(r.genre).toUpperCase()] : [], followers: num(r.followers, 0), following: num(r.following_count, 0), releases: num(r.releases, 0), streak: num(r.streak, 0), online: false, }; } const MEMBER_COLS_BASE = 'id, handle, display_name, bio, tier, genre, followers, following_count, streak, releases'; const MEMBER_COLS = MEMBER_COLS_BASE + ', avatar_path'; // Whether the avatar_path column exists. Starts assumed-yes; flips to false the // first time a query fails because the column isn't there yet (SQL not run), so // the app keeps working before/after supabase/avatars.sql is applied. let _hasAvatarCol = true; function _memberCols() { return _hasAvatarCol ? MEMBER_COLS : MEMBER_COLS_BASE; } // Directory of other members (handle → member), from profiles. Null in demo. async function fetchMembersDirectory(limit = 200) { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); let { data, error } = await client.from('profiles').select(_memberCols()).limit(limit); // avatar_path column missing yet → retry without it (and remember). if (error && /avatar_path/.test(error.message || '')) { _hasAvatarCol = false; ({ data, error } = await client.from('profiles').select(MEMBER_COLS_BASE).limit(limit)); } if (error || !data) return null; const dir = {}; data.forEach(r => { if (user && r.id === user.id) return; // don't list yourself dir[r.handle] = mapMember(r); }); return dir; } catch (e) { return null; } } // Look up profiles by id → { [id]: row }. async function _profilesByIds(ids) { const out = {}; if (!ids || !ids.length) return out; try { let { data, error } = await sb().from('profiles').select(_memberCols()).in('id', ids); if (error && /avatar_path/.test(error.message || '')) { _hasAvatarCol = false; ({ data } = await sb().from('profiles').select(MEMBER_COLS_BASE).in('id', ids)); } (data || []).forEach(r => { out[r.id] = r; }); } catch (e) {} return out; } // Conversations (newest activity first-ish), grouped by the other participant. // Null in demo mode. Each thread carries otherId so sends can target a real user. async function fetchThreads() { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return null; const { data, error } = await client.from('messages') .select('id, sender_id, recipient_id, body, read_at, created_at') .or(`sender_id.eq.${user.id},recipient_id.eq.${user.id}`) .order('created_at', { ascending: true }) .limit(500); if (error || !data) return []; const otherIds = [...new Set(data.map(m => m.sender_id === user.id ? m.recipient_id : m.sender_id))]; const profMap = await _profilesByIds(otherIds); const byHandle = {}; data.forEach(m => { const otherId = m.sender_id === user.id ? m.recipient_id : m.sender_id; const prof = profMap[otherId]; const handle = prof ? prof.handle : otherId; if (!byHandle[handle]) byHandle[handle] = { id: handle, member: handle, otherId, unread: 0, lastTime: '', messages: [] }; const th = byHandle[handle]; th.messages.push({ from: m.sender_id === user.id ? 'me' : 'them', t: m.body, ts: fmtMsgTime(m.created_at) }); th.lastTime = fmtMsgTime(m.created_at).toUpperCase(); if (m.recipient_id === user.id && !m.read_at) th.unread++; }); return Object.values(byHandle); } catch (e) { return []; } } // Send a DM to recipientId. Returns { message } or { error } (no-op in demo). async function sendMessageLive(recipientId, body) { if (!isConfigured()) return { demo: true }; if (!recipientId || !String(body || '').trim()) return { error: 'Nothing to send.' }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; const { data, error } = await client.from('messages') .insert({ sender_id: user.id, recipient_id: recipientId, body: String(body).trim() }) .select().single(); if (error) return { error: error.message }; return { message: data }; } catch (e) { return { error: String(e.message || e) }; } } // Mark every unread message FROM otherId TO me as read. No-op in demo. async function markThreadRead(otherId) { if (!isConfigured() || !otherId) return; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return; await client.from('messages') .update({ read_at: new Date().toISOString() }) .eq('recipient_id', user.id).eq('sender_id', otherId).is('read_at', null); } catch (e) {} } // Subscribe to INCOMING messages (recipient = me). onInsert(row) per new message. // Returns an unsubscribe fn. No-op in demo. function subscribeMessages(onInsert) { if (!isConfigured()) return () => {}; const client = sb(); let channel = null; (async () => { try { const { data: { user } } = await client.auth.getUser(); if (!user) return; channel = client.channel('dm-inbox-' + user.id) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: 'recipient_id=eq.' + user.id }, (payload) => { try { onInsert(payload.new); } catch (e) {} }) .subscribe(); } catch (e) {} })(); return () => { try { if (channel) client.removeChannel(channel); } catch (e) {} }; } // Join the shared presence channel and report the set of online handles. // onChange(Set) fires on every sync. Returns a leave fn. No-op in demo. function joinPresence(onChange) { if (!isConfigured()) return () => {}; const client = sb(); let channel = null; (async () => { try { const { data: { user } } = await client.auth.getUser(); const me = loadProfile(); const myHandle = (me && me.handle) || (user && user.id) || 'anon'; channel = client.channel('online-users', { config: { presence: { key: myHandle } } }); channel.on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); // Collect BOTH the handle AND the id of every present user, so the member // directory can be matched reliably — handles don't always line up between // a user's local profile and the DB, but the auth id always does. The live // count is the number of distinct presence keys (= distinct connected users). const ids = new Set(); Object.values(state).forEach(arr => arr.forEach(p => { if (p && p.handle) ids.add(p.handle); if (p && p.id) ids.add(p.id); })); onChange(ids, Object.keys(state).length); }); channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { try { await channel.track({ handle: myHandle, id: user ? user.id : null, online_at: new Date().toISOString() }); } catch (e) {} } }); } catch (e) {} })(); return () => { try { if (channel) client.removeChannel(channel); } catch (e) {} }; } // ── public: follow graph (live) ────────────────────────────────────────────── // Real followers/following backed by the `follows` table (see supabase/follows.sql). // A trigger keeps profiles.followers / following_count in sync, so the existing // count reads stay accurate. No-ops / null in demo mode (the app keeps its local // `following` list and the sample follower numbers). // Handles the current user follows. Null in demo. → array of handles. async function fetchFollowing() { if (!isConfigured()) return null; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return null; const { data, error } = await client.from('follows').select('following_id').eq('follower_id', user.id); if (error || !data) return []; const ids = data.map(r => r.following_id); const profMap = await _profilesByIds(ids); return ids.map(id => (profMap[id] ? profMap[id].handle : id)); } catch (e) { return []; } } // Follow targetId (a profile/auth id). Returns { ok } or { error }. No-op in demo. async function followUser(targetId) { if (!isConfigured()) return { demo: true }; if (!targetId) return { error: 'No target.' }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; if (user.id === targetId) return { error: "Can't follow yourself" }; const { error } = await client.from('follows').insert({ follower_id: user.id, following_id: targetId }); if (error && !/duplicate|unique/i.test(error.message)) return { error: error.message }; return { ok: true }; } catch (e) { return { error: String(e.message || e) }; } } // Unfollow targetId. Returns { ok } or { error }. No-op in demo. async function unfollowUser(targetId) { if (!isConfigured()) return { demo: true }; if (!targetId) return { error: 'No target.' }; const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) return { error: 'Not signed in' }; const { error } = await client.from('follows').delete().eq('follower_id', user.id).eq('following_id', targetId); if (error) return { error: error.message }; return { ok: true }; } catch (e) { return { error: String(e.message || e) }; } } // ── public: subscriptions / plans ──────────────────────────────────────────── // free / basic ($9.99) / vip ($19.99), 30-day periods activated on payment. // • LIVE — profiles.plan + plan_expires_at + benefit_redemptions, via the // subscribe()/my_entitlements()/claim_free_eval()/book_free_class() RPCs. // • DEMO — localStorage so the preview works with no backend. const PLAN_LS_KEY = 'launch.plan'; const BENEFIT_LS_KEY = 'launch.benefitusage'; const PLAN_LIMITS = { free: { quick_eval: 0, full_eval: 0, class: 0 }, basic: { quick_eval: 1, full_eval: 0, class: 0 }, vip: { quick_eval: 0, full_eval: 2, class: 1 }, }; function _loadPlanLS() { try { const r = JSON.parse(localStorage.getItem(PLAN_LS_KEY) || 'null'); if (r && typeof r === 'object') return r; } catch (e) {} return { plan: 'free', startedAt: null, expiresAt: null }; } function _savePlanLS(p) { try { localStorage.setItem(PLAN_LS_KEY, JSON.stringify(p)); } catch (e) {} } function _loadBenefitLS() { try { const r = JSON.parse(localStorage.getItem(BENEFIT_LS_KEY) || '[]'); return Array.isArray(r) ? r : []; } catch (e) { return []; } } function _saveBenefitLS(l) { try { localStorage.setItem(BENEFIT_LS_KEY, JSON.stringify(l)); } catch (e) {} } let _planCache = null; function _emptyEntitlements() { return { plan: 'free', active: false, expiresAt: null, quickEval: 0, fullEval: 0, class: 0 }; } function loadPlan() { return _planCache || _emptyEntitlements(); } function emitPlanChange() { try { window.dispatchEvent(new Event('launch.planchange')); } catch (e) {} } function _demoEntitlements() { const p = _loadPlanLS(); const active = !!(p.expiresAt && new Date(p.expiresAt).getTime() > Date.now()); const plan = active ? (p.plan || 'free') : 'free'; const lim = PLAN_LIMITS[plan] || PLAN_LIMITS.free; const usage = active ? _loadBenefitLS().filter(u => p.startedAt && new Date(u.ts).getTime() >= new Date(p.startedAt).getTime()) : []; const usedOf = (b) => usage.filter(u => u.benefit === b).length; return { plan, active, expiresAt: active ? p.expiresAt : null, quickEval: Math.max(0, lim.quick_eval - usedOf('quick_eval')), fullEval: Math.max(0, lim.full_eval - usedOf('full_eval')), class: Math.max(0, lim.class - usedOf('class')), }; } // The caller's plan + remaining free benefits this period. Updates _planCache. async function fetchEntitlements() { if (!isConfigured()) { _planCache = _demoEntitlements(); return _planCache; } const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (!user) { _planCache = _emptyEntitlements(); return _planCache; } const { data, error } = await client.rpc('my_entitlements'); if (!error && data) { const r = Array.isArray(data) ? (data[0] || {}) : data; _planCache = { plan: r.plan || 'free', active: !!r.active, expiresAt: r.expires_at || null, quickEval: num(r.quick_eval, 0), fullEval: num(r.full_eval, 0), class: num(r.class, 0), }; } } catch (e) {} return _planCache || _emptyEntitlements(); } // Activate a 30-day period for plan ('basic'|'vip'); 'free' cancels. async function subscribe(plan) { if (isConfigured()) { const client = sb(); try { const { data: { user } } = await client.auth.getUser(); if (user) { const { error } = await client.rpc('subscribe', { p_plan: plan }); if (error) return { error: error.message }; await fetchEntitlements(); try { await fetchLiveProfile(); } catch (e) {} emitPlanChange(); emitChange(); return { ok: true, plan, entitlements: _planCache }; } } catch (e) { return { error: String(e.message || e) }; } } // demo const now = new Date(); if (plan !== 'basic' && plan !== 'vip') { _savePlanLS({ plan: 'free', startedAt: null, expiresAt: null }); } else { const exp = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); _savePlanLS({ plan, startedAt: now.toISOString(), expiresAt: exp.toISOString() }); } _planCache = _demoEntitlements(); emitPlanChange(); return { ok: true, plan, entitlements: _planCache }; } // Use a plan's free evaluation on a submission. tier 'quick' (basic) | 'full' (vip). async function claimFreeEval(submissionId, tier) { if (isConfigured() && !String(submissionId).startsWith('demo')) { const client = sb(); try { const { data, error } = await client.rpc('claim_free_eval', { p_submission_id: submissionId, p_tier: tier }); if (error) return { ok: false, error: error.message }; const r = Array.isArray(data) ? (data[0] || {}) : data; if (r.ok) { await fetchEntitlements(); emitPlanChange(); } return { ok: !!r.ok, error: r.ok ? null : r.message }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } const ent = _demoEntitlements(); const benefit = tier === 'quick' ? 'quick_eval' : tier === 'full' ? 'full_eval' : null; const remaining = benefit === 'quick_eval' ? ent.quickEval : benefit === 'full_eval' ? ent.fullEval : 0; if (!benefit || remaining < 1) return { ok: false, error: 'No free evaluations left on your plan.' }; const list = _loadBenefitLS(); list.push({ benefit, ts: new Date().toISOString(), ref: submissionId }); _saveBenefitLS(list); _planCache = _demoEntitlements(); emitPlanChange(); return { ok: true }; } // VIP: book a free 20-min class with a coach. Returns { ok, bookingId } or { error }. async function bookFreeClass(coachId, startsAt, topic) { if (isConfigured()) { const client = sb(); try { const { data, error } = await client.rpc('book_free_class', { p_coach_id: coachId, p_starts_at: startsAt, p_topic: topic || null }); if (error) return { ok: false, error: error.message }; const r = Array.isArray(data) ? (data[0] || {}) : data; if (r.ok) { await fetchEntitlements(); emitPlanChange(); } return { ok: !!r.ok, error: r.ok ? null : r.message, bookingId: r.booking_id }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } const ent = _demoEntitlements(); if (ent.class < 1) return { ok: false, error: 'No free 20-min classes left on your plan.' }; const list = _loadBenefitLS(); list.push({ benefit: 'class', ts: new Date().toISOString() }); _saveBenefitLS(list); _planCache = _demoEntitlements(); emitPlanChange(); return { ok: true, bookingId: 'demo-class-' + Date.now() }; } // React hook → the current plan + entitlements, reactive to subscribe/claim. function usePlan() { const [ent, setEnt] = useStateU(loadPlan); useEffectU(() => { let alive = true; const refresh = () => { fetchEntitlements().then(() => { if (alive) setEnt({ ...(_planCache || _emptyEntitlements()) }); }); }; refresh(); window.addEventListener('launch.planchange', refresh); window.addEventListener('launch.profilechange', refresh); return () => { alive = false; window.removeEventListener('launch.planchange', refresh); window.removeEventListener('launch.profilechange', refresh); }; }, []); return ent; } window.LAUNCH_USER = { useUser, loadProfile, saveProfile, clearProfile, buildProfile, decorate, nameFromHandle, initialsFrom, MAYA, isConfigured, subscribe, fetchEntitlements, usePlan, loadPlan, claimFreeEval, bookFreeClass, usePoints, loadPoints, awardPoints, redeemReview, refreshPoints, pointsRules, reviewCost, reviewCreditCents: () => REVIEW_CREDIT_CENTS, payReviewWithCredit, fetchMembersDirectory, fetchThreads, sendMessageLive, markThreadRead, subscribeMessages, joinPresence, fetchFollowing, followUser, unfollowUser, signUp, signIn, signInWithProvider, completeOAuthSignIn, signOut, deleteAccount, authProviders, fetchLiveProfile, fetchLeaderboard, fetchMyWeeklyXp, fetchWeekBounds, weeklyPrizeStatus, claimWeeklyPrize, fetchFeed, togglePostLike, fetchComments, addComment, createPost, uploadTrack, rateTrack, fetchTrackRatings, trackAudioUrl, deleteTrack, hasAcceptedSongAgreement, acceptSongAgreement, songAgreementVersion: () => SONG_AGREEMENT_VERSION, songAgreementUrl: () => SONG_AGREEMENT_URL, uploadAvatar, removeAvatar, avatarUrlFor, submitSongForReview, fetchCoachQueue, fetchMySubmissions, fetchCoaches, coachByItem, audioUrlFor, evalVoiceUrl, saveEvaluation, fetchMyEvaluations, fetchArtistHistory, startCheckout, confirmCheckout, requestBooking, fetchMyBookings, fetchCoachAvailability, cancelBooking, fetchMyCredit, fetchMyReviewCredit, fetchHabitLog, setHabitDay, };