);
}
// ─────────────────────────────────────────────────────────────
// 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 (
{dueCents > 0 && storePriceOf(tier)
// What the store will actually charge — never a number of ours
// that could differ from Apple's price for this storefront.
? storePriceOf(tier)
: ${(dueCents / 100).toFixed(0)}.00}
{/* Consent gate — must be accepted before picking a file or submitting */}
{agreementGate && window.LAUNCH_AGREEMENT &&
setAgreementGate(false)}
onAccepted={() => { setAgreementGate(false); setConsented(true); if (fileRef.current) fileRef.current.click(); }}
/>
}