import { useAuth, useCollection, useIdentity, useWebhook } from '@deplixo/sdk'; import { HomeView, TrainingView, StatsView, ChallengesView, FriendsView, BlogView, ProfileView, CoachView } from './components/views.jsx'; import { PremiumView, SubscriptionView } from './components/Premium.jsx'; import { AdminView } from './components/Admin.jsx'; import { Icon } from './components/icons.jsx'; import { NotificationsButton } from './components/Notifications.jsx'; function App() { const { user, loading: idLoading } = useIdentity(); const [view, setView] = useState('home'); const [adminMode, setAdminMode] = useState(() => typeof window !== 'undefined' && window.location.hash === '#admin' ); useEffect(() => { if (typeof window === 'undefined') return; const onHash = () => setAdminMode(window.location.hash === '#admin'); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); const enterAdmin = () => { if (typeof window !== 'undefined') window.location.hash = 'admin'; setAdminMode(true); }; const exitAdmin = () => { if (typeof window !== 'undefined' && window.location.hash === '#admin') { history.replaceState(null, '', window.location.pathname + window.location.search); } setAdminMode(false); }; if (idLoading || !user) { return (
⚽
Chargement de FootRise…
); } if (adminMode) { return ; } return ; } function AppShell({ user, view, setView, enterAdmin }) { const profilesCol = useCollection('profiles'); const matchesCol = useCollection('matches'); const trainingsCol = useCollection('trainings'); const subsCol = useCollection('subscriptions'); const allProfiles = profilesCol.items || []; const allMatches = matchesCol.items || []; const allTrainings = trainingsCol.items || []; const allSubscriptions = subsCol.items || []; const myProfileItem = allProfiles.find(p => p.author?.id === user.id); const profile = myProfileItem?.value; const matches = allMatches.filter(m => m.author?.id === user.id); const trainings = allTrainings.filter(t => t.author?.id === user.id); const mySubItem = allSubscriptions.find(s => s.author?.id === user.id); const subscription = mySubItem?.value; const [initTried, setInitTried] = useState(false); useEffect(() => { if (profilesCol.loading) return; if (initTried) return; if (!myProfileItem && user) { setInitTried(true); profilesCol.add({ name: user.name || 'Joueur' }).catch(() => {}); } else if (myProfileItem) { setInitTried(true); } }, [profilesCol.loading, myProfileItem, user, initTried]); const now = Date.now(); const isPremium = !!subscription && ( subscription.status === 'active' || subscription.status === 'trialing' || ((subscription.status === 'cancelled' || subscription.cancelAtPeriodEnd) && (subscription.nextBillingAt || 0) > now) ); // ============ STRIPE WEBHOOK ============ // Listen to Stripe webhook events to activate / renew / cancel Premium. // Premium is ONLY granted after Stripe confirms a paid invoice — never on page open. useWebhook('stripe', async (event) => { if (!event || !event.type) return; // Try to resolve the userId this event belongs to. // Priority: metadata.userId, then client_reference_id (set on checkout). const obj = event.data?.object || {}; const md = obj.metadata || {}; const eventUserId = md.userId || obj.client_reference_id || null; // Only the receiving user's session applies the change to their own subscription row. if (!eventUserId || eventUserId !== user.id) return; // Resolve the current subscription doc for this user (may not exist yet). const currentItem = (subsCol.items || []).find(s => s.author?.id === user.id); const current = currentItem?.value || {}; const upsert = async (patch) => { const next = { ...current, ...patch }; if (currentItem) { await subsCol.update(currentItem.id, next); } else { await subsCol.add(next); } }; switch (event.type) { // Checkout succeeded → mark subscription active (payment already validated by Stripe). case 'checkout.session.completed': { const nowT = Date.now(); const monthMs = 30 * 24 * 3600 * 1000; await upsert({ status: 'active', plan: 'footrise-premium-monthly', price: 4.99, currency: 'EUR', startedAt: current.startedAt || nowT, nextBillingAt: nowT + monthMs, stripeCustomerId: obj.customer || current.stripeCustomerId || null, stripeSubscriptionId: obj.subscription || current.stripeSubscriptionId || null, cancelAtPeriodEnd: false, demo: false, }); break; } // Monthly renewal succeeded → keep Premium active and push next billing date. case 'invoice.paid': case 'invoice.payment_succeeded': { const periodEnd = (obj.lines?.data?.[0]?.period?.end || obj.period_end || 0) * 1000; await upsert({ status: 'active', nextBillingAt: periodEnd || (Date.now() + 30 * 24 * 3600 * 1000), stripeCustomerId: obj.customer || current.stripeCustomerId || null, stripeSubscriptionId: obj.subscription || current.stripeSubscriptionId || null, lastPaymentAt: Date.now(), }); break; } // Renewal failed → apply the "grace" policy: mark past_due but keep Premium // until the paid period ends; Stripe will retry automatically. case 'invoice.payment_failed': { await upsert({ status: 'past_due', lastPaymentFailedAt: Date.now(), }); break; } // User cancelled via the Stripe portal → keep Premium until period end. case 'customer.subscription.updated': { const periodEnd = (obj.current_period_end || 0) * 1000; await upsert({ status: obj.status === 'canceled' ? 'cancelled' : obj.status, cancelAtPeriodEnd: !!obj.cancel_at_period_end, nextBillingAt: periodEnd || current.nextBillingAt || 0, stripeSubscriptionId: obj.id || current.stripeSubscriptionId || null, }); break; } // Subscription fully ended (past the paid period) → downgrade to Free. case 'customer.subscription.deleted': { await upsert({ status: 'cancelled', cancelAtPeriodEnd: false, nextBillingAt: 0, // period elapsed → user reverts to Free endedAt: Date.now(), }); break; } default: // Ignore other Stripe events. break; } }); const saveProfile = async (form) => { if (myProfileItem) { await profilesCol.update(myProfileItem.id, form); } else { await profilesCol.add(form); } }; const addMatch = (data) => matchesCol.add(data); const addTraining = (data) => trainingsCol.add(data); const cancelSubscription = async () => { // Real cancellation happens in the Stripe Customer Portal. // This local helper is kept for compatibility but is no longer surfaced in the UI. if (!mySubItem) return; await subsCol.update(mySubItem.id, { ...subscription, status: 'cancelled' }); }; // No-op signout in this build (real deployment would call useAuth().signOut) const signOut = () => { setView('home'); }; const goToPremium = () => setView('premium'); const goToSubscription = () => setView('subscription'); const goToProfile = () => setView('profile'); const goToTraining = () => setView('training'); const commonProps = { user, profile, isPremium, matches, trainings, goToPremium, goToSubscription, goToProfile, goToTraining }; return (
{view === 'home' && } {view === 'training' && } {view === 'stats' && } {view === 'challenges' && } {view === 'friends' && } {view === 'blog' && } {view === 'coach' && } {view === 'profile' && } {view === 'premium' && } {view === 'subscription' && }
); } function TopBar({ user, profile, isPremium, setView, trainings, subscription }) { return (
{isPremium && ★ PREMIUM}
); } function BottomNav({ view, setView }) { const items = [ { id: 'home', label: 'Accueil', icon: }, { id: 'training', label: 'Entraînement', icon: }, { id: 'stats', label: 'Stats', icon: }, { id: 'challenges', label: 'Défis', icon: }, { id: 'friends', label: 'Amis', icon: }, { id: 'blog', label: 'Blog', icon: }, { id: 'profile', label: 'Profil', icon: }, ]; return ( ); } ReactDOM.createRoot(document.getElementById('root')).render();