diff --git a/apps/main/src/components/TrialUpsellModal.tsx b/apps/main/src/components/TrialUpsellModal.tsx
new file mode 100644
index 0000000..9470942
--- /dev/null
+++ b/apps/main/src/components/TrialUpsellModal.tsx
@@ -0,0 +1,134 @@
+import { Button } from "@xtablo/ui/components/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@xtablo/ui/components/dialog";
+import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
+import { useMaybeUser } from "../providers/UserStoreProvider";
+
+const MODAL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
+const LAST_SHOWN_KEY = "trial-upsell-modal-last-shown";
+
+/**
+ * Auto-opening modal that shows every 15 minutes to remind users about trial expiration
+ * Only shows if daysRemaining is not null (user is in trial period)
+ */
+export function TrialUpsellModal() {
+ const [isOpen, setIsOpen] = useState(false);
+ const { daysRemaining } = useTrialExpiration();
+ const user = useMaybeUser();
+ const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
+
+ const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
+
+ // Only show modal for users in trial period (not beta, not paid, and daysRemaining exists)
+ const shouldShowModal = daysRemaining !== null && user?.plan === "none" && !user?.is_temporary;
+
+ useEffect(() => {
+ if (!shouldShowModal) return;
+
+ const checkAndShowModal = () => {
+ const lastShown = localStorage.getItem(LAST_SHOWN_KEY);
+ const now = Date.now();
+
+ if (!lastShown || now - parseInt(lastShown) >= MODAL_INTERVAL_MS) {
+ setIsOpen(true);
+ localStorage.setItem(LAST_SHOWN_KEY, now.toString());
+ }
+ };
+
+ // Check immediately on mount
+ checkAndShowModal();
+
+ // Set up interval to check every 15 minutes
+ const interval = setInterval(checkAndShowModal, MODAL_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, [shouldShowModal]);
+
+ if (!shouldShowModal) {
+ return null;
+ }
+
+ const isUrgent = daysRemaining <= 3;
+
+ return (
+
+ );
+}
diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx
new file mode 100644
index 0000000..a5c9293
--- /dev/null
+++ b/apps/main/src/components/UpgradePanel.tsx
@@ -0,0 +1,115 @@
+import { Button } from "@xtablo/ui/components/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@xtablo/ui/components/card";
+import { Text } from "@xtablo/ui/components/typography";
+import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
+import { useUpgradeBlock } from "../contexts/UpgradeBlockContext";
+import { useCreateCheckoutSession } from "../hooks/stripe";
+
+/**
+ * Blocking upgrade panel that appears when users are past their trial period
+ * Prevents access to the app until they upgrade to a paid plan
+ */
+export function UpgradePanel() {
+ const { isBlocked } = useUpgradeBlock();
+ const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
+
+ const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
+
+ if (!isBlocked) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ Votre période d'essai est terminée
+
+ Pour continuer à utiliser XTablo, passez au plan Standard et débloquez toutes les
+ fonctionnalités
+
+
+
+ {/* Features list */}
+
+ Ce que vous obtenez avec Standard :
+
+ {[
+ "Tablos illimités",
+ "Planification avancée",
+ "Chat en temps réel",
+ "Stockage de fichiers",
+ "Support prioritaire",
+ ].map((feature) => (
+