// screens3.jsx — Pro Review (paid evaluation service) const { useState: useState3 } = React; const { Card, Chip, CoverArt, Waveform, TopBar, IconBtn, DisplayHeading, Icon } = window.LAUNCH_SCREENS_1; const TIERS = [ { id: 'quick', name: 'QUICK SCORE', price: 11, eta: '48 HOURS', blurb: 'Fast point-based score. 5 categories, 1\u201310 each. Hit 40+/50 to unlock rewards.', perks: [ 'Song Structure \u00b7 1\u201310', 'Title / Hook \u00b7 1\u201310', 'Idea \u00b7 1\u201310', 'Lyrics \u00b7 1\u201310', 'Melody \u00b7 1\u201310', 'Score 40+ \u2192 rewards & bonus XP' ], accent: false }, { id: 'full', name: 'IN-DEPTH EVAL', price: 40, eta: '5 DAYS', blurb: 'Deep written breakdown + voice note from a Launch mentor. Mix, songwriting, vocals, arrangement.', perks: [ 'Mentor-matched to your genre', 'Written breakdown (~600 words)', '5-min voice walkthrough', 'Stems-level feedback', 'Full rubric (out of 50)' ], accent: true, badge: 'MOST PICKED' }]; const REVIEWERS = [ { lbl: 'JB', color: '#C72820', name: 'Jay Brunswick', displayName: 'Jay', role: 'RIAA Gold · 170+ cuts · 2 Billboard Top 40' }, { lbl: 'PG', color: '#7A5AE0', name: 'PG Banker', displayName: 'PG', role: 'Logic Pro certified · 1,000+ placements' }]; const RECENT_REVIEWS = [ { avatar: 'KP', color: '#C72820', name: 'kai.park', track: 'NEON HOURS', tier: 'IN-DEPTH EVAL', quote: '"Top-line is sticky as hell. Pull the 2k from your vocal bus — it\'s fighting the synth lead."', reviewer: 'JAY BRUNSWICK', stars: 5 }, { avatar: 'IL', color: '#FBBF24', name: 'isla.loop', track: 'LOW TIDE', tier: 'A&R SUBMIT', quote: '"Scored 9.1 — pitched to two labels. Hold tight."', reviewer: 'A&R PANEL', stars: 5, ar: true }]; function Stars({ n, color, dim }) { return (
{[0, 1, 2, 3, 4].map((i) => )}
); } // ───────────────────────────────────────────────────────────── // Pro Review screen — 2 modes: 'browse' and 'submit' // ───────────────────────────────────────────────────────────── function ReviewScreen({ theme, copy, onBack, onOpenEvaluations }) { const [mode, setMode] = useState3('browse'); // 'browse' | 'submit' | 'success' const [tier, setTier] = useState3('full'); const [trackTitle, setTrackTitle] = useState3(''); const [notes, setNotes] = useState3(''); const [trackUploaded, setTrackUploaded] = useState3(false); const [audioFile, setAudioFile] = useState3(null); const [coachLbl, setCoachLbl] = useState3('JB'); const [reviewers, setReviewers] = useState3(REVIEWERS); // demo fallback until the table loads const [submitting, setSubmitting] = useState3(false); const [submitErr, setSubmitErr] = useState3(null); const [agreementGate, setAgreementGate] = useState3(false); // consent gate before upload const [consented, setConsented] = useState3(false); // read+agreed THIS submission — re-prompts on every upload const [creditCents, setCreditCents] = useState3(0); // review-only credit from redeemed points React.useEffect(() => { let alive = true; if (window.LAUNCH_USER.fetchMyReviewCredit) window.LAUNCH_USER.fetchMyReviewCredit().then(c => { if (alive) setCreditCents(c || 0); }); return () => { alive = false; }; }, []); const fileRef = React.useRef(null); // Load the admin-managed coach roster (Supabase `coaches` table). Falls back // to the built-in REVIEWERS list in demo mode / on error. React.useEffect(() => { let alive = true; window.LAUNCH_USER.fetchCoaches().then((list) => { if (!alive || !list || !list.length) return; setReviewers(list); setCoachLbl((prev) => list.some((r) => r.lbl === prev) ? prev : list[0].lbl); }); return () => { alive = false; }; }, []); const selectedTier = TIERS.find((t) => t.id === tier); // Plan benefits: basic → 1 free quick ($11); vip → 2 free in-depth ($40). const ent = window.LAUNCH_USER.usePlan ? window.LAUNCH_USER.usePlan() : { plan: 'free', quickEval: 0, fullEval: 0 }; const freeEvalLeft = tier === 'quick' ? (ent.quickEval || 0) : tier === 'full' ? (ent.fullEval || 0) : 0; const planCoversThis = freeEvalLeft > 0; const ITEM_OF_TIER = { quick: 'quick-score', full: 'full-review' }; const tierItem = ITEM_OF_TIER[tier] || 'quick-score'; // On native this review is bought from the App Store / Play Store. const storeBuy = !!(window.LAUNCH_USER.buysFromStore && window.LAUNCH_USER.buysFromStore(tierItem)); // Real store prices, so what we show is what the store will charge. const [storePrices, setStorePrices] = useState3({}); React.useEffect(() => { if (!storeBuy) return; let alive = true; window.LAUNCH_USER.iapItemPrices() .then((p) => { if (alive && p) setStorePrices(p); }) .catch(() => {}); return () => { alive = false; }; }, [storeBuy]); const storePriceOf = (id) => (storePrices[ITEM_OF_TIER[id]] || {}).price || null; // Review-only credit (from redeemed points) applies ONLY to the $40 tier, and // only when the plan doesn't already cover it (plan benefit takes priority). const priceCents = (selectedTier ? selectedTier.price : 0) * 100; const isReviewTier = tier === 'full'; // A store product is a fixed-price SKU — the store cannot charge "price minus // credit". So on native, account credit is all-or-nothing: it either covers // the whole review (no store purchase at all) or it doesn't apply, and the // store charges full price. Otherwise the total on screen wouldn't match what // Apple actually bills. const rawCredit = (!planCoversThis && isReviewTier) ? Math.max(0, creditCents) : 0; const creditApplied = storeBuy ? (rawCredit >= priceCents ? priceCents : 0) : Math.min(rawCredit, priceCents); const dueCents = planCoversThis ? 0 : Math.max(0, priceCents - creditApplied); const onPickFile = (e) => { const f = e.target.files && e.target.files[0]; if (f) { setAudioFile(f); setTrackUploaded(true); setSubmitErr(null); } }; // Show the Song Submission Agreement BEFORE the file picker opens — the user // can't even choose an audio file until they've read it and ticked "I agree". // We prompt on EVERY upload (per-submission `consented` flag), not just the // first time, even though the acceptance is still recorded in Supabase. const openFilePicker = () => { if (!consented) { setAgreementGate(true); return; } if (fileRef.current) fileRef.current.click(); }; const fmtSize = (b) => b < 1024 * 1024 ? (b / 1024).toFixed(0) + ' KB' : (b / (1024 * 1024)).toFixed(1) + ' MB'; const doSubmit = async () => { // Consent gate: the user must accept the Song Submission Agreement first. if (!consented) { setAgreementGate(true); return; } setSubmitting(true); setSubmitErr(null); const res = await window.LAUNCH_USER.submitSongForReview({ title: trackTitle, notes, tier, coach: coachLbl, audioFile, }); if (res && res.error) { setSubmitting(false); setSubmitErr(res.error); return; } const subId = res.submission && res.submission.id; // 1) Plan benefit: a free Quick (basic) or In-Depth (vip) eval — no payment. // Works in both live and demo (claimFreeEval handles both). if (planCoversThis && subId && window.LAUNCH_USER.claimFreeEval) { const fr = await window.LAUNCH_USER.claimFreeEval(subId, tier); if (fr && fr.ok) { setSubmitting(false); (setConsented(false), setMode('success')); return; } // claim failed → fall through to the normal paid flow } // Live mode → charge via Stripe Checkout (redirects out and back). if (window.LAUNCH_USER.isConfigured()) { // If account credit (from redeemed points) covers the full price, apply it // directly — no Stripe, no payment page. if (subId && priceCents > 0 && creditApplied >= priceCents && window.LAUNCH_USER.payReviewWithCredit) { const pc = await window.LAUNCH_USER.payReviewWithCredit(subId, priceCents); if (pc && pc.ok && pc.covered) { setSubmitting(false); (setConsented(false), setMode('success')); return; } // credit didn't cover (e.g. stale balance) → fall through to checkout } const itemMap = { quick: 'quick-score', full: 'full-review' }; const item = itemMap[tier] || 'quick-score'; // Inside the native app an evaluation is bought from the App Store / // Play Store, not Stripe: it's asynchronous digital content, so it isn't // covered by the person-to-person exemption that keeps 1:1 coach // sessions on Stripe. The server verifies the purchase before the review // is unlocked (iap-fulfill). if (window.LAUNCH_USER.buysFromStore(item)) { const pr = await window.LAUNCH_USER.purchaseItemInApp(item, subId); setSubmitting(false); if (pr && pr.cancelled) return; // backed out — stay put if (pr && pr.error) { setSubmitErr(pr.error); return; } (setConsented(false), setMode('success')); return; } // Store billing is required here but unavailable → sell nothing. We must // not quietly fall back to Stripe: on iOS that's the 3.1.1 rejection. // (On Android this is false until Play billing is configured, so the // shipped app keeps charging through Stripe as it does today.) if (window.LAUNCH_USER.storeRequiredFor(item)) { setSubmitting(false); setSubmitErr('Purchases are unavailable in the app right now. Please try again later.'); return; } const co = await window.LAUNCH_USER.startCheckout({ item, submissionId: subId, }); if (co && co.error) { setSubmitting(false); setSubmitErr('Saved, but checkout failed: ' + co.error); return; } // Account credit fully covered it server-side → no Stripe redirect. if (co && co.done) { setSubmitting(false); (setConsented(false), setMode('success')); return; } return; // leaving the page for Stripe } // Demo mode → simulated success. setSubmitting(false); (setConsented(false), setMode('success')); }; // ───────── SUCCESS ───────── if (mode === 'success') { return (
} />
SUBMITTED · {dueCents <= 0 ? 'COVERED BY CREDIT' : '$' + (dueCents / 100).toFixed(0) + ' CHARGED'}
YOUR TRACK
IS IN THE QUEUE
Expect your review within {selectedTier.eta.toLowerCase()}. We'll notify you the moment it drops.
ORDER · LCH-2487
Track {trackTitle || 'Untitled Demo v3'}
Tier {selectedTier.name}
ETA {selectedTier.eta}
); } // ───────── SUBMIT FORM ───────── if (mode === 'submit') { const canSubmit = trackTitle.trim() && audioFile && !submitting; return (
{/* Top bar with back */}
STEP 2 / 2
{selectedTier.name} · {storePriceOf(tier) || '$' + selectedTier.price}
DROP YOUR
TRACK
{/* Upload area */}
{/* Form fields */}
setTrackTitle(e.target.value)} placeholder="e.g. NEON HOURS" style={{ width: '100%', padding: '14px 16px', borderRadius: 12, background: theme.surface, border: `1px solid ${theme.border}`, color: theme.text, fontFamily: theme.fontBody, fontSize: 15, outline: 'none' }} onFocus={(e) => e.target.style.borderColor = theme.accent} onBlur={(e) => e.target.style.borderColor = theme.border} />