// review-queue.jsx — Coach side of song submissions. // CoachQueueScreen: list incoming submissions → open one → listen → score with // the 5-category rubric + verdict → submit. Reads/writes via window.LAUNCH_USER // (Supabase in live mode, a shared localStorage list in demo mode). const { useState: useStateRQ, useEffect: useEffectRQ } = React; const { Card, Chip, DisplayHeading } = window.LAUNCH_SCREENS_1; // Same rubric the member is scored against (evaluations.jsx). const RQ_RUBRIC = [ { id: 'structure', label: 'SONG STRUCTURE' }, { id: 'hook', label: 'TITLE / HOOK' }, { id: 'idea', label: 'IDEA' }, { id: 'lyrics', label: 'LYRICS' }, { id: 'melody', label: 'MELODY' }, ]; const RQ_ZERO_SCORES = { structure: 7, hook: 7, idea: 7, lyrics: 7, melody: 7 }; const RQ_ZERO_NOTES = { structure: '', hook: '', idea: '', lyrics: '', melody: '' }; function rqStatusColor(theme, status) { if (status === 'reviewed') return '#22C55E'; if (status === 'in_review') return '#E8A736'; return theme.accent; // pending } function rqWhen(ts) { if (!ts) return ''; try { const d = new Date(ts); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }).toUpperCase() + ' · ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).toUpperCase(); } catch (e) { return ''; } } function CoachQueueScreen({ theme, onBack }) { const [queue, setQueue] = useStateRQ([]); const [loading, setLoading] = useStateRQ(true); const [active, setActive] = useStateRQ(null); // submission being reviewed const [audioUrl, setAudioUrl] = useStateRQ(null); const [scores, setScores] = useStateRQ(RQ_ZERO_SCORES); const [notes, setNotes] = useStateRQ(RQ_ZERO_NOTES); const [verdict, setVerdict] = useStateRQ(''); const [saving, setSaving] = useStateRQ(false); const [saveErr, setSaveErr] = useStateRQ(null); const [historyMember, setHistoryMember] = useStateRQ(null); // { id, name } | null // Coach voice note (In-Depth / $40 tier) — recorded in-browser, private to the member. const [voiceBlob, setVoiceBlob] = useStateRQ(null); const [voiceUrl, setVoiceUrl] = useStateRQ(null); const [recording, setRecording] = useStateRQ(false); const [recSec, setRecSec] = useStateRQ(0); const recorderRef = React.useRef(null); const chunksRef = React.useRef([]); const refresh = () => { setLoading(true); window.LAUNCH_USER.fetchCoachQueue().then(rows => { setQueue(Array.isArray(rows) ? rows : []); setLoading(false); }); }; useEffectRQ(() => { refresh(); }, []); // When a submission is opened, fetch a playable URL (live mode only). useEffectRQ(() => { let alive = true; setAudioUrl(null); if (active && active.audio_path) { window.LAUNCH_USER.audioUrlFor(active.audio_path).then(u => { if (alive) setAudioUrl(u); }); } return () => { alive = false; }; }, [active && active.id]); const total = RQ_RUBRIC.reduce((sum, c) => sum + (Number(scores[c.id]) || 0), 0); // Tick the recording timer while capturing. useEffectRQ(() => { if (!recording) return; const t = setInterval(() => setRecSec(s => s + 1), 1000); return () => clearInterval(t); }, [recording]); const clearVoice = () => { setVoiceBlob(null); setVoiceUrl(u => { if (u) URL.revokeObjectURL(u); return null; }); setRecSec(0); }; const startRec = async () => { setSaveErr(null); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mr = new MediaRecorder(stream); chunksRef.current = []; mr.ondataavailable = e => { if (e.data && e.data.size) chunksRef.current.push(e.data); }; mr.onstop = () => { const blob = new Blob(chunksRef.current, { type: mr.mimeType || 'audio/webm' }); setVoiceBlob(blob); setVoiceUrl(URL.createObjectURL(blob)); stream.getTracks().forEach(t => t.stop()); }; recorderRef.current = mr; mr.start(); setRecSec(0); setRecording(true); } catch (e) { setSaveErr('Mic access denied'); } }; const stopRec = () => { const mr = recorderRef.current; if (mr && mr.state !== 'inactive') mr.stop(); setRecording(false); }; const openOne = (s) => { setActive(s); setScores(RQ_ZERO_SCORES); setNotes(RQ_ZERO_NOTES); setVerdict(''); setSaveErr(null); clearVoice(); }; const submitEval = async () => { setSaving(true); setSaveErr(null); const res = await window.LAUNCH_USER.saveEvaluation(active.id, { scores, notes, verdict, total, voiceNote: voiceBlob, memberId: active.member_id, }); setSaving(false); if (res && res.error) { setSaveErr(res.error); return; } clearVoice(); setActive(null); refresh(); }; const pendingCount = queue.filter(s => s.status !== 'reviewed').length; // ───────── ARTIST HISTORY ───────── if (historyMember) { return setHistoryMember(null)} />; } // ───────── DETAIL / SCORING ───────── if (active) { const memberName = active.member_name || active.member_handle || 'Member'; return (
★ COACH REVIEW
FROM {String(memberName).toUpperCase()} · {(active.tier || 'quick').toUpperCase()}
{active.title || 'UNTITLED'} {active.member_id && ( )}
{/* Audio */}
{audioUrl ? (
{/* Member's ask */} {active.notes ? (
WANTS FEEDBACK ON
{active.notes}
) : null} {/* Rubric scoring */}
{RQ_RUBRIC.map(cat => (
{cat.label} {scores[cat.id]}/10
{[1,2,3,4,5,6,7,8,9,10].map(n => { const on = scores[cat.id] >= n; return ( ); })}
setNotes(nn => ({ ...nn, [cat.id]: e.target.value }))} placeholder={`Note on ${cat.label.toLowerCase()} (optional)`} style={{ width: '100%', marginTop: 8, padding: '10px 12px', borderRadius: 10, background: theme.surface, border: `1px solid ${theme.border}`, color: theme.text, fontFamily: theme.fontBody, fontSize: 13, outline: 'none' }} />
))} {/* Verdict */}
VERDICT · THE BOTTOM LINE