// plans.jsx — subscription tiers (space themed) + Profile plan card const { useState: useStateP } = React; const { Card, Chip, TopBar, IconBtn, DisplayHeading, Icon } = window.LAUNCH_SCREENS_1; // Three tiers: free (browse only), basic ($9.99), vip ($19.99). Monthly, // 30-day periods. Benefits map 1:1 to entitlements in user.jsx / subscriptions.sql. const PLANS = [ { id: 'free', name: 'FREE', price: 0, priceDisplay: '0', short: 'no plan', tagline: 'Browse the community. Subscribe to join in.', altitude: 'GROUND CONTROL', coords: 'T-MINUS 10', perks: [ 'See the Feed, profiles & coaches', 'No posting, liking or comments', 'No chat or following', 'Upgrade anytime to unlock everything', ], palette: ['#3A3F4A', '#0B0B0F'], glow: '#9AA3B2', }, { id: 'basic', name: 'BASIC', price: 9.99, priceDisplay: '9.99', short: '$9.99 / mo', tagline: 'Join the community + a free quick review every month.', altitude: 'LOW EARTH ORBIT', coords: '400 KM · ALTITUDE', perks: [ 'Full Feed access — post, like, comment, upload & rate', 'Earn & redeem community points', '1 free Quick Score review ($11) every month', ], palette: ['#5B6AC4', '#0B1030'], glow: '#7AA8FF', badge: 'POPULAR', }, { id: 'vip', name: 'VIP', price: 19.99, priceDisplay: '19.99', short: '$19.99 / mo', tagline: 'Everything in Basic, plus pro reviews & a coach class.', altitude: 'DEEP SPACE', coords: 'ALL ACCESS', perks: [ 'Everything in Basic', '2 free In-Depth reviews ($40 each) every month', '1 free 20-min class with a coach every month', ], palette: ['#C72820', '#7A1A0A'], glow: '#FF7849', badge: 'BEST VALUE', }, ]; // Format a plan expiry ISO date → 'JUN 30, 2026'. function fmtPlanDate(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }).toUpperCase(); } catch (e) { return ''; } } // SVG starfield + planet for plan cards function PlanCelestial({ palette, glow, size = 110 }) { const [c1, c2] = palette; return ( ); } // ───────────────────────────────────────────────────────────── // Compact plan card for Profile // ───────────────────────────────────────────────────────────── function ProfilePlanCard({ theme, currentPlanId, expiresAt, active, onOpen }) { const plan = PLANS.find(p => p.id === currentPlanId) || PLANS[0]; const isFree = plan.id === 'free' || !active; // Memberships are now buyable inside the native app too (App Store / Play // billing), so there's no reason left to send anyone to the website. const subline = isFree ? 'NO PLAN · TAP TO SUBSCRIBE' : `$${plan.priceDisplay}/MO · RENEWS ${fmtPlanDate(expiresAt)}`; return ( ); } // ───────────────────────────────────────────────────────────── // Full plans screen // ───────────────────────────────────────────────────────────── function PlansScreen({ theme, currentPlanId, onBack, onSubscribe }) { const U = window.LAUNCH_USER; const [selected, setSelected] = useStateP(currentPlanId === 'free' ? 'basic' : currentPlanId); // Inside the native app, memberships are sold through the App Store / Play // Store (App Store guideline 3.1.1 — a membership the app unlocks has to be // buyable in the app). On the web we keep charging with Stripe. const native = !!(U && U.isNativeApp && U.isNativeApp()); const canIAP = !!(U && U.canBuyInApp && U.canBuyInApp()); // Native build without store billing wired up: we can't sell, and we must not // point people to the website to buy either — that's the 3.1.1 violation. const lockPaid = (id) => native && !canIAP && id !== 'free'; const [busy, setBusy] = useStateP(null); // plan id being purchased const [notice, setNotice] = useStateP(null); // { kind:'error'|'ok', text } const [prices, setPrices] = useStateP({}); // store-formatted prices const [manageUrl, setManageUrl] = useStateP(null); // Real localized store prices ("$9.99", "9,99 €"…). Apple requires we show // what the store will actually charge, not a hardcoded number. React.useEffect(() => { if (!canIAP) return; let alive = true; U.iapPrices().then((p) => { if (alive && p) setPrices(p); }).catch(() => {}); U.iapManagementURL().then((u) => { if (alive) setManageUrl(u); }).catch(() => {}); return () => { alive = false; }; }, [canIAP]); // An active store subscription means cancelling has to happen in the App // Store, not here — otherwise we'd strip the benefits while Apple keeps // charging them every month. const storeSubActive = !!manageUrl; const openManage = () => { if (manageUrl) window.open(manageUrl, '_blank'); }; const choosePlan = async (planId) => { if (planId === currentPlanId || busy) return; setNotice(null); if (planId === 'free' && storeSubActive) { openManage(); return; } if (lockPaid(planId)) { setNotice({ kind: 'error', text: 'Memberships are unavailable in the app right now. Please try again later.' }); return; } if (!onSubscribe) return; setBusy(planId); try { const res = await onSubscribe(planId); if (res && res.cancelled) return; // user backed out — stay quiet if (res && res.error) { setNotice({ kind: 'error', text: res.error }); return; } if (res && res.ok && planId !== 'free') { setNotice({ kind: 'ok', text: 'You\'re in. Your membership is active.' }); U.iapManagementURL().then(setManageUrl).catch(() => {}); } } finally { setBusy(null); } }; const restore = async () => { if (busy) return; setNotice(null); setBusy('restore'); try { const res = await U.restorePurchasesInApp(); if (res && res.error) setNotice({ kind: 'error', text: res.error }); else if (res && res.nothingToRestore) setNotice({ kind: 'error', text: 'No previous purchases found on this Apple ID.' }); else setNotice({ kind: 'ok', text: 'Your membership has been restored.' }); U.iapManagementURL().then(setManageUrl).catch(() => {}); } finally { setBusy(null); } }; // Store price when we have it, our own copy as fallback. const priceOf = (p) => (prices[p.id] && prices[p.id].price) || null; return (