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 (
);
}
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();