From c631b19dd97cc1fd8bb1e842cd4ab6020ec267da Mon Sep 17 00:00:00 2001 From: Bill Yang <45103519+goldflag@users.noreply.github.com> Date: Tue, 15 Apr 2025 23:31:41 -0700 Subject: [PATCH] custom stripe integration (#88) * custom stripe integration * Refactor subscription management UI and remove unused components - Removed ChangePlanDialog, CurrentPlanCard, and ErrorDialog components to streamline subscription management. - Updated SubscriptionPage to define PlanTemplate locally and improve plan details handling. - Adjusted button labels for clarity and removed unnecessary HelpSection for a cleaner interface. - Enhanced the overall structure and readability of the SubscriptionPage component. * Refactor SubscriptionPage to streamline plan display and enhance user experience - Removed unused components and consolidated plan handling into FreePlan and ProPlan components for better organization. - Simplified the SubscriptionPage structure by integrating the useStripeSubscription hook and eliminating redundant state management. - Improved UI clarity by directly rendering plan components based on subscription status, enhancing overall user interaction. * Refactor subscription plans and UI components for improved clarity and functionality - Updated FreePlan and ProPlan components to enhance the display of subscription details and usage information. - Renamed pricing tiers from "basic" to "pro" in both client and server code to reflect the new subscription structure. - Simplified the layout of SubscriptionPage and adjusted button styles for better user experience. - Enhanced plan details and descriptions to provide clearer information on subscription benefits and features. * Enhance Header and UsageBanners components for improved user experience - Updated Header component to display UsageBanners with padding for better layout. - Simplified button styles in UsageBanners for consistency and clarity. - Adjusted text labels in UsageBanners to better reflect user actions and subscription options. - Removed unnecessary console log from Weekdays component to clean up code. * Update subscription plans and event limits to reflect new pricing structure - Changed the event limit for the free plan from 20,000 to 10,000 across client and server code. - Renamed the "basic" plan to "pro" in the subscription management UI and related components for consistency. - Adjusted plan details and features to align with the new subscription offerings, enhancing clarity for users. - Removed deprecated code related to subscription handling to streamline the implementation. --- .../app/[site]/components/Header/Header.tsx | 3 +- .../[site]/components/Header/UsageBanners.tsx | 9 +- .../main/components/sections/Weekdays.tsx | 1 - .../components/ChangePlanDialog.tsx | 240 --------- .../components/CurrentPlanCard.tsx | 179 ------- .../subscription/components/ErrorDialog.tsx | 68 --- .../subscription/components/FreePlan.tsx | 73 +++ .../components/PlanFeaturesCard.tsx | 2 +- .../subscription/components/ProPlan.tsx | 164 +++++++ client/src/app/settings/subscription/page.tsx | 460 +----------------- .../settings/subscription/utils/planUtils.tsx | 19 +- .../utils/useStripeSubscription.ts | 46 ++ client/src/app/subscribe/page.tsx | 432 ++++++++-------- client/src/components/ui/alert.tsx | 2 +- client/src/lib/auth.ts | 10 +- client/src/lib/stripe.ts | 28 +- server/src/api/sites/getSites.ts | 114 +---- .../src/api/stripe/createCheckoutSession.ts | 95 ++++ server/src/api/stripe/createPortalSession.ts | 61 +++ server/src/api/stripe/getSubscription.ts | 115 +++++ server/src/api/stripe/webhook.ts | 129 +++++ server/src/api/user/getUserSubscription.ts | 84 ---- server/src/cron/monthly-usage-checker.ts | 99 ++-- server/src/db/postgres/schema.ts | 17 - server/src/index.ts | 27 +- server/src/lib/auth.ts | 10 - server/src/lib/const.ts | 41 +- server/src/lib/stripe.ts | 17 + 28 files changed, 1068 insertions(+), 1477 deletions(-) delete mode 100644 client/src/app/settings/subscription/components/ChangePlanDialog.tsx delete mode 100644 client/src/app/settings/subscription/components/CurrentPlanCard.tsx delete mode 100644 client/src/app/settings/subscription/components/ErrorDialog.tsx create mode 100644 client/src/app/settings/subscription/components/FreePlan.tsx create mode 100644 client/src/app/settings/subscription/components/ProPlan.tsx create mode 100644 client/src/app/settings/subscription/utils/useStripeSubscription.ts create mode 100644 server/src/api/stripe/createCheckoutSession.ts create mode 100644 server/src/api/stripe/createPortalSession.ts create mode 100644 server/src/api/stripe/getSubscription.ts create mode 100644 server/src/api/stripe/webhook.ts delete mode 100644 server/src/api/user/getUserSubscription.ts create mode 100644 server/src/lib/stripe.ts diff --git a/client/src/app/[site]/components/Header/Header.tsx b/client/src/app/[site]/components/Header/Header.tsx index 72fea71..59c751b 100644 --- a/client/src/app/[site]/components/Header/Header.tsx +++ b/client/src/app/[site]/components/Header/Header.tsx @@ -6,6 +6,5 @@ import { UsageBanners } from "./UsageBanners"; export function Header() { const { user } = userStore(); - return null; - // return
{user && }
; + return
{user && }
; } diff --git a/client/src/app/[site]/components/Header/UsageBanners.tsx b/client/src/app/[site]/components/Header/UsageBanners.tsx index 4c4ef1b..874bb8f 100644 --- a/client/src/app/[site]/components/Header/UsageBanners.tsx +++ b/client/src/app/[site]/components/Header/UsageBanners.tsx @@ -57,11 +57,7 @@ export function UsageBanners() { Upgrade your plan to continue collecting analytics. - diff --git a/client/src/app/[site]/main/components/sections/Weekdays.tsx b/client/src/app/[site]/main/components/sections/Weekdays.tsx index ab4c216..1b6638f 100644 --- a/client/src/app/[site]/main/components/sections/Weekdays.tsx +++ b/client/src/app/[site]/main/components/sections/Weekdays.tsx @@ -55,7 +55,6 @@ export function Weekdays() { // Parse the timestamp const date = DateTime.fromSQL(item.time); - console.log(date); if (!date.isValid) return; const dayOfWeek = date.weekday % 7; // 0 = Monday, 6 = Sunday in Luxon diff --git a/client/src/app/settings/subscription/components/ChangePlanDialog.tsx b/client/src/app/settings/subscription/components/ChangePlanDialog.tsx deleted file mode 100644 index a6bc62b..0000000 --- a/client/src/app/settings/subscription/components/ChangePlanDialog.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { AlertCircle, Shield } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Subscription } from "@/api/admin/subscription"; -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; - -interface ChangePlanDialogProps { - showUpgradeDialog: boolean; - setShowUpgradeDialog: (show: boolean) => void; - actionError: string | null; - upgradePlans: any[]; - activeSubscription: Subscription | null | undefined; - isProcessing: boolean; - handleUpgradeSubscription: (planId: string) => Promise; - router: { - push: (url: string) => void; - }; -} - -export function ChangePlanDialog({ - showUpgradeDialog, - setShowUpgradeDialog, - actionError, - upgradePlans, - activeSubscription, - isProcessing, - handleUpgradeSubscription, - router, -}: ChangePlanDialogProps) { - // State to track if we're resuming a subscription - const [resumingPlan, setResumingPlan] = useState(null); - // State to track billing interval preference - const [isAnnual, setIsAnnual] = useState(false); - - // When dialog opens and subscription is canceled, highlight the current plan - useEffect(() => { - if ( - showUpgradeDialog && - activeSubscription?.cancelAtPeriodEnd && - activeSubscription?.plan - ) { - setResumingPlan(activeSubscription.plan); - // Initialize the annual toggle based on the current subscription - setIsAnnual(activeSubscription.plan.includes("-annual")); - } else if (showUpgradeDialog && activeSubscription?.plan) { - // Initialize the annual toggle based on the current subscription - setIsAnnual(activeSubscription.plan.includes("-annual")); - } else { - setResumingPlan(null); - } - }, [showUpgradeDialog, activeSubscription]); - - // Filter plans based on the selected billing interval - const filteredPlans = upgradePlans.filter( - (plan) => - plan.name.startsWith("basic") && - (isAnnual - ? plan.name.includes("-annual") - : !plan.name.includes("-annual")) - ); - - return ( - { - setShowUpgradeDialog(open); - if (!open) setResumingPlan(null); - }} - > - - - - {activeSubscription?.cancelAtPeriodEnd - ? "Resume Subscription" - : "Change Your Plan"} - - - {activeSubscription?.cancelAtPeriodEnd - ? "Select a plan to resume your subscription. Your current plan is highlighted." - : "Select a plan to switch to"} - - - - {actionError && ( - - - Error - {actionError} - - )} - - {resumingPlan && ( - - - Resuming Subscription - - Your current plan is highlighted. Click "Select" to resume this - plan or choose a different one. - - - )} - - {/* Billing toggle buttons */} -
-
- -
- - - 2 months free - -
-
-
- -
- {/* Pro Plans */} -
-

- - Pro Plans -

-
- {filteredPlans.map((plan) => ( - - -
-
-

- {plan.limits.events.toLocaleString()} events - {isAnnual && ( - - Save 17% - - )} -

-

- ${plan.price} / {plan.interval} -

-
- -
-
-
- ))} -
-
-
- - - - - -
-
- ); -} diff --git a/client/src/app/settings/subscription/components/CurrentPlanCard.tsx b/client/src/app/settings/subscription/components/CurrentPlanCard.tsx deleted file mode 100644 index e6a2385..0000000 --- a/client/src/app/settings/subscription/components/CurrentPlanCard.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; -import { Separator } from "@/components/ui/separator"; -import { Subscription } from "@/api/admin/subscription"; -import { PlanTemplate, formatDate } from "../utils/planUtils"; -import { AlertCircle, ArrowRight, X } from "lucide-react"; - -interface CurrentPlanCardProps { - activeSubscription: Subscription; - currentPlan: PlanTemplate | null; - currentUsage: number; - eventLimit: number; - usagePercentage: number; - isProcessing: boolean; - handleCancelSubscription: () => Promise; - handleResumeSubscription: () => Promise; - handleShowUpgradeOptions: () => void; - upgradePlans: any[]; -} - -export function CurrentPlanCard({ - activeSubscription, - currentPlan, - currentUsage, - eventLimit, - usagePercentage, - isProcessing, - handleCancelSubscription, - handleResumeSubscription, - handleShowUpgradeOptions, - upgradePlans, -}: CurrentPlanCardProps) { - return ( - - -
-
- Current Plan - Your current subscription details -
- - {activeSubscription.cancelAtPeriodEnd - ? "Cancels Soon" - : activeSubscription?.status === "active" - ? "Active" - : activeSubscription?.status === "trialing" - ? "Trial" - : activeSubscription?.status === "canceled" - ? "Canceled" - : activeSubscription?.status} - -
-
- -
-
-
-

Plan

-

{currentPlan?.name}

-

- {currentPlan?.price}/{currentPlan?.interval} -

-
-
-

- {activeSubscription.cancelAtPeriodEnd || - activeSubscription.status === "canceled" - ? "Ends On" - : "Renewal Date"} -

-

- {formatDate(activeSubscription?.periodEnd)} -

- {activeSubscription?.cancelAt && - !activeSubscription.cancelAtPeriodEnd && ( -

- Cancels on {formatDate(activeSubscription?.cancelAt)} -

- )} -
-
- - {activeSubscription?.trialEnd && - new Date( - activeSubscription.trialEnd instanceof Date - ? activeSubscription.trialEnd - : String(activeSubscription.trialEnd) - ) > new Date() && ( - - - Trial Period - - Your trial ends on {formatDate(activeSubscription?.trialEnd)}. - You'll be charged afterward unless you cancel. - - - )} - - - - {/* Usage section */} -
-

Usage

-
-
-
- Events - - {currentUsage.toLocaleString()} /{" "} - {eventLimit.toLocaleString()} - -
- -
-
-
-
-
- - {activeSubscription.cancelAtPeriodEnd ? ( - - ) : ( - - )} - - {/* Only show change plan button if there are other plans available */} - {upgradePlans.length > 0 && ( - - )} - -
- ); -} diff --git a/client/src/app/settings/subscription/components/ErrorDialog.tsx b/client/src/app/settings/subscription/components/ErrorDialog.tsx deleted file mode 100644 index 5f853b3..0000000 --- a/client/src/app/settings/subscription/components/ErrorDialog.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -interface ErrorDialogProps { - showConfigError: boolean; - setShowConfigError: (show: boolean) => void; - errorType: "cancel" | "resume"; - router: { - push: (url: string) => void; - }; -} - -export function ErrorDialog({ - showConfigError, - setShowConfigError, - errorType, - router, -}: ErrorDialogProps) { - return ( - - - - - {errorType === "cancel" - ? "Subscription Cancellation Unavailable" - : "Stripe Checkout Error"} - - - {errorType === "cancel" - ? "Our subscription management system is currently being configured. You cannot cancel your subscription at this time." - : "We encountered an issue while trying to redirect you to the Stripe checkout page. Please try again or view all plans to select a new subscription."} - - - - - {errorType === "resume" ? ( - - ) : ( - - )} - - - - ); -} diff --git a/client/src/app/settings/subscription/components/FreePlan.tsx b/client/src/app/settings/subscription/components/FreePlan.tsx new file mode 100644 index 0000000..7deca23 --- /dev/null +++ b/client/src/app/settings/subscription/components/FreePlan.tsx @@ -0,0 +1,73 @@ +import { ArrowRight } from "lucide-react"; +import { Button } from "../../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../../../../components/ui/card"; +import { Progress } from "../../../../components/ui/progress"; +import { DEFAULT_EVENT_LIMIT } from "../utils/constants"; +import { getPlanDetails } from "../utils/planUtils"; +import { useStripeSubscription } from "../utils/useStripeSubscription"; +import { PlanFeaturesCard } from "./PlanFeaturesCard"; +import { useRouter } from "next/navigation"; + +export function FreePlan() { + const { + data: activeSubscription, + isLoading, + error: subscriptionError, + refetch, + } = useStripeSubscription(); + + const currentUsage = activeSubscription?.monthlyEventCount || 0; + + const router = useRouter(); + + return ( +
+ + + Free Plan + + You are currently on the Free Plan. Upgrade to unlock more events. + + + +
+
+

Usage

+
+
+
+ Events + + {currentUsage.toLocaleString()} /{" "} + {DEFAULT_EVENT_LIMIT.toLocaleString()} + +
+ +
+
+
+
+
+ + + +
+ + +
+ ); +} diff --git a/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx b/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx index 99e2e5f..dd4786d 100644 --- a/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx +++ b/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx @@ -37,7 +37,7 @@ export function PlanFeaturesCard({ currentPlan }: PlanFeaturesCardProps) {
    - {currentPlan?.features.map((feature, i) => ( + {currentPlan?.features?.map((feature, i) => (
  • {feature} diff --git a/client/src/app/settings/subscription/components/ProPlan.tsx b/client/src/app/settings/subscription/components/ProPlan.tsx new file mode 100644 index 0000000..aee86d6 --- /dev/null +++ b/client/src/app/settings/subscription/components/ProPlan.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "../../../../components/ui/button"; +import { Card, CardContent } from "../../../../components/ui/card"; +import { Progress } from "../../../../components/ui/progress"; +import { DEFAULT_EVENT_LIMIT } from "../utils/constants"; +import { formatDate, getPlanDetails, PlanTemplate } from "../utils/planUtils"; +import { useStripeSubscription } from "../utils/useStripeSubscription"; +import { PlanFeaturesCard } from "./PlanFeaturesCard"; +import { Alert } from "../../../../components/ui/alert"; + +export function ProPlan() { + const { + data: activeSubscription, + isLoading, + error: subscriptionError, + refetch, + } = useStripeSubscription(); + + const [isProcessing, setIsProcessing] = useState(false); + const [actionError, setActionError] = useState(null); + + const eventLimit = activeSubscription?.eventLimit || DEFAULT_EVENT_LIMIT; + const currentUsage = activeSubscription?.monthlyEventCount || 0; + const usagePercentage = + eventLimit > 0 ? Math.min((currentUsage / eventLimit) * 100, 100) : 0; + const isAnnualPlan = activeSubscription?.interval === "year"; + + const currentPlanDetails: PlanTemplate | null = activeSubscription + ? getPlanDetails(activeSubscription.planName) + : null; + + const handleManageSubscription = async () => { + setActionError(null); + setIsProcessing(true); + try { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + const response = await fetch( + `${backendUrl}/api/stripe/create-portal-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + returnUrl: window.location.href, + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to create portal session."); + } + + if (data.portalUrl) { + window.location.href = data.portalUrl; + } else { + throw new Error("Portal URL not received."); + } + } catch (err: any) { + console.error("Portal Session Error:", err); + setActionError(err.message || "Could not open billing portal."); + toast.error(`Error: ${err.message || "Could not open billing portal."}`); + } finally { + setIsProcessing(false); + } + }; + + const getFormattedPrice = () => { + if (!currentPlanDetails) return "$0/month"; + return `${currentPlanDetails.price}/${ + currentPlanDetails.interval === "year" ? "year" : "month" + }`; + }; + + const formatRenewalDate = () => { + if (!activeSubscription?.currentPeriodEnd) return "N/A"; + const formattedDate = formatDate(activeSubscription.currentPeriodEnd); + + if (activeSubscription.cancelAtPeriodEnd) { + return `Ends on ${formattedDate}`; + } + if (activeSubscription.status === "active") { + return isAnnualPlan + ? `Renews annually on ${formattedDate}` + : `Renews monthly on ${formattedDate}`; + } + return `Status: ${activeSubscription.status}, ends/renews ${formattedDate}`; + }; + + if (!activeSubscription) { + return null; + } + + return ( +
    + {actionError && {actionError}} + + +
    +
    +
    +

    + {currentPlanDetails?.name || activeSubscription.planName} +

    +

    {getFormattedPrice()}

    + {isAnnualPlan && ( +
    +

    You save by paying annually (2 months free)

    +
    + )} +

    + {formatRenewalDate()} +

    +
    + +
    + +
    +

    Usage

    +
    +
    +
    + Events + + {currentUsage.toLocaleString()} /{" "} + {eventLimit.toLocaleString()} + +
    + +
    +
    +
    + + {isAnnualPlan && ( +
    +

    + Annual Billing: You're on annual billing + which saves you money compared to monthly billing. Your + subscription will renew once per year on{" "} + {formatDate(activeSubscription.currentPeriodEnd)}. +

    +
    + )} +
    +
    +
    + + {/* Conditionally render PlanFeaturesCard only when details are available */} + {!isLoading && currentPlanDetails && ( + + )} +
    + ); +} diff --git a/client/src/app/settings/subscription/page.tsx b/client/src/app/settings/subscription/page.tsx index 2f1c8f0..c0b3dc6 100644 --- a/client/src/app/settings/subscription/page.tsx +++ b/client/src/app/settings/subscription/page.tsx @@ -1,253 +1,28 @@ "use client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { STRIPE_PRICES } from "@/lib/stripe"; -import { AlertCircle, ArrowRight } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useSubscriptionWithUsage } from "../../../api/admin/subscription"; -import { authClient } from "../../../lib/auth"; -import { ChangePlanDialog } from "./components/ChangePlanDialog"; -import { CurrentPlanCard } from "./components/CurrentPlanCard"; -import { ErrorDialog } from "./components/ErrorDialog"; -import { HelpSection } from "./components/HelpSection"; -import { PlanFeaturesCard } from "./components/PlanFeaturesCard"; -import { DEFAULT_EVENT_LIMIT } from "./utils/constants"; -import { getPlanDetails, formatDate } from "./utils/planUtils"; -import { Progress } from "@/components/ui/progress"; -import { Badge } from "@/components/ui/badge"; +import { FreePlan } from "./components/FreePlan"; +import { ProPlan } from "./components/ProPlan"; +import { useStripeSubscription } from "./utils/useStripeSubscription"; export default function SubscriptionPage() { - const router = useRouter(); - const { data: activeSubscription, isLoading, error: subscriptionError, refetch, - } = useSubscriptionWithUsage(); - - console.info("activeSubscription", activeSubscription); - - // State variables - const [errorType, setErrorType] = useState<"cancel" | "resume">("cancel"); - const [showConfigError, setShowConfigError] = useState(false); - const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [actionError, setActionError] = useState(null); - - // Current usage - in a real app, you would fetch this from your API - const currentUsage = activeSubscription?.monthlyEventCount || 0; - - const handleCancelSubscription = async () => { - try { - setIsProcessing(true); - setErrorType("cancel"); - setActionError(null); - - // Don't pass referenceId if it's the same as the user ID - // This is because Better Auth defaults to the user ID when no referenceId is provided - const { error } = await authClient.subscription.cancel({ - returnUrl: globalThis.location.href, - }); - - if (error) { - // Check for specific error about Stripe portal configuration - if ( - error.message?.includes("No configuration provided") || - error.message?.includes("default configuration has not been created") - ) { - // Show the error dialog instead of an alert - setShowConfigError(true); - - // Log detailed instructions for developers/admins - console.error( - "Stripe Customer Portal not configured. Admin needs to set up the Customer Portal at https://dashboard.stripe.com/test/settings/billing/portal" - ); - } else { - setActionError( - error.message || - "An error occurred while canceling the subscription" - ); - } - } - // The user will be redirected to Stripe's billing portal if successful - } catch (err: any) { - console.error("Failed to cancel subscription:", err); - setActionError(err.message || "Failed to cancel subscription"); - } finally { - setIsProcessing(false); - } - }; - - const handleUpgradeSubscription = async (planId: string) => { - try { - setIsProcessing(true); - setActionError(null); - - // Don't pass referenceId if it's the same as the user ID - // Better Auth defaults to the user ID when no referenceId is provided - const { error } = await authClient.subscription.upgrade({ - plan: planId, - cancelUrl: globalThis.location.origin + "/settings/subscription", - successUrl: globalThis.location.origin + "/auth/subscription/success", - }); - - if (error) { - setActionError( - error.message || "An error occurred while changing the plan" - ); - } - // The user will be redirected to Stripe checkout if successful - } catch (err: any) { - console.error("Failed to change plan:", err); - setActionError(err.message || "Failed to change plan"); - } finally { - setIsProcessing(false); - } - }; - - const handleResumeSubscription = async () => { - try { - setIsProcessing(true); - setErrorType("resume"); - setActionError(null); - - // Check if we have the plan information - if (!activeSubscription?.plan) { - setActionError( - "Cannot resume subscription: plan information is missing" - ); - setShowConfigError(true); - return; - } - - // Directly use the upgrade method to take the user to Stripe checkout - // with the same plan they currently have - const { error } = await authClient.subscription.upgrade({ - plan: activeSubscription.plan, - successUrl: globalThis.location.origin + "/auth/subscription/success", - cancelUrl: globalThis.location.origin + "/settings/subscription", - }); - - if (error) { - setActionError( - error.message || "An error occurred while resuming the subscription" - ); - setShowConfigError(true); - } - // The user will be redirected to Stripe checkout if successful - } catch (err: any) { - console.error("Failed to resume subscription:", err); - setActionError(err.message || "Failed to resume subscription"); - setShowConfigError(true); - } finally { - setIsProcessing(false); - } - }; - - const handleShowUpgradeOptions = () => { - setShowUpgradeDialog(true); - setActionError(null); - }; - - // Get information about current plan if there's an active subscription - const currentPlan = activeSubscription - ? getPlanDetails(activeSubscription.plan) - : null; - - // Determine if the current plan is annual - const isAnnualPlan = activeSubscription?.plan?.includes("-annual") || false; - - // Find the next tier plans for upgrade options - const getCurrentTierPrices = () => { - if (!activeSubscription?.plan) return []; - - // Return all available plans for switching, regardless of interval - // The ChangePlanDialog will handle filtering by interval - return STRIPE_PRICES.sort((a, b) => { - // First sort by plan type (basic only now) - - // Then sort by interval (month first, then year) - if (a.interval === "month" && b.interval === "year") return -1; - if (a.interval === "year" && b.interval === "month") return 1; - - // Then sort by event limit - return a.limits.events - b.limits.events; - }); - }; - - const upgradePlans = getCurrentTierPrices(); - - const errorMessage = subscriptionError?.message || actionError || null; - - // Get event limit from the subscription plan - const getEventLimit = () => { - if (!activeSubscription?.plan) return DEFAULT_EVENT_LIMIT; - - const plan = STRIPE_PRICES.find((p) => p.name === activeSubscription.plan); - return plan?.limits.events || DEFAULT_EVENT_LIMIT; - }; - - const eventLimit = getEventLimit(); - const usagePercentage = (currentUsage / eventLimit) * 100; - - // Format the price with the correct interval - const formatPriceWithInterval = (price: number, interval: string) => { - return `$${price}/${interval === "year" ? "year" : "month"}`; - }; - - // Get formatted price for display - const getFormattedPrice = () => { - if (!activeSubscription?.plan) return "$0/month"; - - const plan = STRIPE_PRICES.find((p) => p.name === activeSubscription.plan); - if (!plan) return "$0/month"; - - return formatPriceWithInterval(plan.price, plan.interval); - }; - - // Format the renewal date with appropriate text - const formatRenewalDate = () => { - if (!activeSubscription?.periodEnd) return "N/A"; - - const formattedDate = formatDate(activeSubscription.periodEnd); - - if (activeSubscription.status === "canceled") { - return `Expires on ${formattedDate}`; - } - - if (activeSubscription.cancelAtPeriodEnd) { - return `Ends on ${formattedDate}`; - } - - return isAnnualPlan - ? `Renews annually on ${formattedDate}` - : `Renews monthly on ${formattedDate}`; - }; + } = useStripeSubscription(); return ( -
    -
    +
    +

    Subscription

    Manage your subscription and billing information

    -
    {isLoading ? ( @@ -260,226 +35,11 @@ export default function SubscriptionPage() {
    - ) : errorMessage ? ( - - - Error - {errorMessage} - - ) : !activeSubscription?.plan ? ( -
    - - - Free Plan - - You are currently on the Free Plan. Upgrade to unlock premium - features. - - - -
    -
    -
    -

    Plan

    -

    Free

    -

    - $0/month -

    -
    -
    -

    Renewal Date

    -

    Never expires

    -
    -
    - -
    -

    Usage

    -
    -
    -
    - Events - - {currentUsage.toLocaleString()} /{" "} - {DEFAULT_EVENT_LIMIT.toLocaleString()} - -
    - -
    -
    -
    -
    -
    - - - -
    - - - - -
    + ) : !activeSubscription ? ( + ) : ( -
    - {/* Current Plan */} - - -
    -
    - - {currentPlan?.name || "Current Plan"} - {isAnnualPlan && ( - - Annual - - )} - {activeSubscription.cancelAtPeriodEnd && ( - - Canceling - - )} - - - {activeSubscription.cancelAtPeriodEnd - ? "Your subscription will be canceled at the end of the current billing period." - : activeSubscription.status === "active" - ? "Your subscription is active." - : activeSubscription.status === "canceled" - ? "Your subscription has been canceled but is still active until the end of the billing period." - : "Your subscription is inactive."} - -
    -
    - {activeSubscription.status === "active" && ( - - )} -
    -
    -
    - -
    -
    -
    -

    Plan

    -

    {currentPlan?.name}

    -

    - {getFormattedPrice()} -

    - {isAnnualPlan && ( -
    -

    You save by paying annually (2 months free)

    -
    - )} -
    -
    -

    Renewal Date

    -

    {formatRenewalDate()}

    -

    - {activeSubscription.cancelAtPeriodEnd - ? "Your subscription will not renew after this date" - : isAnnualPlan - ? "Your plan renews once per year" - : "Your plan renews monthly"} -

    -
    -
    - -
    -

    Usage

    -
    -
    -
    - Events - - {currentUsage.toLocaleString()} /{" "} - {eventLimit.toLocaleString()} - -
    - -
    -
    -
    - - {/* Billing Cycle Explanation */} - {isAnnualPlan && ( -
    -

    - Annual Billing: You're on annual billing - which saves you money compared to monthly billing. Your - subscription will renew once per year on{" "} - {formatDate(activeSubscription.periodEnd)}. -

    -
    - )} -
    -
    - - {activeSubscription.status === "active" ? ( - activeSubscription.cancelAtPeriodEnd ? ( - - ) : ( - - ) - ) : ( - - )} - -
    - - {/* Plan Features */} - -
    + )} - - {/* Help section */} - - - {/* Error dialog */} - - - {/* Change plan dialog */} -
    ); } diff --git a/client/src/app/settings/subscription/utils/planUtils.tsx b/client/src/app/settings/subscription/utils/planUtils.tsx index 8d44cb7..2b0c29e 100644 --- a/client/src/app/settings/subscription/utils/planUtils.tsx +++ b/client/src/app/settings/subscription/utils/planUtils.tsx @@ -19,7 +19,7 @@ export const getPlanDetails = ( ): PlanTemplate | null => { if (!planName) return null; - const tier = planName.startsWith("basic") ? "basic" : "free"; + const tier = planName.startsWith("pro") ? "pro" : "free"; const stripePlan = STRIPE_PRICES.find((p) => p.name === planName); const planTemplates: Record = { @@ -29,27 +29,18 @@ export const getPlanDetails = ( price: "$0", interval: "month", description: "Get started with basic analytics", - features: [ - "20,000 events per month", - "Basic analytics", - "7-day data retention", - "Community support", - ], + features: ["10,000 events per month", "6 month data retention"], color: "bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900", icon: , }, - basic: { - id: "basic", + pro: { + id: "pro", name: "Pro", price: "$19+", interval: "month", description: "Advanced analytics for growing projects", - features: [ - "Advanced analytics features", - "14-day data retention", - "Priority support", - ], + features: ["5 year data retention", "Priority support"], color: "bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800", icon: , diff --git a/client/src/app/settings/subscription/utils/useStripeSubscription.ts b/client/src/app/settings/subscription/utils/useStripeSubscription.ts new file mode 100644 index 0000000..decf646 --- /dev/null +++ b/client/src/app/settings/subscription/utils/useStripeSubscription.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; + +interface SubscriptionData { + id: string; + planName: string; + status: string; + currentPeriodEnd: string; + monthlyEventCount: number; + eventLimit: number; + interval: string; + cancelAtPeriodEnd?: boolean; +} + +export function useStripeSubscription() { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + + const fetchSubscription = async () => { + const response = await fetch(`${backendUrl}/api/stripe/subscription`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + if (response.status === 401) { + return null; + } else { + const errorData = await response.json(); + throw new Error(errorData.error || `Error: ${response.status}`); + } + } + + return response.json(); + }; + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["stripe-subscription"], + queryFn: fetchSubscription, + staleTime: 5 * 60 * 1000, // 5 minutes + retry: false, + }); + + return { data, isLoading, error, refetch }; +} diff --git a/client/src/app/subscribe/page.tsx b/client/src/app/subscribe/page.tsx index b6992c7..c40e6df 100644 --- a/client/src/app/subscribe/page.tsx +++ b/client/src/app/subscribe/page.tsx @@ -1,33 +1,32 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; -import { Check, Users } from "lucide-react"; -import { authClient } from "@/lib/auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, + CardContent, + CardDescription, + CardFooter, CardHeader, CardTitle, - CardDescription, - CardContent, - CardFooter, } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { STRIPE_PRICES } from "@/lib/stripe"; import { Slider } from "@/components/ui/slider"; -import { Badge } from "@/components/ui/badge"; +import { authClient } from "@/lib/auth"; +import { STRIPE_PRICES } from "@/lib/stripe"; import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { StandardPage } from "../../components/StandardPage"; // Available event tiers for the slider const EVENT_TIERS = [ - 20_000, 100_000, 250_000, 500_000, 1_000_000, 2_000_000, 5_000_000, - 10_000_000, + 100_000, 250_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000, ]; // Define types for plans interface PlanTemplate { - id: "free" | "basic"; + id: "free" | "pro"; name: string; price?: string; interval?: string; @@ -63,23 +62,15 @@ const PLAN_TEMPLATES: PlanTemplate[] = [ price: "$0", interval: "month", description: "Get started with basic analytics", - baseFeatures: [ - "Basic analytics", - "7-day data retention", - "Community support", - ], + baseFeatures: ["6 month data retention"], color: "bg-gradient-to-br from-neutral-100 to-neutral-200 dark:from-neutral-800 dark:to-neutral-900", }, { - id: "basic", + id: "pro", name: "Pro", description: "Advanced analytics for growing projects", - baseFeatures: [ - "Advanced analytics features", - "14-day data retention", - "Priority support", - ], + baseFeatures: ["5 year data retention", "Priority support"], color: "bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800", }, @@ -92,7 +83,7 @@ function getFormattedPrice(price: number): string { // Find the appropriate price for a tier at current event limit function findPriceForTier( - tier: "basic", + tier: "free" | "pro", eventLimit: number, interval: "month" | "year" ): StripePrice | null { @@ -154,134 +145,85 @@ function calculateSavings(monthlyPrice: number, annualPrice: number): string { return `Save ${savingsPercent}%`; } -// Function to get the direct plan ID based on criteria -function getDirectPlanID( - tier: "basic", - eventLimit: number, - isAnnual: boolean -): string { - // Base pattern for plan names is like "basic100k" or "pro250k" - let planPrefix = tier; - let eventSuffix = ""; - - // Determine event tier suffix - if (eventLimit <= 100_000) { - eventSuffix = "100k"; - } else if (eventLimit <= 250_000) { - eventSuffix = "250k"; - } else if (eventLimit <= 500_000) { - eventSuffix = "500k"; - } else if (eventLimit <= 1_000_000) { - eventSuffix = "1m"; - } else if (eventLimit <= 2_000_000) { - eventSuffix = "2m"; - } else if (eventLimit <= 5_000_000) { - eventSuffix = "5m"; - } else { - eventSuffix = "10m"; - } - - // Construct the plan name with annual suffix if needed - const planName = `${planPrefix}${eventSuffix}${isAnnual ? "-annual" : ""}`; - console.log(`Constructed plan name: ${planName} for isAnnual=${isAnnual}`); - - return planName; -} - export default function Subscribe() { - const [selectedTier, setSelectedTier] = useState<"free" | "basic">("free"); + const [selectedTier, setSelectedTier] = useState<"free" | "pro">("free"); const [eventLimitIndex, setEventLimitIndex] = useState(0); // Default to 20k (index 0) - const [selectedPrice, setSelectedPrice] = useState(null); - const [isAnnual, setIsAnnual] = useState(false); + const [isAnnual, setIsAnnual] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const { data: sessionData } = authClient.useSession(); - // Get the actual event limit value from the index const eventLimit = EVENT_TIERS[eventLimitIndex]; - // Check if free plan is available based on event limit - const isFreeAvailable = eventLimit <= 20_000; - - // Group plans by type and interval - const basicMonthlyPlans = STRIPE_PRICES.filter( - (plan) => plan.name.startsWith("basic") && !plan.name.includes("-annual") - ); - const basicAnnualPlans = STRIPE_PRICES.filter( - (plan) => plan.name.includes("basic") && plan.name.includes("-annual") - ); - - // Update the selected price when tier or event limit changes - useEffect(() => { - if (selectedTier === "free") { - setSelectedPrice(null); - return; - } - - // Get the correct set of plans based on the tier and interval - let filteredPlans; - if (selectedTier === "basic") { - filteredPlans = isAnnual ? basicAnnualPlans : basicMonthlyPlans; - } - - const matchingPlan = - filteredPlans?.find((plan) => plan.limits.events >= eventLimit) || - filteredPlans?.[filteredPlans.length - 1]; - if (matchingPlan) { - setSelectedPrice(matchingPlan); - } - }, [selectedTier, eventLimit, isAnnual]); + // TODO: Implement proper check if user already has an active subscription + const isFreeAvailable = !!sessionData?.user; // Placeholder check based on login // Handle subscription - function handleSubscribe(planId: "free" | "basic"): void { - setSelectedTier(planId); - - if (planId === "free") return; - - // Use the direct plan mapping approach with the new naming scheme - const planName = getDirectPlanID("basic", eventLimit, isAnnual); - - console.log( - `Direct plan mapping selected: ${planName}, interval=${ - isAnnual ? "year" : "month" - }` - ); - - // Find the specific plan object in STRIPE_PRICES - const interval = isAnnual ? "year" : "month"; - - // The filter should match the new naming convention for annual plans - const matchingPlans = STRIPE_PRICES.filter((p) => { - const nameMatches = p.name === planName; - const intervalMatches = p.interval === interval; - return nameMatches && intervalMatches; - }); - - console.log( - `Found ${matchingPlans.length} matching plans for ${planName} (${interval})` - ); - - if (matchingPlans.length === 0) { - console.error( - `No matching plan found for name=${planName}, interval=${interval}` - ); + async function handleSubscribe(planId: "free" | "pro"): Promise { + if (planId === "free") { return; } - const selectedPlan = matchingPlans[0]; - console.log(`Selected plan: `, selectedPlan); + // Check if user is logged in directly + if (!sessionData?.user) { + toast.error("Please log in to subscribe."); + return; + } - // Log the selected plan to verify - console.log( - `Subscribing to ${selectedPlan.name} (${selectedPlan.interval}) - $${selectedPlan.price} - ${selectedPlan.limits.events} events` - ); + if (planId === "pro") { + const selectedTierPrice = findPriceForTier( + "pro", + eventLimit, + isAnnual ? "year" : "month" + ); - authClient.subscription - .upgrade({ - plan: selectedPlan.name, - successUrl: globalThis.location.origin + "/auth/subscription/success", - cancelUrl: globalThis.location.origin + "/subscribe", - }) - .catch((error) => { - console.error("Subscription error:", error); - }); + if (!selectedTierPrice) { + toast.error( + "Selected pricing plan not found. Please adjust the slider." + ); + return; + } + + setIsLoading(true); + try { + // Use NEXT_PUBLIC_BACKEND_URL if available, otherwise use relative path for same-origin requests + const backendBaseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + const baseUrl = window.location.origin; + const successUrl = `${baseUrl}/settings/subscription?session_id={CHECKOUT_SESSION_ID}`; + const cancelUrl = `${baseUrl}/subscribe`; + + const response = await fetch( + `${backendBaseUrl}/api/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", // Send cookies + body: JSON.stringify({ + priceId: selectedTierPrice.priceId, + successUrl: successUrl, + cancelUrl: cancelUrl, + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to create checkout session."); + } + + if (data.checkoutUrl) { + window.location.href = data.checkoutUrl; // Redirect to Stripe checkout + } else { + throw new Error("Checkout URL not received."); + } + } catch (error: any) { + console.error("Subscription Error:", error); + toast.error(`Subscription failed: ${error.message}`); + setIsLoading(false); // Stop loading on error + } + } } // Handle slider changes @@ -289,24 +231,24 @@ export default function Subscribe() { setEventLimitIndex(value[0]); // If event limit is over 20k, ensure free plan is not selected - if (EVENT_TIERS[value[0]] > 20_000 && selectedTier === "free") { - setSelectedTier("basic"); + if (EVENT_TIERS[value[0]] > 10_000 && selectedTier === "free") { + setSelectedTier("pro"); } } // Find the current prices for each tier based on the event limit const interval = isAnnual ? "year" : "month"; - const basicTierPrice = findPriceForTier("basic", eventLimit, interval); + const basicTierPrice = findPriceForTier("pro", eventLimit, interval); // Also get monthly prices for savings calculation - const basicMonthly = findPriceForTier("basic", eventLimit, "month"); - const basicAnnual = findPriceForTier("basic", eventLimit, "year"); + const basicMonthly = findPriceForTier("pro", eventLimit, "month"); + const basicAnnual = findPriceForTier("pro", eventLimit, "year"); // Generate plan objects with current state const plans: Plan[] = PLAN_TEMPLATES.map((template) => { const plan = { ...template } as Plan; - if (plan.id === "basic") { + if (plan.id === "pro") { const tierPrice = basicTierPrice; plan.price = tierPrice ? getFormattedPrice(tierPrice.price) : "$19+"; plan.interval = isAnnual ? "year" : "month"; @@ -324,7 +266,7 @@ export default function Subscribe() { // Add event limit feature at the beginning const eventFeature = plan.id === "free" - ? "20,000 events per month" + ? "10,000 events per month" : `${Math.max(eventLimit, 100_000).toLocaleString()} events per month`; plan.features = [eventFeature, ...plan.baseFeatures]; @@ -342,80 +284,9 @@ export default function Subscribe() {

    Find the perfect plan to track your site's performance

    - - {/* Billing toggle buttons */} -
    -
    - -
    - - - 2 months free - -
    -
    -
    -
    -
    -

    - How many events do you need? -

    -
    - - Events per month - - - {eventLimit.toLocaleString()} - -
    -
    - - - -
    - {EVENT_TIERS.map((tier, index) => ( - - {tier.toLocaleString()} - - ))} -
    -
    - -
    +
    {plans.map((plan) => (
    - {plan.name} - -
    - - {plan.price} - - {plan.interval && ( - - /{plan.interval} - - )} -
    - {isAnnual && plan.id !== "free" && ( - - 2 months free - - )} + {plan.name} +

    {plan.description}

    - + {plan.id === "pro" && ( + <> + + {/* Billing toggle buttons */} +
    +
    + +
    + + + 2 months free + +
    +
    +
    + + {/* Event slider */} +
    +
    +
    +
    + Events per month +
    + {eventLimit.toLocaleString()} +
    +
    +
    + + {plan.price} + + {plan.interval && ( + + /{plan.interval} + + )} +
    +
    +
    + + + +
    + {EVENT_TIERS.map((tier, index) => ( + + {tier >= 1_000_000 + ? `${tier / 1_000_000}M` + : `${tier / 1_000}K`} + + ))} +
    +
    +
    + + )} + +
      {plan.features.map((feature, i) => ( @@ -466,12 +414,16 @@ export default function Subscribe() { - {plan.id === "basic" ? ( + {plan.id === "pro" ? ( ) : (