Merge pull request #46 from artslidd/develop

Add beta plan + upsell modals + block past_due users
This commit is contained in:
Arthur Belleville 2025-11-25 09:07:29 +01:00 committed by GitHub
commit e4570db38b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 542 additions and 117 deletions

View file

@ -33,7 +33,7 @@
"multer": "^2.0.2",
"nodemailer": "^7.0.4",
"stream-chat": "^9.8.0",
"stripe": "^19.2.0",
"stripe": "^20.0.0",
"ts-node": "^10.9.2"
},
"devDependencies": {

View file

@ -55,6 +55,14 @@ export function createConfig(secrets?: Secrets): AppConfig {
// In test mode, use environment variables directly instead of secrets
const isTestMode = NODE_ENV === "test";
const isStagingMode = NODE_ENV === "staging";
const getStreamChatApiSecret = (isStagingMode: boolean) =>
isStagingMode ? secrets!.streamChatApiSecretStaging : secrets!.streamChatApiSecret;
const getStripeSecretKey = (isStagingMode: boolean) =>
isStagingMode ? secrets!.stripeSecretKeyStaging : secrets!.stripeSecretKey;
const getStripeWebhookSecret = (isStagingMode: boolean) =>
isStagingMode ? secrets!.stripeWebhookSecretStaging : secrets!.stripeWebhookSecret;
// Base configuration
const baseConfig: AppConfig = {
NODE_ENV,
@ -70,15 +78,16 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("SUPABASE_CA_CERT", process.env.SUPABASE_CA_CERT)
: secrets!.supabaseCaCert,
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
// Env dependent
STREAM_CHAT_API_SECRET: isTestMode
? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET)
: secrets!.streamChatApiSecret,
: getStreamChatApiSecret(isStagingMode),
STRIPE_SECRET_KEY: isTestMode
? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY)
: secrets!.stripeSecretKey,
: getStripeSecretKey(isStagingMode),
STRIPE_WEBHOOK_SECRET: isTestMode
? validateEnvVar("STRIPE_WEBHOOK_SECRET", process.env.STRIPE_WEBHOOK_SECRET)
: secrets!.stripeWebhookSecret,
: getStripeWebhookSecret(isStagingMode),
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: isTestMode

View file

@ -194,7 +194,7 @@ export class MiddlewareManager {
const stripeMiddleware = createMiddleware(async (c: Context, next: Next) => {
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
apiVersion: "2025-10-29.clover",
apiVersion: "2025-11-17.clover",
});
c.set("stripe", stripe);
await next();

View file

@ -21,13 +21,18 @@ export type Secrets = {
supabaseServiceRoleKey: string;
supabaseConnectionString: string;
supabaseCaCert: string;
streamChatApiSecret: string;
stripeSecretKey: string;
stripeWebhookSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
r2SecretAccessKey: string;
// Env dependent
streamChatApiSecret: string;
stripeSecretKey: string;
stripeWebhookSecret: string;
// Staging
streamChatApiSecretStaging: string;
stripeSecretKeyStaging: string;
stripeWebhookSecretStaging: string;
};
/**
@ -39,13 +44,19 @@ export async function loadSecrets(): Promise<Secrets> {
supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"),
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
streamChatApiSecret: await fetchSecret("stream-chat-api-secret"),
stripeSecretKey: await fetchSecret("stripe-secret-key"),
stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),
r2SecretAccessKey: await fetchSecret("r2-secret-access-key"),
// Env dependent
// Staging
streamChatApiSecretStaging: await fetchSecret("stream-chat-api-secret-staging"),
stripeSecretKeyStaging: await fetchSecret("stripe-secret-key-staging"),
stripeWebhookSecretStaging: await fetchSecret("stripe-webhook-secret-staging"),
// Production
streamChatApiSecret: await fetchSecret("stream-chat-api-secret"),
stripeSecretKey: await fetchSecret("stripe-secret-key"),
stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"),
};
return secrets;
}

View file

@ -6,7 +6,7 @@ VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFz
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpXuXgpirUWOj24dnb7DzbcEAu45nU1S5k66Nm4liY3IlGOW00pndRsgUM
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app

View file

@ -3,6 +3,9 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { CookieBanner } from "./components/CookieBanner";
import { TrialUpsellModal } from "./components/TrialUpsellModal";
import { UpgradePanel } from "./components/UpgradePanel";
import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext";
import { useCookieConsent } from "./hooks/useCookieConsent";
import { useDatadogRumViewName } from "./hooks/useDatadogRumViewName";
import { publicRoutes } from "./lib/publicRoutes";
@ -21,9 +24,17 @@ const Routes = () => {
return publicElement;
}
// If app route matched, render it inside UserStoreProvider
// If app route matched, render it inside UserStoreProvider and UpgradeBlockProvider
if (appElement) {
return <UserStoreProvider>{appElement}</UserStoreProvider>;
return (
<UserStoreProvider>
<UpgradeBlockProvider>
<UpgradePanel />
<TrialUpsellModal />
{appElement}
</UpgradeBlockProvider>
</UserStoreProvider>
);
}
// Neither matched, show 404

View file

@ -13,10 +13,12 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertCircle,
CalendarCheckIcon,
CalendarIcon,
Circle,
ConstructionIcon,
CreditCard,
FileTextIcon,
Kanban,
LogOutIcon,
@ -26,6 +28,7 @@ import {
PlusIcon,
SendIcon,
SettingsIcon,
Sparkles,
SquareKanban,
} from "lucide-react";
import { useState } from "react";
@ -34,6 +37,7 @@ import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { isProd, isStaging } from "../lib/env";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
@ -279,7 +283,16 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const location = useLocation();
const isReadOnly = useIsReadOnlyUser();
const user = useUser();
const { t } = useTranslation("navigation");
const { daysRemaining } = useTrialExpiration();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
// Show upsell for users in trial period (not beta, not paid, and daysRemaining exists)
const shouldShowUpsell = daysRemaining !== null && user.plan === "none" && !user.is_temporary;
const isUrgent = daysRemaining !== null && daysRemaining <= 3;
type List<T> = T[];
@ -391,6 +404,77 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
})}
</ul>
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowUpsell && !isCollapsed && (
<li className="mb-2">
<div
className={twMerge(
"mx-2 mb-2 p-3 rounded-lg border",
isUrgent
? "bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
: "bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
)}
>
<div className="flex items-start gap-2 mb-2">
{isUrgent ? (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p
className={twMerge(
"text-xs font-medium",
isUrgent
? "text-red-900 dark:text-red-100"
: "text-purple-900 dark:text-purple-100"
)}
>
{daysRemaining === 0
? "Dernier jour d'essai"
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restant${daysRemaining > 1 ? "s" : ""}`}
</p>
<p
className={twMerge(
"text-xs mt-0.5",
isUrgent
? "text-red-700 dark:text-red-300"
: "text-purple-700 dark:text-purple-300"
)}
>
{isUrgent ? "Passez à Standard maintenant" : "Passez à Standard"}
</p>
</div>
</div>
<Button
size="sm"
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className={twMerge(
"w-full h-7 text-xs gap-1",
isUrgent
? "bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600"
: "bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
)}
>
{checkoutPending ? (
"..."
) : (
<>
<CreditCard className="w-3 h-3" />
Mettre à niveau
</>
)}
</Button>
</div>
</li>
)}
{/* <li>
<NavLink isActive={location.pathname === "/support"}>
<RouterLink

View file

@ -87,6 +87,7 @@ describe("ProtectedRoute", () => {
is_temporary: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<SessionTestProvider>

View file

@ -8,7 +8,7 @@ import {
CardTitle,
} from "@xtablo/ui/components/card";
import { Text } from "@xtablo/ui/components/typography";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon } from "lucide-react";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import {
useCancelSubscription,
useCreateCheckoutSession,
@ -31,14 +31,23 @@ export function SubscriptionCard() {
const { mutate: reactivateSubscription, isPending: reactivatePending } =
useReactivateSubscription();
const isPaying = user.plan !== "none";
console.log({ subscription });
const isPaying = user.plan !== "none" && user.plan !== "beta";
const isBeta = user.plan === "beta";
// Replace with your actual price ID from Stripe Dashboard
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
const getStatusBadge = () => {
// Check for beta plan first
if (isBeta) {
return (
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
<Sparkles className="w-3 h-3" />
Beta
</Badge>
);
}
if (!subscription) {
return (
<Badge variant="secondary" className="gap-1.5">
@ -90,9 +99,11 @@ export function SubscriptionCard() {
{getStatusBadge()}
</div>
<CardDescription>
{isPaying
? "Gérez votre abonnement et votre facturation"
: "Passez à Standard pour débloquer toutes les fonctionnalités"}
{isBeta
? "Vous avez accès à toutes les fonctionnalités gratuitement en tant que bêta-testeur"
: isPaying
? "Gérez votre abonnement et votre facturation"
: "Passez à Standard pour débloquer toutes les fonctionnalités"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -102,8 +113,33 @@ export function SubscriptionCard() {
</div>
) : (
<>
{/* Beta Plan */}
{isBeta && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
Plan Beta
</p>
</div>
<p className="text-xs text-purple-700 dark:text-purple-300">
Accès gratuit et illimité à toutes les fonctionnalités
</p>
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
<p className="text-xs text-purple-600 dark:text-purple-400">
Merci de faire partie de nos bêta-testeurs ! Votre retour nous aide à
améliorer XTablo.
</p>
</div>
</div>
</div>
</div>
)}
{/* Free Tier */}
{!isPaying && (
{!isPaying && !isBeta && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-2">

View file

@ -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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="mx-auto w-12 h-12 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center mb-2">
{isUrgent ? (
<AlertCircle className="w-6 h-6 text-white" />
) : (
<Sparkles className="w-6 h-6 text-white" />
)}
</div>
<DialogTitle className="text-center">
{isUrgent
? `Plus que ${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} de période d'essai`
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restants dans votre période d'essai`}
</DialogTitle>
<DialogDescription className="text-center">
{isUrgent
? "Ne perdez pas l'accès à vos projets ! Passez au plan Standard pour continuer."
: "Profitez de toutes les fonctionnalités sans limite en passant au plan Standard."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<p className="text-sm font-medium">Avec Standard, vous bénéficiez de :</p>
<ul className="space-y-2">
{[
"Tablos et projets illimités",
"Planification avancée",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)} className="sm:flex-1">
Plus tard
</Button>
<Button
onClick={() => {
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
});
setIsOpen(false);
}}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className="sm:flex-1 gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
>
{checkoutPending ? (
<>
<Loader2Icon className="w-4 h-4 animate-spin" />
Chargement...
</>
) : (
<>
<CreditCard className="w-4 h-4" />
Passer à Standard
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
<div className="w-full max-w-lg mx-4">
<Card className="shadow-2xl border-2">
<CardHeader className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center mb-2">
<Sparkles className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
<CardDescription className="text-base">
Pour continuer à utiliser XTablo, passez au plan Standard et débloquez toutes les
fonctionnalités
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Features list */}
<div className="space-y-3">
<Text className="text-sm font-medium">Ce que vous obtenez avec Standard :</Text>
<ul className="space-y-2">
{[
"Tablos illimités",
"Planification avancée",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
{/* Upgrade button */}
<div className="space-y-3">
<Button
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 h-12 text-base"
>
{checkoutPending ? (
<>
<Loader2Icon className="w-5 h-5 animate-spin" />
Chargement...
</>
) : (
<>
<CreditCard className="w-5 h-5" />
Passer à Standard
</>
)}
</Button>
{!STANDARD_MONTHLY_PRICE_ID && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />
<Text className="text-xs text-red-600 dark:text-red-400">
Configuration Stripe requise. Veuillez contacter le support.
</Text>
</div>
)}
</div>
{/* Help text */}
<div className="text-center pt-2 border-t">
<Text className="text-xs text-muted-foreground">
Des questions ?{" "}
<a
href="mailto:support@xtablo.com"
className="text-primary hover:underline font-medium"
>
Contactez-nous
</a>
</Text>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
import React, { createContext, useContext } from "react";
import { useIsPastTrial } from "../hooks/stripe";
interface UpgradeBlockContextValue {
isBlocked: boolean;
}
const UpgradeBlockContext = createContext<UpgradeBlockContextValue | null>(null);
export const useUpgradeBlock = () => {
const context = useContext(UpgradeBlockContext);
if (!context) {
throw new Error("useUpgradeBlock must be used within UpgradeBlockProvider");
}
return context;
};
interface UpgradeBlockProviderProps {
children: React.ReactNode;
}
export const UpgradeBlockProvider: React.FC<UpgradeBlockProviderProps> = ({ children }) => {
const { isPastTrial: isBlocked } = useIsPastTrial();
return (
<UpgradeBlockContext.Provider value={{ isBlocked }}>{children}</UpgradeBlockContext.Provider>
);
};

View file

@ -7,13 +7,16 @@ import {
StripeApiSubscription,
toast,
} from "@xtablo/shared";
import { useMemo } from "react";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { useMaybeUser, useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
const TRIAL_DURATION = 7 * 24 * 60 * 60 * 1000;
/**
* Hook to get user's subscription status from Supabase
* Uses RPC function to access stripe data without modifying stripe schema
@ -58,6 +61,53 @@ export function useIsPayingUser() {
};
}
export const useIsPastTrial = () => {
const user = useMaybeUser();
// Calculate if user is past trial inline
// User is past trial if: plan is 'none' AND created more than 7 days ago
const isPastTrial = useMemo(() => {
if (!user) return false;
const isPastTrial =
user.plan === "none" &&
new Date(user.created_at || "") < new Date(Date.now() - TRIAL_DURATION);
return isPastTrial;
}, [user]);
return { isPastTrial, isLoading: !user };
};
/**
* Hook to get when the user's trial expires
* Returns the trial expiration date (creation date + 7 days)
*/
export const useTrialExpiration = () => {
const user = useMaybeUser();
const trialExpiration = useMemo(() => {
if (!user?.created_at) return null;
const creationDate = new Date(user.created_at);
const expirationDate = new Date(creationDate.getTime() + TRIAL_DURATION);
return expirationDate;
}, [user?.created_at]);
const daysRemaining = useMemo(() => {
if (!trialExpiration) return null;
const now = new Date();
const diffInMs = trialExpiration.getTime() - now.getTime();
const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24));
return Math.max(0, diffInDays);
}, [trialExpiration]);
return { daysRemaining, isLoading: !user };
};
/**
* Hook to get available prices for Standard plan from Supabase
* Uses RPC functions to access stripe data without modifying stripe schema

View file

@ -69,6 +69,7 @@ describe("TestUserStoreProvider", () => {
short_user_id: "short-id",
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
};
it("renders children with user", () => {

View file

@ -21,6 +21,7 @@ const defaultUser = {
is_temporary: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
};
export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {

File diff suppressed because one or more lines are too long

View file

@ -1,30 +1,10 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never;
};
Views: {
[_ in never]: never;
};
Functions: {
graphql: {
Args: {
extensions?: Json;
operationName?: string;
query?: string;
variables?: Json;
};
Returns: Json;
};
};
Enums: {
[_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.4";
};
public: {
Tables: {
@ -401,6 +381,7 @@ export type Database = {
profiles: {
Row: {
avatar_url: string | null;
created_at: string | null;
email: string | null;
first_name: string | null;
id: string;
@ -413,6 +394,7 @@ export type Database = {
};
Insert: {
avatar_url?: string | null;
created_at?: string | null;
email?: string | null;
first_name?: string | null;
id: string;
@ -425,6 +407,7 @@ export type Database = {
};
Update: {
avatar_url?: string | null;
created_at?: string | null;
email?: string | null;
first_name?: string | null;
id?: string;
@ -913,7 +896,7 @@ export type Database = {
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
subscription_plan: "none" | "trial" | "standard";
subscription_plan: "none" | "trial" | "standard" | "beta";
task_status: "todo" | "in_progress" | "in_review" | "done";
};
CompositeTypes: {
@ -1041,13 +1024,10 @@ export type CompositeTypes<
: never;
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"],
subscription_plan: ["none", "trial", "standard", "beta"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},

View file

@ -35,7 +35,7 @@ importers:
version: 1.19.6(hono@4.10.4)
'@supabase/stripe-sync-engine':
specifier: ^0.45.0
version: 0.45.0(stripe@19.3.0(@types/node@20.19.23))
version: 0.45.0(stripe@20.0.0(@types/node@20.19.23))
'@supabase/supabase-js':
specifier: ^2.49.4
version: 2.76.1
@ -76,8 +76,8 @@ importers:
specifier: ^9.8.0
version: 9.24.0
stripe:
specifier: ^19.2.0
version: 19.3.0(@types/node@20.19.23)
specifier: ^20.0.0
version: 20.0.0(@types/node@20.19.23)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.23)(typescript@5.9.3)
@ -350,9 +350,6 @@ importers:
stream-chat-react:
specifier: ^13.1.0
version: 13.9.0(@emoji-mart/data@1.2.1)(@types/react@19.0.10)(emoji-mart@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.24.0)(typescript@5.9.3)
three:
specifier: ^0.172.0
version: 0.172.0
ts-pattern:
specifier: ^5.6.2
version: 5.8.0
@ -417,9 +414,6 @@ importers:
'@types/react-dom':
specifier: 19.0.4
version: 19.0.4(@types/react@19.0.10)
'@types/three':
specifier: ^0.181.0
version: 0.181.0
'@typescript-eslint/eslint-plugin':
specifier: ^7.0.2
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
@ -1298,9 +1292,6 @@ packages:
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@emnapi/runtime@1.6.0':
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
@ -3970,9 +3961,6 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@ -4104,12 +4092,6 @@ packages:
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.181.0':
resolution: {integrity: sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@ -4125,9 +4107,6 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@ -4377,9 +4356,6 @@ packages:
'@vitest/utils@4.0.8':
resolution: {integrity: sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==}
'@webgpu/types@0.1.66':
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@ -6541,9 +6517,6 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@0.22.0:
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@ -7941,8 +7914,8 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@19.3.0:
resolution: {integrity: sha512-3MbqRkw5LXb4LWP1LgIEYxUAYhYDDU5pcHZj4Xha6VWPnN1wrUmQ7Htsgm8wR584s0hn1aQg1lYD0Hi+F37E5g==}
stripe@20.0.0:
resolution: {integrity: sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==}
engines: {node: '>=16'}
peerDependencies:
'@types/node': '>=16'
@ -8032,9 +8005,6 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
three@0.172.0:
resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@ -9769,8 +9739,6 @@ snapshots:
'@date-fns/tz@1.4.1': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/runtime@1.6.0':
dependencies:
tslib: 2.8.1
@ -12683,11 +12651,11 @@ snapshots:
'@supabase/node-fetch': 2.6.15
tslib: 2.8.1
'@supabase/stripe-sync-engine@0.45.0(stripe@19.3.0(@types/node@20.19.23))':
'@supabase/stripe-sync-engine@0.45.0(stripe@20.0.0(@types/node@20.19.23))':
dependencies:
pg: 8.16.3
pg-node-migrations: 0.0.8
stripe: 19.3.0(@types/node@20.19.23)
stripe: 20.0.0(@types/node@20.19.23)
yesql: 7.0.0
transitivePeerDependencies:
- pg-native
@ -12951,8 +12919,6 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@tweenjs/tween.js@23.1.3': {}
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@ -13110,18 +13076,6 @@ snapshots:
'@types/stack-utils@2.0.3': {}
'@types/stats.js@0.17.4': {}
'@types/three@0.181.0':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
'@webgpu/types': 0.1.66
fflate: 0.8.2
meshoptimizer: 0.22.0
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7':
@ -13133,8 +13087,6 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/webxr@0.5.24': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
@ -13465,8 +13417,6 @@ snapshots:
'@vitest/pretty-format': 4.0.8
tinyrainbow: 3.0.3
'@webgpu/types@0.1.66': {}
abab@2.0.6: {}
abort-controller@3.0.0:
@ -16294,8 +16244,6 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@0.22.0: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.2.0
@ -18033,7 +17981,7 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@19.3.0(@types/node@20.19.23):
stripe@20.0.0(@types/node@20.19.23):
dependencies:
qs: 6.14.0
optionalDependencies:
@ -18127,8 +18075,6 @@ snapshots:
dependencies:
real-require: 0.2.0
three@0.172.0: {}
through@2.3.8: {}
tiny-async-pool@2.1.0: {}

View file

@ -0,0 +1,7 @@
-- Add beta plan to subscription_plan enum
-- This plan can only be manually activated (not through Stripe flows)
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'beta';
-- Add comment to document that beta is manually activated only
COMMENT ON TYPE public.subscription_plan IS 'Subscription plans: none (no subscription), trial (trial period), standard (paid subscription), beta (manually activated beta access)';

View file

@ -0,0 +1,8 @@
-- Add created_at column to profiles table
-- This tracks when the user profile was created
-- The DEFAULT CURRENT_TIMESTAMP will automatically populate this for both existing and new profiles
ALTER TABLE public.profiles
ADD COLUMN created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP;
COMMENT ON COLUMN public.profiles.created_at IS 'Timestamp when the user profile was created';

View file

@ -387,6 +387,7 @@ export type Database = {
profiles: {
Row: {
avatar_url: string | null
created_at: string | null
email: string | null
first_name: string | null
id: string
@ -399,6 +400,7 @@ export type Database = {
}
Insert: {
avatar_url?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id: string
@ -411,6 +413,7 @@ export type Database = {
}
Update: {
avatar_url?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id?: string
@ -899,7 +902,7 @@ export type Database = {
}
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
subscription_plan: "none" | "trial" | "standard"
subscription_plan: "none" | "trial" | "standard" | "beta"
task_status: "todo" | "in_progress" | "in_review" | "done"
}
CompositeTypes: {
@ -1032,7 +1035,7 @@ export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"],
subscription_plan: ["none", "trial", "standard", "beta"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},