// screens2.jsx — Roadmap, Community, Profile + Habit Tracker const { useState: useState2, useEffect: useEffect2 } = React; const { HomeScreen: _HS } = window.LAUNCH_SCREENS_1; // ensure load order const { Card, Chip, CoverArt, Waveform, TopBar, IconBtn, DisplayHeading, Icon, Avatar } = window.LAUNCH_SCREENS_1; // ───────────────────────────────────────────────────────────── // HABIT TRACKER — songwriting log // 7 days × 5 weeks heatmap, with today highlighted, tap-to-log // ───────────────────────────────────────────────────────────── // The habit log maps a REAL date → 1 (wrote a song that day). Keyed by date, // not position, so marks stick to their actual day and the 5-week window slides // forward on its own. Persists in localStorage; today stays driven by the app's // `logged` state so the "LOG TODAY" button is the source of truth for today. const HABIT_KEY = 'launch.habit'; function habitDateKey(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } function habitStartOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function habitStartOfWeekMon(d) { const x = habitStartOfDay(d); const dow = (x.getDay() + 6) % 7; // Mon=0 … Sun=6 x.setDate(x.getDate() - dow); return x; } const HABIT_MONTHS_FULL = ['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']; // Build any month as a Monday-first calendar: a few leading blanks so day 1 // lands under the right weekday, then 1 → end of month. function habitMonth(year, month) { const offset = (new Date(year, month, 1).getDay() + 6) % 7; // blanks before the 1st (Mon-first) const daysInMonth = new Date(year, month + 1, 0).getDate(); const cells = []; for (let i = 0; i < offset; i++) cells.push(null); for (let day = 1; day <= daysInMonth; day++) cells.push(new Date(year, month, day)); return { cells, monthLabel: HABIT_MONTHS_FULL[month] + ' ' + year, monthAbbr: HABIT_MONTHS[month], }; } function loadHabit() { try { const r = JSON.parse(localStorage.getItem(HABIT_KEY) || 'null'); if (r && typeof r === 'object' && !Array.isArray(r)) return r; } catch (e) {} return {}; } function saveHabit(map) { try { localStorage.setItem(HABIT_KEY, JSON.stringify(map)); } catch (e) {} try { window.dispatchEvent(new Event('launch.habitchange')); } catch (e) {} } const HABIT_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; function habitShort(d) { return HABIT_MONTHS[d.getMonth()] + ' ' + d.getDate(); } function HabitTracker({ theme, compact = false, logged, onToggleToday }) { // Real "today" is fixed; `view` is the month being looked at (navigable). const [todayInfo] = useState2(() => { const t = habitStartOfDay(new Date()); return { today: t, todayKey: habitDateKey(t), y: t.getFullYear(), m: t.getMonth() }; }); const { today, todayKey } = todayInfo; const [view, setView] = useState2({ y: todayInfo.y, m: todayInfo.m }); const isCurrentMonth = view.y === todayInfo.y && view.m === todayInfo.m; const { cells, monthLabel, monthAbbr } = habitMonth(view.y, view.m); const goPrev = () => setView(v => { const d = new Date(v.y, v.m - 1, 1); return { y: d.getFullYear(), m: d.getMonth() }; }); const goNext = () => { if (isCurrentMonth) return; setView(v => { const d = new Date(v.y, v.m + 1, 1); return { y: d.getFullYear(), m: d.getMonth() }; }); }; const navBtn = { width: 18, height: 18, borderRadius: 6, padding: 0, background: theme.surface2, border: `1px solid ${theme.border}`, color: theme.text, fontFamily: theme.fontMono, fontSize: 12, fontWeight: 700, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const [map, setMap] = useState2(loadHabit); const firstSync = React.useRef(true); const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; const cell = compact ? 26 : 30; const gap = compact ? 4 : 5; const ping = () => { try { window.dispatchEvent(new Event('launch.habitchange')); } catch (e) {} }; // Load the saved log (Supabase in live mode, localStorage in demo) on mount, // and reload when another HabitTracker instance changes it. useEffect2(() => { let alive = true; const load = () => window.LAUNCH_USER.fetchHabitLog().then(m => { if (alive) setMap(m || {}); }); load(); window.addEventListener('launch.habitchange', load); window.addEventListener('storage', load); return () => { alive = false; window.removeEventListener('launch.habitchange', load); window.removeEventListener('storage', load); }; }, []); // Today's cell follows the app's LOG TODAY button. Skip the first run so we // don't overwrite the value we just loaded before the user touches anything. useEffect2(() => { if (firstSync.current) { firstSync.current = false; return; } const want = logged ? 1 : 0; setMap(m => ((m[todayKey] ? 1 : 0) === want ? m : { ...m, [todayKey]: want })); window.LAUNCH_USER.setHabitDay(todayKey, !!logged).then(res => { if (res && res.error) console.warn('[LAUNCH] habit not synced:', res.error); ping(); }); }, [logged]); const toggleDate = (key, future) => { if (future) return; // day hasn't happened yet if (key === todayKey) { onToggleToday && onToggleToday(); return; } // today → app state (effect persists) const on = !map[key]; setMap(m => ({ ...m, [key]: on ? 1 : 0 })); // optimistic window.LAUNCH_USER.setHabitDay(key, on).then(res => { if (res && res.error) console.warn('[LAUNCH] habit not synced:', res.error); ping(); }); }; // streak: consecutive logged days counting back from today let streak = 0; for (let d = new Date(today); map[habitDateKey(d)]; d.setDate(d.getDate() - 1)) streak++; const total = cells.reduce((a, d) => a + (d && map[habitDateKey(d)] ? 1 : 0), 0); return ( {/* header */}
SONGWRITING HABIT
{monthLabel}
{streak > 0 ? <>{streak} DAYS IN A ROW : <>LOG TODAY }
{total}
SONGS · {monthAbbr}
{/* day labels */}
{days.map((d, i) =>
{d}
)}
{/* grid */}
{cells.map((d, i) => { if (!d) return
; const key = habitDateKey(d); const isToday = key === todayKey; const future = key > todayKey; const wrote = !!map[key]; return ( ); })}
{/* CTA strip */}
WROTE A SONG
); } // ───────────────────────────────────────────────────────────── // 3. ROADMAP — career journey // ───────────────────────────────────────────────────────────── function RoadmapScreen({ theme, copy }) { const phases = [ { n: '01', title: 'FIND YOUR SOUND', sub: 'Identity · references · niche', state: 'done', goals: 4, done: 4 }, { n: '02', title: 'BUILD THE TOOLKIT', sub: 'DAW · mixing · songwriting reps', state: 'done', goals: 6, done: 6 }, { n: '03', title: 'FIRST RELEASE', sub: 'Single ready for distribution', state: 'current', goals: 5, done: 3 }, { n: '04', title: 'GROW AN AUDIENCE', sub: 'Content · socials · email list', state: 'next', goals: 5, done: 0 }, { n: '05', title: 'FIRST LIVE SHOW', sub: 'Set · rehearsal · booking', state: 'locked', goals: 4, done: 0 }, { n: '06', title: 'LAUNCH PROJECT', sub: 'EP · campaign · press', state: 'locked', goals: 7, done: 0 }]; const goals = [ { t: 'Finalize cover art', done: true }, { t: 'Master the single', done: true }, { t: 'Distribute via DistroKid', done: true }, { t: 'Pitch to 5 playlists', done: false, due: 'FRI' }, { t: 'Schedule release day post', done: false, due: 'NEXT WEEK' }]; return (
{copy.roadmapKicker}
{copy.roadmapTitle}
} right={{Icon.plus(theme.text)}} /> {/* Overall progress */}
OVERALL PROGRESS
38%
13 of 31 goals hit. Phase 03 is up next →
{/* Phase ladder */}
PHASES
{/* spine */}
{phases.map((p, i) => { const colors = { done: { dot: theme.accent, ring: 'transparent', txt: theme.text, sub: theme.muted }, current: { dot: theme.text, ring: theme.accent, txt: theme.text, sub: theme.muted }, next: { dot: theme.surface2, ring: theme.border, txt: theme.text, sub: theme.muted }, locked: { dot: theme.surface2, ring: theme.border, txt: theme.faint, sub: theme.faint } }[p.state]; return (
{p.state === 'done' ? Icon.check(theme.onAccent) : p.state === 'locked' ? Icon.lock(theme.faint) : {p.n}}
{p.title}
{p.state === 'current' && ACTIVE} {p.state === 'done' && COMPLETE }
{p.sub}
{p.done}/{p.goals}
); })}
{/* Active phase goals */}
PHASE 03 · GOALS
{goals.map((g, i) =>
{g.done && Icon.check(theme.onAccent)}
{g.t}
{g.due && {g.due}}
)}
); } // ───────────────────────────────────────────────────────────── // 4. COMMUNITY FEED // ───────────────────────────────────────────────────────────── // Sample posts shown in demo mode (no Supabase keys) and as a fallback. const DEMO_POSTS = [ { kind: 'track', avatarLbl: 'KP', avatarColor: '#C72820', name: 'kai.park', sub: '2H · drop · mixed today', title: 'NEON HOURS (rough mix)', coverPal: ['#C72820', '#0A0A0A'], coverLbl: 'KP', coverSub: 'rough · 2:47', body: 'mixed this in 3 hrs after today\'s lesson — gain staging changed everything fr', audio: true, likes: 47, comments: 12 }, { kind: 'win', avatarLbl: 'SV', avatarColor: '#CAFF33', name: 'sasha.vee', sub: '5H · win 🚀', body: 'GOT ON AN OFFICIAL SPOTIFY EDITORIAL!! "fresh finds" 🥹 thanks to Noah for the pitch template lesson', tag: 'PLAYLIST PLACE', likes: 312, comments: 48 }, { kind: 'ask', avatarLbl: 'TM', avatarColor: '#7A5AE0', name: 'theo.m', sub: '6H · ask', body: 'what\'s everyone using for vocal compression rn? been trying to get that pillowy r&b feel and my track sounds flat 😭', tag: 'NEED ADVICE', likes: 28, comments: 31 }, { kind: 'track', avatarLbl: 'IL', avatarColor: '#FBBF24', name: 'isla.loop', sub: 'yesterday · feedback wanted', title: 'LOW TIDE', coverPal: ['#FBBF24', '#7A5AE0'], coverLbl: 'IL', coverSub: 'final · 3:18', body: 'mastered version of low tide. brutally honest feedback only pls', audio: true, likes: 89, comments: 24 }]; const FEED_COLORS = ['#C72820', '#7A5AE0', '#CAFF33', '#FBBF24', '#22C55E', '#EC4899', '#06B6D4', '#F97316']; function feedColorFor(handle) { let h = 0; for (let i = 0; i < (handle || '').length; i++) h = (h * 31 + handle.charCodeAt(i)) >>> 0; return FEED_COLORS[h % FEED_COLORS.length]; } function feedInitials(name, handle) { const U = window.LAUNCH_USER; if (U && U.initialsFrom) return U.initialsFrom(name, handle); return (handle || '??').slice(0, 2).toUpperCase(); } function feedRelTime(iso) { try { const then = new Date(iso).getTime(); const mins = Math.max(0, Math.round((Date.now() - then) / 60000)); if (mins < 60) return mins + 'M'; const hrs = Math.round(mins / 60); if (hrs < 24) return hrs + 'H'; return Math.round(hrs / 24) + 'D'; } catch (e) { return 'NOW'; } } // Map a live `feed_posts` row into the shape the card renderer expects. function mapLivePost(r, theme) { return { id: r.id, kind: r.kind || 'track', name: r.author_handle || 'artist', avatarLbl: feedInitials(r.author_name, r.author_handle), avatarColor: feedColorFor(r.author_handle || ''), sub: feedRelTime(r.created_at) + ' · ' + (r.kind || 'post'), title: r.title || '', body: r.body || '', tag: r.tag || '', audio: !!r.audio, audioPath: r.audio_path || null, author_id: r.author_id || null, coverPal: (r.cover_pal && r.cover_pal.length) ? r.cover_pal : [theme.accent, '#0A0A0A'], coverLbl: r.cover_lbl || feedInitials(r.author_name, r.author_handle), coverSub: r.cover_sub || '', likes: r.like_count || 0, comments: r.comment_count || 0, liked_by_me: !!r.liked_by_me, }; } // Peer-rating rubric — the SAME 5 categories as the $11 evaluation (each 1–10 → /50). const RATE_RUBRIC = [ { id: 'structure', label: 'SONG STRUCTURE' }, { id: 'hook', label: 'TITLE / HOOK' }, { id: 'idea', label: 'IDEA' }, { id: 'lyrics', label: 'LYRICS' }, { id: 'melody', label: 'MELODY' }, ]; const RATE_ZERO_SCORES = { structure: 7, hook: 7, idea: 7, lyrics: 7, melody: 7 }; // m:ss for the player clock. function fmtTime(s) { s = Math.max(0, Math.floor(s || 0)); const m = Math.floor(s / 60); const ss = s % 60; return m + ':' + (ss < 10 ? '0' : '') + ss; } // Real audio player for an uploaded track: play/pause toggle, live progress, // and tap-to-seek on the waveform. The signed URL is resolved lazily on the // first play/seek so a long feed doesn't fetch every track up front. function TrackPlayer({ theme, post }) { const U = window.LAUNCH_USER; const audioRef = React.useRef(null); const pendingSeekRef = React.useRef(null); // frac to apply once metadata loads const [url, setUrl] = useState2(null); const [loading, setLoading] = useState2(false); const [playing, setPlaying] = useState2(false); const [cur, setCur] = useState2(0); const [dur, setDur] = useState2(0); const hasAudio = !!(post.audioPath && U && U.trackAudioUrl); const ensureUrl = () => { if (url || loading || !hasAudio) return; setLoading(true); U.trackAudioUrl(post.audioPath).then(u => { setLoading(false); if (u) setUrl(u); }); }; const onLoadedMeta = () => { const el = audioRef.current; if (!el) return; setDur(el.duration || 0); if (pendingSeekRef.current != null && el.duration > 0) { el.currentTime = Math.max(0, Math.min(el.duration, pendingSeekRef.current * el.duration)); setCur(el.currentTime); pendingSeekRef.current = null; } if (playing && el.paused) el.play().catch(() => {}); }; const toggle = () => { if (!hasAudio) return; const el = audioRef.current; if (!url) { setPlaying(true); ensureUrl(); return; } // play once the src loads (onLoadedMeta) if (!el) return; if (el.paused) el.play().catch(() => {}); else el.pause(); }; const seekFrac = (frac) => { if (!hasAudio) return; frac = Math.max(0, Math.min(1, frac)); const el = audioRef.current; if (!url || !el || !(el.duration > 0)) { pendingSeekRef.current = frac; ensureUrl(); return; } el.currentTime = frac * el.duration; setCur(el.currentTime); }; const frac = dur > 0 ? cur / dur : 0; return (
{ const r = e.currentTarget.getBoundingClientRect(); seekFrac((e.clientX - r.left) / r.width); }} title={hasAudio ? 'Tap to seek' : ''} style={{ flex: 1, cursor: hasAudio ? 'pointer' : 'default' }}>
{fmtTime(cur)} / {dur > 0 ? fmtTime(dur) : ((post.coverSub && post.coverSub.match(/\d+:\d\d/)) ? post.coverSub.match(/\d+:\d\d/)[0] : '0:00')} {hasAudio && url &&
); } function FeedScreen({ theme, copy, onOpenMember, following = [], onToggleFollow, members = null, online = null, onlineCount = 0, liveMode = false, canInteract = true, onNeedPlan }) { const U = window.LAUNCH_USER; // No active plan → can browse but not interact; nudge to subscribe instead. const requirePlan = () => { if (!canInteract) { onNeedPlan && onNeedPlan(); return false; } return true; }; const [filter, setFilter] = useState2('all'); // Feed posts — start with samples; replaced by live rows in live mode. const [feed, setFeed] = useState2(() => DEMO_POSTS.map((p, i) => ({ id: 'demo-' + i, ...p }))); const [liked, setLiked] = useState2({}); // { [postId]: bool } — current desired like state const [thread, setThread] = useState2({}); // { [postId]: [{ name, body }] } existing comments const [added, setAdded] = useState2({}); // { [postId]: [{ name, body }] } added this session const [openComments, setOpenComments] = useState2(null); // post id whose thread is open const [draft, setDraft] = useState2(''); const [showComposer, setShowComposer] = useState2(false); // in-app "new post" composer const [composerText, setComposerText] = useState2(''); const points = U.usePoints(); // reactive points balance const [redeemMsg, setRedeemMsg] = useState2(null); const [redeeming, setRedeeming] = useState2(false); // guards against double-redeem const [creditCents, setCreditCents] = useState2(0); // real account credit (live) useEffect2(() => { let alive = true; const load = () => { if (U.fetchMyReviewCredit) U.fetchMyReviewCredit().then(c => { if (alive) setCreditCents(c || 0); }); }; load(); window.addEventListener('launch.creditchange', load); return () => { alive = false; window.removeEventListener('launch.creditchange', load); }; }, []); // Peer tracks: upload composer + per-track ratings const [trackRatings, setTrackRatings] = useState2({ summary: {}, mine: {} }); const [localRated, setLocalRated] = useState2({}); // { postId: myTotal } (demo + optimistic) const [showUpload, setShowUpload] = useState2(false); const [trackTitle, setTrackTitle] = useState2(''); const [uploading, setUploading] = useState2(false); const [uploadMsg, setUploadMsg] = useState2(null); const [agreementGate, setAgreementGate] = useState2(false); // consent gate before upload const [consented, setConsented] = useState2(false); // read+agreed THIS upload — re-prompts every time const trackFileRef = React.useRef(null); const [ratingOpen, setRatingOpen] = useState2(null); // postId whose rating form is open const [ratingScores, setRatingScores] = useState2(RATE_ZERO_SCORES); useEffect2(() => { let alive = true; const load = () => { if (U.fetchTrackRatings) U.fetchTrackRatings().then(r => { if (alive && r) setTrackRatings(r); }); }; load(); return () => { alive = false; }; }, []); // Opening the upload form: show the Song Submission Agreement FIRST if the // user hasn't accepted it yet — they can't even reach the file picker until // they read it and tick "I agree". const openUpload = async () => { if (!requirePlan()) return; setUploadMsg(null); // Prompt on EVERY upload (per-session `consented` flag), not just the first. if (!consented) { setAgreementGate(true); return; } setShowUpload(true); }; // Upload an unreleased track (+10 to the uploader). Re-checks the agreement as // a safety net (the entry gate normally handles it). const submitUpload = async () => { if (!requirePlan()) return; const title = trackTitle.trim(); if (!title) { setUploadMsg({ ok: false, text: 'Give your song a title.' }); return; } if (!consented) { setUploadMsg(null); setAgreementGate(true); return; } doUpload(); }; // The actual upload — only reached once the agreement is accepted. const doUpload = () => { const title = trackTitle.trim(); const file = trackFileRef.current && trackFileRef.current.files && trackFileRef.current.files[0]; setUploading(true); U.uploadTrack({ title, audioFile: file }).then(res => { setUploading(false); if (res && res.post) { const p = res.post; const local = { id: p.id, kind: 'track', name: myHandle, avatarLbl: feedInitials(me && me.displayName, myHandle), avatarColor: feedColorFor(myHandle), sub: 'NOW · track', title: p.title || title, body: '', tag: 'UNRELEASED', audio: true, audioPath: p.audio_path || null, author_id: p.author_id || null, coverPal: [theme.accent, '#0A0A0A'], coverLbl: feedInitials(me && me.displayName, myHandle), coverSub: 'unreleased', likes: 0, comments: 0, liked_by_me: false, }; setFeed(prev => [local, ...prev]); setTrackTitle(''); if (trackFileRef.current) trackFileRef.current.value = ''; setShowUpload(false); setConsented(false); // re-prompt the agreement on the next upload setUploadMsg({ ok: true, text: 'Song uploaded! +10 points. Other artists can now rate it.' }); } else { setUploadMsg({ ok: false, text: (res && res.error) || "Couldn't upload." }); } }); }; // Submit a peer rating (+10 to the rater). const submitRating = (post) => { if (!requirePlan()) return; const id = post.id; const scores = { ...ratingScores }; const total = Object.values(scores).reduce((a, b) => a + (Number(b) || 0), 0); U.rateTrack(id, scores).then(res => { if (res && res.ok) { setLocalRated(prev => ({ ...prev, [id]: res.total != null ? res.total : total })); setTrackRatings(prev => { const s = { ...prev.summary }; const cur = s[id] || { votes: 0, avg: 0 }; const votes = cur.votes + 1; const avg = Math.round(((cur.avg * cur.votes) + (res.total != null ? res.total : total)) / votes); s[id] = { votes, avg }; return { summary: s, mine: { ...prev.mine, [id]: res.total != null ? res.total : total } }; }); setRatingOpen(null); setRatingScores(RATE_ZERO_SCORES); } else { setLocalRated(prev => ({ ...prev, [id]: prev[id] })); window.alert((res && res.error) || "Couldn't submit rating."); } }); }; // Delete one of the current user's uploaded tracks. Removes it from the feed // and takes back the +10 points earned for uploading it. const removeTrack = (post) => { if (!requirePlan()) return; if (!window.confirm('Delete this song? You will lose the 10 points you earned for uploading it.')) return; const id = post.id; setFeed(prev => prev.filter(x => x.id !== id)); // optimistic remove if (U && U.deleteTrack) { U.deleteTrack(post).then(res => { if (res && res.error) { setFeed(prev => [post, ...prev]); // restore on failure window.alert(res.error); } }); } }; const me = U && U.loadProfile ? U.loadProfile() : null; const myHandle = (me && me.handle) || 'you'; const isLive = (postId) => !String(postId).startsWith('demo-'); // Pull live posts from Supabase (no-op / null in demo mode → keep samples). useEffect2(() => { let alive = true; if (U && U.fetchFeed) { U.fetchFeed().then(rows => { if (!alive || !rows) return; setFeed(rows.map(r => mapLivePost(r, theme))); const lk = {}; rows.forEach(r => { if (r.liked_by_me) lk[r.id] = true; }); setLiked(lk); }); } return () => { alive = false; }; }, []); const toggleLike = (post) => { if (!requirePlan()) return; const id = post.id; const now = (id in liked) ? liked[id] : !!post.liked_by_me; const next = !now; setLiked(prev => ({ ...prev, [id]: next })); if (U && U.awardPoints) U.awardPoints('like', { undo: !next, meta: { postId: id } }); if (U && U.togglePostLike && isLive(id)) { U.togglePostLike(id, now).then(res => { if (res && res.error) setLiked(prev => ({ ...prev, [id]: now })); // revert on failure }); } }; const openThread = (post) => { const id = post.id; if (openComments === id) { setOpenComments(null); return; } setOpenComments(id); setDraft(''); if (U && U.fetchComments && isLive(id) && !thread[id]) { U.fetchComments(id).then(rows => { if (!rows) return; setThread(prev => ({ ...prev, [id]: rows.map(c => ({ name: c.author_handle || 'artist', body: c.body })) })); }); } }; const postComment = (post) => { if (!requirePlan()) return; const body = draft.trim(); if (!body) return; const id = post.id; setAdded(prev => ({ ...prev, [id]: [...(prev[id] || []), { name: myHandle, body }] })); setDraft(''); if (U && U.awardPoints) U.awardPoints('comment', { meta: { postId: id } }); if (U && U.addComment && isLive(id)) { U.addComment(id, body).then(res => { if (res && res.error) { /* keep optimistic comment; could surface error */ } }); } }; // Create a new post. Opens the in-app composer (no external browser prompt). const newPost = () => { if (!requirePlan()) return; setComposerText(''); setShowComposer(true); }; // Publish what the in-app composer holds. Prepends optimistically to the feed. const submitPost = () => { const body = composerText.trim(); if (!body) return; setShowComposer(false); setComposerText(''); if (U && U.awardPoints) U.awardPoints('post', { meta: { body } }); const local = { id: 'demo-new-' + (feed.length + 1), kind: 'win', name: myHandle, avatarLbl: feedInitials(me && me.displayName, myHandle), avatarColor: feedColorFor(myHandle), sub: 'NOW · win', body, title: '', tag: '', audio: false, likes: 0, comments: 0, liked_by_me: false, }; if (U && U.createPost && U.isConfigured && U.isConfigured()) { U.createPost({ kind: 'win', body }).then(res => { if (res && res.post && res.post.id) { // Use the real DB id (so likes/comments persist) + local display info. setFeed(prev => [{ ...local, id: res.post.id }, ...prev]); } else { setFeed(prev => [local, ...prev]); // fallback so the user still sees it } }); } else { setFeed(prev => [local, ...prev]); } }; const filters = [ { id: 'all', l: 'ALL' }, { id: 'tracks', l: 'TRACKS' }]; // Filter chips actually filter now (track/ask/win → kind). const kindForFilter = { tracks: 'track' }; const posts = filter === 'all' ? feed : feed.filter(p => p.kind === kindForFilter[filter]); // Points / rewards economy const cost = U.reviewCost ? U.reviewCost() : 200; const bal = Math.max(0, (points && points.balance) || 0); const pct = Math.min(100, Math.round((bal / cost) * 100)); const canRedeem = bal >= cost; const redeemBtnOn = canRedeem && !redeeming; const rules = U.pointsRules ? U.pointsRules() : { like: 1, comment: 2, post: 5, song: 10 }; const onRedeem = () => { if (!requirePlan()) return; if (redeeming) return; // a redemption is already in flight if (!canRedeem) { setRedeemMsg({ ok: false, text: `You need ${cost - bal} more points to redeem.` }); return; } setRedeeming(true); U.redeemReview().then((res) => { setRedeeming(false); if (res && res.ok) { if (res.grantedCents) { setCreditCents(res.creditCents || 0); setRedeemMsg({ ok: true, text: `Done! $${(res.grantedCents / 100).toFixed(0)} credit added to your account — use it free on your next review. ${cost} pts deducted.` }); } else { setRedeemMsg({ ok: true, text: `Done! You redeemed a $40 review — ${cost} pts deducted. A coach will take it soon.` }); } } else { setRedeemMsg({ ok: false, text: (res && res.error) || "Couldn't redeem." }); } }); }; // Who's online right now — live directory (presence) when available, else the // sample directory from social.jsx. const S5 = window.LAUNCH_SCREENS_5; const memberDir = members || (S5 && S5.MEMBERS) || {}; const onlineMembers = Object.values(memberDir).filter(m => m && m.online); // How many people are connected live right now (Supabase Realtime presence). // The `online` set (from app.jsx) is the source of truth and includes me; fall // back to the directory's online flags when the raw set isn't wired/synced yet. // Distinct connected users (from presence). `online` now mixes handles+ids for // matching, so we count via onlineCount, not online.size. const liveCount = onlineCount > 0 ? onlineCount : onlineMembers.length; return (
{/* In-app "new post" composer — replaces the old browser prompt */} {showComposer &&
setShowComposer(false)} style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', }}>
e.stopPropagation()} style={{ width: '100%', maxWidth: 520, background: theme.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, border: `1px solid ${theme.border}`, borderBottom: 'none', padding: 20, paddingBottom: 28, }}>
Share with the community