// 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
{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 */}
);
}
// ─────────────────────────────────────────────────────────────
// 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 */}
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}
);
})}
{/* 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
}
0 ? '#22C55E' : theme.muted, letterSpacing: 1, fontWeight: 600 }}>
{liveCount > 0
? `● ${liveCount} ${liveCount === 1 ? 'ARTIST' : 'ARTISTS'} ONLINE NOW`
: '● LIVE'}
{copy.communityTitle}
}
right={{Icon.plus(theme.text)}} />
{/* Guest gate — no active plan */}
{!canInteract &&
}
{/* Points & rewards — the social hub's hero */}
REDEEM FOR
$40 REVIEW
{cost} PTS · UNRELEASED SONG
{/* progress to next review */}
{canRedeem ? 'READY TO REDEEM!' : `${bal} / ${cost}`}
{pct}%
{/* redeem button */}
{creditCents > 0 &&
REVIEW CREDIT
${(creditCents / 100).toFixed(2)}
}
{redeemMsg &&
{redeemMsg.text}
}
{/* how to earn */}
{[
{ l: 'LIKE', p: rules.like },
{ l: 'COMMENT', p: rules.comment },
{ l: 'POST', p: rules.post },
{ l: 'UPLOAD SONG', p: rules.track },
{ l: 'RATE', p: rules.rate }].
map((r) =>
+{r.p}{r.l}
)}
{/* Active now — who's online */}
{onlineMembers.length > 0 &&
● {liveCount} ONLINE NOW
{onlineMembers.map((m) =>
)}
}
{/* Filter chips */}
{filters.map((f) =>
)}
{/* Upload your unreleased track (TRACKS filter) */}
{filter === 'tracks' &&
{!showUpload ?
:
UPLOAD UNRELEASED SONG
setTrackTitle(e.target.value)}
placeholder="Song title"
style={{
width: '100%', padding: '11px 13px', borderRadius: 10,
border: `1px solid ${theme.border}`, background: theme.surface2,
color: theme.text, fontFamily: theme.fontBody, fontSize: 14, outline: 'none', marginBottom: 10,
}} />
}
{uploadMsg &&
{uploadMsg.text}
}
}
{/* Posts */}
{posts.map((p, i) =>
{/* head */}
{onToggleFollow && !following.includes(p.name) && (
)}
{p.tag &&
{p.tag}}
{p.kind === 'track' && p.name === myHandle &&
}
{/* body */}
{p.title &&
{p.title}
}
{p.body}
{p.audio && }
{/* Peer rating (track posts only) — same /50 rubric as the $11 eval */}
{p.kind === 'track' && (() => {
const sum = trackRatings.summary[p.id];
const myTotal = (trackRatings.mine[p.id] != null) ? trackRatings.mine[p.id]
: (localRated[p.id] != null ? localRated[p.id] : null);
const isMine = p.name === myHandle;
const open = ratingOpen === p.id;
const liveTotal = Object.values(ratingScores).reduce((a, b) => a + (Number(b) || 0), 0);
return (
RATING
{sum && sum.votes ? sum.avg : '—'}/50
{sum && sum.votes ? sum.votes + ' vote' + (sum.votes === 1 ? '' : 's') : 'no votes'}
{isMine ?
YOUR SONG
: myTotal != null ?
YOUR SCORE: {myTotal}/50
:
}
{open && !isMine && myTotal == null &&
{RATE_RUBRIC.map(cat =>
{cat.label}
{ratingScores[cat.id]}/10
{[1,2,3,4,5,6,7,8,9,10].map(n => {
const on = ratingScores[cat.id] >= n;
return (
);
})}
)}
}
);
})()}
{/* actions */}
{(() => {
const isLiked = (p.id in liked) ? liked[p.id] : !!p.liked_by_me;
const likeDelta = (isLiked ? 1 : 0) - (p.liked_by_me ? 1 : 0);
const likeCount = Math.max(0, p.likes + likeDelta);
const allComments = [...(thread[p.id] || []), ...(added[p.id] || [])];
const commentCount = p.comments + (added[p.id] || []).length;
const commentsOpen = openComments === p.id;
return (
);
})()}
{/* comments thread */}
{openComments === p.id &&
{[...(thread[p.id] || []), ...(added[p.id] || [])].map((c, ci) =>
@{c.name}
{c.body}
)}
setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') postComment(p); }}
placeholder="Add a comment…"
style={{
flex: 1, minWidth: 0, padding: '10px 12px', borderRadius: 10,
border: `1px solid ${theme.border}`, background: theme.surface2,
color: theme.text, fontFamily: theme.fontBody, fontSize: 13, outline: 'none'
}} />
}
)}
{/* Consent gate — must be accepted before the upload form even opens */}
{agreementGate && window.LAUNCH_AGREEMENT &&
setAgreementGate(false)}
onAccepted={() => { setAgreementGate(false); setConsented(true); setShowUpload(true); }}
/>
}
);
}
// ─────────────────────────────────────────────────────────────
// 5. PROFILE
// ─────────────────────────────────────────────────────────────
function ProfileAvatar({ theme, initials = 'MS', color = '#C72820', photo: photoProp = null }) {
const U = window.LAUNCH_USER;
const [photo, setPhoto] = useState2(photoProp || null);
const [busy, setBusy] = useState2(false);
const fileRef = React.useRef(null);
// Keep in sync with the saved profile photo (loaded from the backend).
React.useEffect(() => { setPhoto(photoProp || null); }, [photoProp]);
const onPick = (e) => {
const file = e.target.files && e.target.files[0];
if (e.target) e.target.value = ''; // allow re-picking the same file
if (!file) return;
// Optimistic preview while it uploads.
const reader = new FileReader();
reader.onload = (ev) => setPhoto(ev.target.result);
reader.readAsDataURL(file);
setBusy(true);
Promise.resolve(U && U.uploadAvatar ? U.uploadAvatar(file) : { error: 'Unavailable' }).then((res) => {
setBusy(false);
if (res && res.error) { alert(res.error); setPhoto(photoProp || null); }
else if (res && res.url) setPhoto(res.url);
});
};
const removePhoto = (e) => {
e.stopPropagation();
setPhoto(null);
if (U && U.removeAvatar) U.removeAvatar();
};
return (
{photo && (
)}
);
}
function ProfileScreen({ theme, copy, logged, onToggleToday, onOpenReview, currentPlanId, planExpiresAt, planActive, onOpenPlans, onOpenMessages, onOpenSettings, onOpenPayment, onOpenBooking, onOpenClassBooking, onOpenSessions, onOpenEvaluations, onLogOut, unreadCount = 0, followingCount = 0 }) {
const ProReviewCTA = window.LAUNCH_SCREENS_3 && window.LAUNCH_SCREENS_3.ProReviewCTA;
const ProfilePlanCard = window.LAUNCH_SCREENS_4 && window.LAUNCH_SCREENS_4.ProfilePlanCard;
const user = window.LAUNCH_USER.useUser();
return (
@{user.handle.toUpperCase()}
}
right={<>
{onLogOut && (
)}
>} />
{/* Identity block */}
{user.tier}
{user.firstName}{user.lastName ? <>
{user.lastName}> : null}
{user.tierSub}
{user.bio}
{/* Follower / messages strip */}
{user.followers.toLocaleString()}
FOLLOWERS
{followingCount || 47}
FOLLOWING
{onOpenMessages && (
)}
{/* Plan card */}
{ProfilePlanCard && onOpenPlans &&
PLAN
{currentPlanId === 'vip' && planActive && onOpenClassBooking &&
}
}
{/* Habit tracker — full version */}
{/* Stats grid */}
{[
{ n: String(user.streak), l: 'STREAK', sub: 'CONSECUTIVE DAYS' },
{ n: String(user.reviewsReceived), l: 'REVIEWS', sub: 'RECEIVED' },
{ n: String(user.sessionsTotal), l: 'SESSIONS', sub: 'WITH MENTORS' },
{ n: String(user.releases), l: 'RELEASES', sub: 'OUT IN THE WILD' }].
map((s, i) =>
{s.n}
{s.l}
{s.sub}
)}
{/* Releases */}
RELEASES
{user.releases} OUT · {user.upcoming} UPCOMING
{[
{ lbl: 'WL', pal: ['#C72820', '#7A5AE0'], t: 'WINTER LIGHT' },
{ lbl: 'BD', pal: ['#CAFF33', '#C72820'], t: 'BLUE DOORS' },
{ lbl: 'HS', pal: ['#FBBF24', '#0A0A0A'], t: 'HALF SLEEP' },
{ lbl: 'OR', pal: ['#7A5AE0', '#FBBF24'], t: 'ORBIT' },
{ lbl: '??', pal: ['#1F1F1F', '#1F1F1F'], t: 'UNTITLED', upcoming: true },
{ lbl: '??', pal: ['#1F1F1F', '#1F1F1F'], t: 'UNTITLED', upcoming: true }].
map((r, i) =>
{r.upcoming &&
SOON
}
{r.t}
)}
{/* Pro review CTA */}
{ProReviewCTA && onOpenReview &&
}
{/* Sign out */}
{/* My booked sessions — entry point */}
{onOpenSessions && (
)}
{/* My reviews (coach feedback) — entry point */}
{onOpenEvaluations && (
)}
{/* Mentors */}
{window.LAUNCH_MENTORS && }
{/* (legacy mentor list — hidden) */}
{[
{ lbl: 'NK', name: 'Noah Kessler', role: 'Production', color: '#7A5AE0' },
{ lbl: 'JX', name: 'Jules Xavier', role: 'Sound design', color: '#C72820' },
{ lbl: 'AT', name: 'Aria Tao', role: 'Songwriting', color: '#CAFF33' }].
map((m, i, arr) =>
{m.lbl}
{m.name}
{m.role.toUpperCase()}
)}
);
}
window.LAUNCH_SCREENS_2 = { RoadmapScreen, FeedScreen, ProfileScreen, HabitTracker };