// 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 ( {/* halo */} {/* stars */} {[[12,18,1],[88,22,1.2],[20,82,0.9],[82,78,1],[68,12,0.7],[10,52,0.6],[92,55,0.8]].map(([x,y,r], i) => ( ))} {/* planet */} {/* ring (for tier 3) */} {glow === '#CAFF33' && ( )} {/* surface detail */} {/* shine */} ); } // ───────────────────────────────────────────────────────────── // 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 (
{/* Starfield background */} {Array.from({ length: 80 }, (_, i) => { const x = (i * 73) % 400; const y = (i * 137) % 1200; const r = (i % 5 === 0) ? 1.4 : (i % 3 === 0) ? 1 : 0.6; return ; })} {/* Top bar */}
MISSION CONTROL
{/* Hero */}
★ CHOOSE YOUR ALTITUDE
HOW FAR
YOU GOING?
Three orbits. Cancel anytime. The further out you go, the more we put behind your launch.
{/* Purchase feedback (store errors, restore results…) */} {notice && (
{notice.text}
)} {/* Native build with no store billing configured: we can't sell here, and we must NOT send people to the website to buy (guideline 3.1.1). */} {native && !canIAP && (
Memberships are temporarily unavailable in the app. Please try again later.
)} {/* Plan cards */}
{PLANS.map((p, i) => { const isCurrent = p.id === currentPlanId; const isSelected = p.id === selected; return ( ); })}
{/* FAQ-ish strip */}
★ THE FINE PRINT
{native ? "Cancel anytime from your store account. Your membership stays active until the end of the period you've already paid for, and refunds are handled by the store." : "Cancel anytime. Your membership stays active until the end of the period you've already paid for."}
{/* Store billing block. App Store review requires all of this to live in the app itself: a working Restore Purchases action, the auto-renewal terms, and links to the Terms of Use and Privacy Policy. */} {native && canIAP && (
{storeSubActive && ( )}
Basic and VIP are monthly auto-renewing subscriptions. Payment is charged to your store account at confirmation of purchase. The subscription renews automatically for the same price and period unless you turn off auto-renew at least 24 hours before the end of the current period. You can manage or cancel it any time in your store account settings. {' '} Terms of Use {' · '} Privacy Policy
)} {/* Sticky CTA */}
{/* The same notice also renders at the top of the screen, but by the time you tap SUBSCRIBE you're looking at the bottom of a long page: an error up there reads as the button doing nothing at all. */} {notice && (
{notice.text}
)}
SELECTED ALTITUDE
{(() => { const sel = PLANS.find(p => p.id === selected); return `${sel.name} · ${priceOf(sel) || '$' + sel.priceDisplay}/MO`; })()}
{(() => { const disabled = selected === currentPlanId || lockPaid(selected) || !!busy; return ( ); })()}
); } window.LAUNCH_SCREENS_4 = { PlansScreen, ProfilePlanCard, PLANS };