Merge pull request #46 from artslidd/develop
Add beta plan + upsell modals + block past_due users
This commit is contained in:
commit
e4570db38b
22 changed files with 542 additions and 117 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ describe("ProtectedRoute", () => {
|
|||
is_temporary: false,
|
||||
last_signed_in: null,
|
||||
plan: "none" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
}}
|
||||
>
|
||||
<SessionTestProvider>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
134
apps/main/src/components/TrialUpsellModal.tsx
Normal file
134
apps/main/src/components/TrialUpsellModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
apps/main/src/components/UpgradePanel.tsx
Normal file
115
apps/main/src/components/UpgradePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/main/src/contexts/UpgradeBlockContext.tsx
Normal file
28
apps/main/src/contexts/UpgradeBlockContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
7
supabase/migrations/20251124211154_add_beta_plan.sql
Normal file
7
supabase/migrations/20251124211154_add_beta_plan.sql
Normal 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)';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue