diff --git a/apps/api/VITEST_MIGRATION.md b/apps/api/docs/VITEST_MIGRATION.md similarity index 100% rename from apps/api/VITEST_MIGRATION.md rename to apps/api/docs/VITEST_MIGRATION.md diff --git a/apps/api/package.json b/apps/api/package.json index 1f65b5e..049f035 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index f5c0926..31be0c4 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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 diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index caff54f..3625e91 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -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(); diff --git a/apps/api/src/secrets.ts b/apps/api/src/secrets.ts index f458c3d..4a69b8e 100644 --- a/apps/api/src/secrets.ts +++ b/apps/api/src/secrets.ts @@ -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 { 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; } diff --git a/apps/main/.env.production b/apps/main/.env.production index d0c8c5c..25a272b 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -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 \ No newline at end of file diff --git a/apps/main/src/App.tsx b/apps/main/src/App.tsx index 707991c..6fbbe7d 100644 --- a/apps/main/src/App.tsx +++ b/apps/main/src/App.tsx @@ -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 {appElement}; + return ( + + + + + {appElement} + + + ); } // Neither matched, show 404 diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index ba99c44..925a28c 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -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[]; @@ -391,6 +404,77 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { })}