init subscription (#51)

* init subscription

* add subscription plans

* fix docker

* fix docker

* fix docker

* wip

* wip
This commit is contained in:
Bill Yang 2025-03-11 21:03:23 -07:00 committed by GitHub
parent 2d22dc6fee
commit bcc1cc5d29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1747 additions and 62 deletions

View file

@ -19,6 +19,8 @@
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",

View file

@ -20,6 +20,8 @@
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",

View file

@ -17,7 +17,7 @@ const metadata: Metadata = {
title: "Frogstats Analytics",
description: "Analytics dashboard for your web applications",
};
const publicRoutes = ["/login"];
const publicRoutes = ["/login", "/signup"];
export default function RootLayout({
children,
@ -29,12 +29,7 @@ export default function RootLayout({
const pathname = usePathname();
useEffect(() => {
if (
!isPending &&
!user &&
!publicRoutes.includes(pathname) &&
pathname !== "/signup"
) {
if (!isPending && !user && !publicRoutes.includes(pathname)) {
redirect("/login");
}
}, [isPending, user, pathname]);

View file

@ -4,18 +4,24 @@ import { Button } from "@/components/ui/button";
import { authClient } from "../../lib/auth";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { GearSix, User, Users as Users_ } from "@phosphor-icons/react";
import {
GearSix,
User,
Users as Users_,
CreditCard,
} from "@phosphor-icons/react";
import { Input } from "../../components/ui/input";
import { Account } from "./account/Account";
import { Organizations } from "./organizations/Organizations";
import { Settings } from "./settings/settings";
import SubscriptionPage from "./subscription/page";
export default function SettingsPage() {
const session = authClient.useSession();
const router = useRouter();
const [selectedTab, setSelectedTab] = useState<
"account" | "settings" | "organizations"
"account" | "settings" | "organizations" | "subscription"
>("account");
return (
@ -46,12 +52,21 @@ export default function SettingsPage() {
<Users_ size={16} weight="bold" />
Organizations
</Button>
<Button
variant={selectedTab === "subscription" ? "default" : "ghost"}
onClick={() => setSelectedTab("subscription")}
className="justify-start"
>
<CreditCard size={16} weight="bold" />
Subscription
</Button>
</div>
{selectedTab === "account" && session.data?.user && (
<Account session={session} />
)}
{selectedTab === "organizations" && <Organizations />}
{selectedTab === "settings" && <Settings />}
{selectedTab === "subscription" && <SubscriptionPage />}
</div>
</div>
);

View file

@ -0,0 +1,257 @@
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 { Subscription } from "@/hooks/api";
import { AlertCircle, Shield, Zap } from "lucide-react";
import { useEffect, useState } from "react";
interface ChangePlanDialogProps {
showUpgradeDialog: boolean;
setShowUpgradeDialog: (show: boolean) => void;
actionError: string | null;
upgradePlans: any[];
activeSubscription: Subscription | null | undefined;
isProcessing: boolean;
handleUpgradeSubscription: (planId: string) => Promise<void>;
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<string | null>(null);
// When dialog opens and subscription is canceled, highlight the current plan
useEffect(() => {
if (
showUpgradeDialog &&
activeSubscription?.cancelAtPeriodEnd &&
activeSubscription?.plan
) {
setResumingPlan(activeSubscription.plan);
} else {
setResumingPlan(null);
}
}, [showUpgradeDialog, activeSubscription]);
return (
<Dialog
open={showUpgradeDialog}
onOpenChange={(open) => {
setShowUpgradeDialog(open);
if (!open) setResumingPlan(null);
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{activeSubscription?.cancelAtPeriodEnd
? "Resume Subscription"
: "Change Your Plan"}
</DialogTitle>
<DialogDescription className="py-4">
{activeSubscription?.cancelAtPeriodEnd
? "Select a plan to resume your subscription. Your current plan is highlighted."
: "Select a plan to switch to"}
</DialogDescription>
</DialogHeader>
{actionError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{actionError}</AlertDescription>
</Alert>
)}
{resumingPlan && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resuming Subscription</AlertTitle>
<AlertDescription>
Your current plan is highlighted. Click "Select" to resume this
plan or choose a different one.
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* Basic Plans */}
<div>
<h3 className="font-medium mb-3 flex items-center">
<Shield className="h-4 w-4 mr-2 text-green-500" />
Basic Plans
</h3>
<div className="space-y-3">
{upgradePlans
.filter((plan) => plan.name.startsWith("basic"))
.map((plan) => (
<Card
key={plan.priceId}
className={`cursor-pointer hover:shadow-md transition-shadow ${
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
resumingPlan === plan.name
? "ring-2 ring-green-400"
: ""
}`}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold">
{plan.limits.events.toLocaleString()} events
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
${plan.price} / {plan.interval}
</p>
</div>
<Button
size="sm"
variant={
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
(resumingPlan === plan.name &&
resumingPlan === activeSubscription?.plan)
? "outline"
: "default"
}
onClick={() => {
if (
activeSubscription?.plan !== plan.name ||
activeSubscription?.cancelAtPeriodEnd
) {
handleUpgradeSubscription(plan.name);
}
}}
disabled={
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
isProcessing
}
>
{activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd
? "Current"
: resumingPlan === plan.name &&
resumingPlan === activeSubscription?.plan
? "Resume"
: isProcessing
? "Processing..."
: "Select"}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Pro Plans */}
<div>
<h3 className="font-medium mb-3 flex items-center">
<Zap className="h-4 w-4 mr-2 text-emerald-500" />
Pro Plans
</h3>
<div className="space-y-3">
{upgradePlans
.filter((plan) => plan.name.startsWith("pro"))
.map((plan) => (
<Card
key={plan.priceId}
className={`cursor-pointer hover:shadow-md transition-shadow ${
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
resumingPlan === plan.name
? "ring-2 ring-emerald-400"
: ""
}`}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold">
{plan.limits.events.toLocaleString()} events
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
${plan.price} / {plan.interval}
</p>
</div>
<Button
size="sm"
variant={
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
(resumingPlan === plan.name &&
resumingPlan === activeSubscription?.plan)
? "outline"
: "default"
}
onClick={() => {
if (
activeSubscription?.plan !== plan.name ||
activeSubscription?.cancelAtPeriodEnd
) {
handleUpgradeSubscription(plan.name);
}
}}
disabled={
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
isProcessing
}
>
{activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd
? "Current"
: resumingPlan === plan.name &&
resumingPlan === activeSubscription?.plan
? "Resume"
: isProcessing
? "Processing..."
: "Select"}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUpgradeDialog(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => {
router.push("/subscribe");
setShowUpgradeDialog(false);
}}
>
View All Plans
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,179 @@
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 "@/hooks/api";
import { PlanTemplate, formatDate } from "../utils/planUtils";
import { AlertCircle, ArrowRight, X } from "lucide-react";
interface CurrentPlanCardProps {
activeSubscription: Subscription;
currentPlan: PlanTemplate | null;
currentUsage: { events: number };
eventLimit: number;
usagePercentage: number;
isProcessing: boolean;
handleCancelSubscription: () => Promise<void>;
handleResumeSubscription: () => Promise<void>;
handleShowUpgradeOptions: () => void;
upgradePlans: any[];
}
export function CurrentPlanCard({
activeSubscription,
currentPlan,
currentUsage,
eventLimit,
usagePercentage,
isProcessing,
handleCancelSubscription,
handleResumeSubscription,
handleShowUpgradeOptions,
upgradePlans,
}: CurrentPlanCardProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl">Current Plan</CardTitle>
<CardDescription>Your current subscription details</CardDescription>
</div>
<Badge
variant={
activeSubscription.cancelAtPeriodEnd
? "outline"
: activeSubscription?.status === "active"
? "default"
: activeSubscription?.status === "trialing"
? "outline"
: "destructive"
}
>
{activeSubscription.cancelAtPeriodEnd
? "Cancels Soon"
: activeSubscription?.status === "active"
? "Active"
: activeSubscription?.status === "trialing"
? "Trial"
: activeSubscription?.status === "canceled"
? "Canceled"
: activeSubscription?.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h3 className="font-medium">Plan</h3>
<p className="text-lg font-bold">{currentPlan?.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{currentPlan?.price}/{currentPlan?.interval}
</p>
</div>
<div>
<h3 className="font-medium">
{activeSubscription.cancelAtPeriodEnd ||
activeSubscription.status === "canceled"
? "Ends On"
: "Renewal Date"}
</h3>
<p className="text-lg font-bold">
{formatDate(activeSubscription?.periodEnd)}
</p>
{activeSubscription?.cancelAt &&
!activeSubscription.cancelAtPeriodEnd && (
<p className="text-sm text-red-500">
Cancels on {formatDate(activeSubscription?.cancelAt)}
</p>
)}
</div>
</div>
{activeSubscription?.trialEnd &&
new Date(
activeSubscription.trialEnd instanceof Date
? activeSubscription.trialEnd
: String(activeSubscription.trialEnd)
) > new Date() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Trial Period</AlertTitle>
<AlertDescription>
Your trial ends on {formatDate(activeSubscription?.trialEnd)}.
You'll be charged afterward unless you cancel.
</AlertDescription>
</Alert>
)}
<Separator />
{/* Usage section */}
<div>
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.events.toLocaleString()} /{" "}
{eventLimit.toLocaleString()}
</span>
</div>
<Progress value={usagePercentage} />
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
{activeSubscription.cancelAtPeriodEnd ? (
<Button
variant="default"
onClick={handleResumeSubscription}
disabled={isProcessing}
>
{isProcessing ? "Processing..." : <>Resume Subscription</>}
</Button>
) : (
<Button
variant="outline"
onClick={handleCancelSubscription}
disabled={isProcessing}
>
{isProcessing ? (
"Processing..."
) : (
<>
Cancel Subscription <X className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
{/* Only show change plan button if there are other plans available */}
{upgradePlans.length > 0 && (
<Button onClick={handleShowUpgradeOptions} disabled={isProcessing}>
{isProcessing ? (
"Processing..."
) : (
<>
Change Plan <ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}

View file

@ -0,0 +1,68 @@
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 (
<Dialog open={showConfigError} onOpenChange={setShowConfigError}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{errorType === "cancel"
? "Subscription Cancellation Unavailable"
: "Stripe Checkout Error"}
</DialogTitle>
<DialogDescription className="py-4">
{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."}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex justify-between">
<Button onClick={() => setShowConfigError(false)}>Close</Button>
{errorType === "resume" ? (
<Button
variant="default"
onClick={() => {
router.push("/subscribe");
setShowConfigError(false);
}}
>
View Plans
</Button>
) : (
<Button
variant="outline"
onClick={() => {
router.push("/contact");
setShowConfigError(false);
}}
>
Contact Support
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,22 @@
import { Button } from "@/components/ui/button";
interface HelpSectionProps {
router: {
push: (url: string) => void;
};
}
export function HelpSection({ router }: HelpSectionProps) {
return (
<div className="mt-8">
<h2 className="text-lg font-medium mb-4">Need Help?</h2>
<p className="text-gray-500 dark:text-gray-400 mb-2">
For billing questions or subscription support, please contact our
customer service team.
</p>
<Button variant="outline" onClick={() => router.push("/contact")}>
Contact Support
</Button>
</div>
);
}

View file

@ -0,0 +1,36 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Check } from "lucide-react";
import { PlanTemplate } from "../utils/planUtils";
interface PlanFeaturesCardProps {
currentPlan: PlanTemplate | null;
}
export function PlanFeaturesCard({ currentPlan }: PlanFeaturesCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Plan Features</CardTitle>
<CardDescription>
What's included in your {currentPlan?.name} plan
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{currentPlan?.features.map((feature, i) => (
<li key={i} className="flex items-start">
<Check className="mr-2 h-5 w-5 text-green-500 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,288 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useSubscription } from "@/hooks/api";
import { AlertCircle, ArrowRight } from "lucide-react";
import { authClient } from "../../../lib/auth";
import { CurrentPlanCard } from "./components/CurrentPlanCard";
import { PlanFeaturesCard } from "./components/PlanFeaturesCard";
import { ChangePlanDialog } from "./components/ChangePlanDialog";
import { ErrorDialog } from "./components/ErrorDialog";
import { HelpSection } from "./components/HelpSection";
import { getPlanDetails } from "./utils/planUtils";
import { DEFAULT_EVENT_LIMIT, DEFAULT_USAGE } from "./utils/constants";
import { STRIPE_PRICES } from "@/lib/stripe";
export default function SubscriptionPage() {
const router = useRouter();
const {
data: activeSubscription,
isLoading,
error: subscriptionError,
refetch,
} = useSubscription();
// 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<string | null>(null);
// Current usage - in a real app, you would fetch this from your API
const currentUsage = DEFAULT_USAGE;
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: window.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,
successUrl: "/settings",
cancelUrl: "/settings",
});
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: window.location.origin + "/settings",
cancelUrl: window.location.origin + "/settings",
});
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;
// Find the next tier plans for upgrade options
const getCurrentTierPrices = () => {
if (!activeSubscription?.plan) return [];
// Return all available plans for switching
return STRIPE_PRICES.sort((a, b) => {
// First sort by plan type (basic first, then pro)
if (a.name.startsWith("basic") && b.name.startsWith("pro")) return -1;
if (a.name.startsWith("pro") && b.name.startsWith("basic")) 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.events / eventLimit) * 100;
return (
<div className="container py-10 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Subscription</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage your subscription and billing information
</p>
</div>
<Button variant="outline" onClick={() => router.push("/subscribe")}>
View Plans
</Button>
</div>
{isLoading ? (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
<Skeleton className="h-20 w-full mt-4" />
</div>
</CardContent>
</Card>
) : errorMessage ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
) : !activeSubscription ? (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>No Active Subscription</CardTitle>
<CardDescription>
You don't have an active subscription. Choose a plan to get
started.
</CardDescription>
</CardHeader>
<CardFooter>
<Button onClick={() => router.push("/subscribe")}>
View Plans <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
</div>
) : (
<div className="space-y-6">
{/* Current Plan */}
<CurrentPlanCard
activeSubscription={activeSubscription}
currentPlan={currentPlan}
currentUsage={currentUsage}
eventLimit={eventLimit}
usagePercentage={usagePercentage}
isProcessing={isProcessing}
handleCancelSubscription={handleCancelSubscription}
handleResumeSubscription={handleResumeSubscription}
handleShowUpgradeOptions={handleShowUpgradeOptions}
upgradePlans={upgradePlans}
/>
{/* Plan Features */}
<PlanFeaturesCard currentPlan={currentPlan} />
</div>
)}
{/* Help section */}
<HelpSection router={router} />
{/* Error dialog */}
<ErrorDialog
showConfigError={showConfigError}
setShowConfigError={setShowConfigError}
errorType={errorType}
router={router}
/>
{/* Change plan dialog */}
<ChangePlanDialog
showUpgradeDialog={showUpgradeDialog}
setShowUpgradeDialog={setShowUpgradeDialog}
actionError={actionError}
upgradePlans={upgradePlans}
activeSubscription={activeSubscription}
isProcessing={isProcessing}
handleUpgradeSubscription={handleUpgradeSubscription}
router={router}
/>
</div>
);
}

View file

@ -0,0 +1,7 @@
// Current usage - in a real app, you would fetch this from your API
export const DEFAULT_USAGE = {
events: 45000, // Example value
};
// Default event limit if not specified in subscription
export const DEFAULT_EVENT_LIMIT = 100000;

View file

@ -0,0 +1,105 @@
import { Clock, Shield, Zap } from "lucide-react";
import { STRIPE_PRICES } from "@/lib/stripe";
// Define interfaces for plan data
export interface PlanTemplate {
id: string;
name: string;
price: string;
interval: string;
description: string;
features: string[];
color: string;
icon: React.ReactNode;
}
// Helper to get the appropriate plan details based on subscription plan name
export const getPlanDetails = (
planName: string | undefined
): PlanTemplate | null => {
if (!planName) return null;
const tier = planName.startsWith("basic")
? "basic"
: planName.startsWith("pro")
? "pro"
: "free";
const stripePlan = STRIPE_PRICES.find((p) => p.name === planName);
const planTemplates: Record<string, PlanTemplate> = {
free: {
id: "free",
name: "Free",
price: "$0",
interval: "month",
description: "Get started with basic analytics",
features: [
"20,000 events per month",
"Basic analytics",
"7-day data retention",
"Community support",
],
color:
"bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900",
icon: <Clock className="h-5 w-5" />,
},
basic: {
id: "basic",
name: "Basic",
price: "$19+",
interval: "month",
description: "Essential analytics for small projects",
features: [
"Core analytics features",
"14-day data retention",
"Basic support",
],
color:
"bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800",
icon: <Shield className="h-5 w-5" />,
},
pro: {
id: "pro",
name: "Pro",
price: "$39+",
interval: "month",
description: "Advanced analytics for growing businesses",
features: [
"Advanced dashboard features",
"30-day data retention",
"Priority support",
"Custom event definitions",
"Team collaboration",
],
color:
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-emerald-800 dark:to-teal-800",
icon: <Zap className="h-5 w-5" />,
},
};
const plan = { ...planTemplates[tier] };
if (stripePlan) {
plan.price = `$${stripePlan.price}`;
plan.interval = stripePlan.interval;
// Add event limit as first feature
plan.features = [
`${stripePlan.limits.events.toLocaleString()} events per month`,
...plan.features,
];
}
return plan;
};
// Helper function to format dates
export const formatDate = (dateString: string | Date | null | undefined) => {
if (!dateString) return "N/A";
const date = dateString instanceof Date ? dateString : new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};

View file

@ -0,0 +1,365 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Check, Zap, Shield, Clock, Users } from "lucide-react";
import { authClient } from "@/lib/auth";
import {
Card,
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";
// Available event tiers for the slider
const EVENT_TIERS = [20_000, 100_000, 250_000, 500_000, 1_000_000, 2_000_000];
// Define types for plans
interface PlanTemplate {
id: "free" | "basic" | "pro";
name: string;
price?: string;
interval?: string;
description: string;
baseFeatures: string[];
color: string;
icon: React.ReactNode;
}
interface Plan extends PlanTemplate {
price: string;
interval: string;
features: string[];
}
interface StripePrice {
priceId: string;
price: number;
name: string;
interval: string;
limits: {
events: number;
};
}
// Plan templates
const PLAN_TEMPLATES: PlanTemplate[] = [
{
id: "free",
name: "Free",
price: "$0",
interval: "month",
description: "Get started with basic analytics",
baseFeatures: [
"Basic analytics",
"7-day data retention",
"Community support",
],
color:
"bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900",
icon: <Clock className="h-5 w-5" />,
},
{
id: "basic",
name: "Basic",
description: "Essential analytics for small projects",
baseFeatures: [
"Core analytics features",
"14-day data retention",
"Basic support",
],
color:
"bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800",
icon: <Shield className="h-5 w-5" />,
},
{
id: "pro",
name: "Pro",
description: "Advanced analytics for growing businesses",
baseFeatures: [
"Advanced dashboard features",
"30-day data retention",
"Priority support",
"Custom event definitions",
"Team collaboration",
],
color:
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-emerald-800 dark:to-teal-800",
icon: <Zap className="h-5 w-5" />,
},
];
// Format price with dollar sign
function getFormattedPrice(plan: StripePrice): string {
return `$${plan.price}`;
}
// Find the appropriate price for a tier at current event limit
function findPriceForTier(
tier: "basic" | "pro",
eventLimit: number
): StripePrice | null {
const plans = STRIPE_PRICES.filter((plan) => plan.name.startsWith(tier));
return (
plans.find((plan) => plan.limits.events >= eventLimit) ||
plans[plans.length - 1] ||
null
);
}
export default function Subscribe() {
const [selectedTier, setSelectedTier] = useState<"free" | "basic" | "pro">(
"free"
);
const [eventLimitIndex, setEventLimitIndex] = useState<number>(1); // Default to 100k (index 1)
const [selectedPrice, setSelectedPrice] = useState<StripePrice | null>(null);
// Get the actual event limit value from the index
const eventLimit = EVENT_TIERS[eventLimitIndex];
// Group plans by type
const basicPlans = STRIPE_PRICES.filter((plan) =>
plan.name.startsWith("basic")
);
const proPlans = STRIPE_PRICES.filter((plan) => plan.name.startsWith("pro"));
// Update the selected price when tier or event limit changes
useEffect(() => {
if (selectedTier === "free") {
setSelectedPrice(null);
return;
}
const plans = selectedTier === "basic" ? basicPlans : proPlans;
const matchingPlan =
plans.find((plan) => plan.limits.events >= eventLimit) ||
plans[plans.length - 1];
setSelectedPrice(matchingPlan);
}, [selectedTier, eventLimit, basicPlans, proPlans]);
// Handle subscription
function handleSubscribe(): void {
if (!selectedPrice) return;
authClient.subscription
.upgrade({
plan: selectedPrice.name,
successUrl: "/",
cancelUrl: "/subscribe",
})
.catch((error) => {
console.error("Subscription error:", error);
});
}
// Handle slider changes
function handleSliderChange(value: number[]): void {
setEventLimitIndex(value[0]);
}
// Handle tier selection
function handleTierSelection(tier: "free" | "basic" | "pro"): void {
setSelectedTier(tier);
}
// Find the current prices for each tier based on the event limit
const basicTierPrice = findPriceForTier("basic", eventLimit);
const proTierPrice = findPriceForTier("pro", eventLimit);
// Generate plan objects with current state
const plans: Plan[] = PLAN_TEMPLATES.map((template) => {
const plan = { ...template } as Plan;
if (plan.id === "basic") {
plan.price = basicTierPrice ? getFormattedPrice(basicTierPrice) : "$19+";
plan.interval = "month";
} else if (plan.id === "pro") {
plan.price = proTierPrice ? getFormattedPrice(proTierPrice) : "$39+";
plan.interval = "month";
} else {
plan.price = "$0";
plan.interval = "month";
}
// Add event limit feature at the beginning
plan.features = [
plan.id === "free"
? "20,000 events per month"
: `${eventLimit.toLocaleString()} events per month`,
...plan.baseFeatures,
];
return plan;
});
return (
<div className="container mx-auto py-12">
<div className="mb-16 text-center max-w-3xl mx-auto">
<h1 className="text-4xl font-bold tracking-tight mb-4 ">
Choose Your Analytics Plan
</h1>
<p className="text-lg text-neutral-600 dark:text-neutral-400 mb-6">
Find the perfect plan to track your site's performance
</p>
</div>
<div className="mb-12 max-w-3xl mx-auto p-6 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-100 dark:border-gray-800">
<div className="mb-6">
<h2 className="text-xl font-medium mb-4">
How many events do you need?
</h2>
<div className="flex justify-between mb-4">
<span className="text-neutral-600 dark:text-neutral-400">
Events per month
</span>
<span className="font-bold text-lg bg-clip-text text-transparent bg-gradient-to-r from-green-500 to-emerald-400">
{eventLimit.toLocaleString()}
</span>
</div>
</div>
<Slider
defaultValue={[1]} // Default to index 1 (100k)
max={EVENT_TIERS.length - 1}
min={0}
step={1}
onValueChange={handleSliderChange}
className="mb-6"
/>
<div className="flex justify-between text-xs text-neutral-500">
{EVENT_TIERS.map((tier, index) => (
<span
key={index}
className={
eventLimitIndex === index ? "font-bold text-emerald-400" : ""
}
>
{tier === 20_000 ? "Free" : tier.toLocaleString()}
</span>
))}
</div>
</div>
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
{plans.map((plan) => (
<div
key={plan.id}
className="group transition-all duration-300 h-full"
onClick={() => handleTierSelection(plan.id)}
>
<Card
className={`flex flex-col h-full transition-transform duration-300 transform ${
selectedTier === plan.id
? "ring-2 ring-emerald-400 shadow-lg scale-[1.02]"
: "hover:scale-[1.01] hover:shadow-md"
} cursor-pointer overflow-hidden`}
>
<div className={`${plan.color} h-3 w-full`}></div>
<CardHeader className="pb-4">
<div className="flex items-center mb-2">
<div
className={`p-1.5 rounded-full mr-2 ${
plan.id === "free"
? "bg-gray-100 dark:bg-gray-800"
: plan.id === "basic"
? "bg-green-50 dark:bg-green-800"
: "bg-emerald-50 dark:bg-emerald-800"
}`}
>
{plan.icon}
</div>
<CardTitle className="text-xl">{plan.name}</CardTitle>
</div>
<CardDescription className="space-y-3">
<div className="flex items-baseline">
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{plan.price}
</span>
<span className="ml-1 text-neutral-500">
/{plan.interval}
</span>
</div>
<p>{plan.description}</p>
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-grow">
<div className="w-full h-px bg-gray-200 dark:bg-gray-800 mb-4"></div>
<ul className="space-y-3 text-sm">
{plan.features.map((feature, i) => (
<li key={feature} className="flex items-start">
<Check
className={`mr-2 h-4 w-4 ${
i === 0 ? "text-emerald-400" : "text-green-400"
} shrink-0`}
/>
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
{plan.id !== "free" && (
<Button
onClick={(e) => {
e.stopPropagation();
handleSubscribe();
}}
disabled={!selectedPrice}
className={`w-full ${
plan.id === "pro"
? "bg-gradient-to-r from-green-500 to-emerald-400 hover:from-green-600 hover:to-emerald-500"
: ""
}`}
variant={plan.id === "pro" ? "default" : "outline"}
>
Subscribe to {plan.name}
</Button>
)}
{plan.id === "free" && (
<Button
className="w-full border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-300"
variant="outline"
disabled
>
Current Plan
</Button>
)}
</CardFooter>
</Card>
</div>
))}
</div>
<div className="mt-16 text-center text-sm max-w-2xl mx-auto p-6 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-100 dark:border-gray-800">
<div className="flex items-center justify-center mb-4">
<Users className="h-5 w-5 text-emerald-400 mr-2" />
<span className="font-medium">Important Information</span>
</div>
<p className="mb-3 text-neutral-600 dark:text-neutral-400">
All paid plans include a 14-day free trial. No credit card required
until your trial ends.
</p>
<p className="text-neutral-600 dark:text-neutral-400">
Have questions about our plans?{" "}
<a
href="/contact"
className="text-emerald-400 hover:underline font-medium"
>
Contact our sales team
</a>
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-neutral-900 transition-all dark:bg-neutral-50"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View file

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-neutral-200 dark:bg-neutral-800",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-neutral-900/20 dark:bg-neutral-50/20">
<SliderPrimitive.Range className="absolute h-full bg-neutral-900 dark:bg-neutral-50" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-neutral-200 border-neutral-900/50 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-800 dark:border-neutral-50/50 dark:bg-neutral-950 dark:focus-visible:ring-neutral-300" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View file

@ -2,10 +2,13 @@ import {
useQuery,
UseQueryResult,
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { BACKEND_URL } from "../lib/const";
import { FilterParameter, useStore } from "../lib/store";
import { authedFetch, getStartAndEndDate } from "./utils";
import { authClient } from "@/lib/auth";
export type APIResponse<T> = {
data: T;
@ -391,3 +394,55 @@ export const useOrganizationMembers = (organizationId: string) => {
staleTime: Infinity,
});
};
// Subscription types
export type Subscription = {
id: string;
status: "active" | "trialing" | "canceled" | "incomplete" | "past_due";
plan: string;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
trialStart?: Date;
trialEnd?: string | null | Date;
createdAt?: Date;
updatedAt?: Date;
cancelAt?: string | null;
canceledAt?: Date | null;
periodStart?: Date | string;
periodEnd?: Date | string;
cancelAtPeriodEnd: boolean;
referenceId?: string;
limits?: {
events: number;
[key: string]: any;
};
seats?: number;
metadata?: Record<string, any>;
};
export function useSubscription() {
return useQuery({
queryKey: ["subscription"],
queryFn: async () => {
try {
const { data, error } = await authClient.subscription.list();
if (error) {
throw new Error(error.message);
}
// Find the active subscription
const activeSubscription =
data?.find(
(sub) => sub.status === "active" || sub.status === "trialing"
) || null;
// Ensure the returned data has the correct shape for our frontend
return activeSubscription as Subscription | null;
} catch (error) {
console.error("Failed to fetch subscription:", error);
throw error;
}
},
});
}

View file

@ -4,10 +4,18 @@ import {
usernameClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { stripeClient } from "@better-auth/stripe/client";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
plugins: [usernameClient(), adminClient(), organizationClient()],
plugins: [
usernameClient(),
adminClient(),
organizationClient(),
stripeClient({
subscription: true,
}),
],
fetchOptions: {
credentials: "include",
},

94
client/src/lib/stripe.ts Normal file
View file

@ -0,0 +1,94 @@
export const STRIPE_PRICES = [
{
priceId: "price_1R1fIVDFVprnAny2yJtRRPBm",
price: 19,
name: "basic100k",
interval: "month",
limits: {
events: 100_000,
},
},
{
priceId: "price_1R1fKJDFVprnAny2mfiBjkAQ",
price: 29,
name: "basic250k",
interval: "month",
limits: {
events: 250_000,
},
},
{
priceId: "price_1R1fQlDFVprnAny2WwNdiRgT",
price: 49,
name: "basic500k",
interval: "month",
limits: {
events: 500_000,
},
},
{
id: "basic1m",
priceId: "price_1R1fR2DFVprnAny28tPEQAwh",
price: 69,
name: "basic1m",
interval: "month",
limits: {
events: 1_000_000,
},
},
{
priceId: "price_1R1fRMDFVprnAny24AMo0Vuu",
price: 99,
name: "basic2m",
interval: "month",
limits: {
events: 2_000_000,
},
},
{
priceId: "price_1R1fRmDFVprnAny27gL7XFCY",
price: 39,
name: "pro100k",
interval: "month",
limits: {
events: 100_000,
},
},
{
priceId: "price_1R1fSADFVprnAny2d7d4tXTs",
price: 59,
name: "pro250k",
interval: "month",
limits: {
events: 250_000,
},
},
{
priceId: "price_1R1fSkDFVprnAny2MzBvhPKs",
price: 99,
name: "pro500k",
interval: "month",
limits: {
events: 500_000,
},
},
{
priceId: "price_1R1fTMDFVprnAny2IdeB1bLV",
price: 139,
name: "pro1m",
interval: "month",
limits: {
events: 1_000_000,
},
},
{
priceId: "price_1R1fTXDFVprnAny2JBLVtkIU",
price: 199,
name: "pro2m",
interval: "month",
limits: {
events: 2_000_000,
},
},
];

View file

@ -19,7 +19,14 @@ export function middleware(request: NextRequest) {
const siteId = match[1];
// Don't redirect certain built-in routes like 'login', 'signup', etc.
const excludedRoutes = ["login", "signup", "settings", "_next", "api"];
const excludedRoutes = [
"login",
"signup",
"settings",
"subscribe",
"_next",
"api",
];
if (excludedRoutes.includes(siteId)) {
return NextResponse.next();
}

View file

@ -51,6 +51,8 @@ services:
- POSTGRES_PASSWORD=frog
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BASE_URL=${BASE_URL}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
depends_on:
clickhouse:
condition: service_healthy

View file

@ -14,10 +14,6 @@ COPY . .
# Build the application
RUN npm run build
# Generate migrations (but don't run them)
RUN mkdir -p /app/drizzle
RUN npx drizzle-kit generate || echo "Skipping migration generation during build"
# Runtime image
FROM node:20-alpine
@ -29,11 +25,11 @@ RUN apk add --no-cache postgresql-client
# Copy built application and dependencies
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/docker-entrypoint.sh /docker-entrypoint.sh
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/public ./public
COPY --from=builder /app/src ./src
# Make the entrypoint executable
RUN chmod +x /docker-entrypoint.sh

View file

@ -11,6 +11,7 @@
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
"db:migrate": "drizzle-kit migrate --config=drizzle.config.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
"db:pull": "drizzle-kit pull --config=drizzle.config.ts",
"db:drop": "drizzle-kit drop --config=drizzle.config.ts",
"db:check": "drizzle-kit check --config=drizzle.config.ts"
},

View file

@ -6,24 +6,33 @@ import {
serial,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core";
// User table
export const users = pgTable("user", {
id: text("id").primaryKey().notNull(),
name: text("name").notNull(),
username: text("username").unique(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified").notNull(),
image: text("image"),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull(),
role: text("role").notNull().default("user"),
displayUsername: text("displayUsername"),
banned: boolean("banned"),
banReason: text("banReason"),
banExpires: timestamp("banExpires"),
});
export const users = pgTable(
"user",
{
id: text().primaryKey().notNull(),
name: text().notNull(),
username: text(),
email: text().notNull(),
emailVerified: boolean().notNull(),
image: text(),
createdAt: timestamp({ mode: "string" }).notNull(),
updatedAt: timestamp({ mode: "string" }).notNull(),
role: text().default("user").notNull(),
displayUsername: text(),
banned: boolean(),
banReason: text(),
banExpires: timestamp({ mode: "string" }),
stripeCustomerId: text(),
},
(table) => [
unique("user_username_unique").on(table.username),
unique("user_email_unique").on(table.email),
]
);
// Verification table
export const verification = pgTable("verification", {
@ -151,3 +160,21 @@ export const session = pgTable("session", {
impersonatedBy: text("impersonatedBy"),
activeOrganizationId: text("activeOrganizationId"),
});
// Subscription table
export const subscription = pgTable("subscription", {
id: text("id").primaryKey().notNull(),
plan: text("plan").notNull(),
referenceId: text("referenceId").notNull(),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
status: text("status").notNull(),
periodStart: timestamp("periodStart", { mode: "string" }),
periodEnd: timestamp("periodEnd", { mode: "string" }),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
trialStart: timestamp("trialStart", { mode: "string" }),
trialEnd: timestamp("trialEnd", { mode: "string" }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

View file

@ -7,9 +7,13 @@ import { db } from "../db/postgres/postgres.js";
import { IS_CLOUD } from "./const.js";
import * as schema from "../db/postgres/schema.js";
import { eq } from "drizzle-orm";
import { stripe } from "@better-auth/stripe";
import Stripe from "stripe";
dotenv.config();
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!);
type AuthType = ReturnType<typeof betterAuth> | null;
const pluginList = IS_CLOUD
@ -21,6 +25,97 @@ const pluginList = IS_CLOUD
// Set the creator role to owner
creatorRole: "owner",
}),
stripe({
stripeClient,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
priceId: "price_1R1fIVDFVprnAny2yJtRRPBm",
name: "basic100k",
interval: "month",
limits: {
events: 100_000,
},
},
{
priceId: "price_1R1fKJDFVprnAny2mfiBjkAQ",
name: "basic250k",
interval: "month",
limits: {
events: 250_000,
},
},
{
name: "basic500k",
priceId: "price_1R1fQlDFVprnAny2WwNdiRgT",
interval: "month",
limits: {
events: 500_000,
},
},
{
name: "basic1m",
priceId: "price_1R1fR2DFVprnAny28tPEQAwh",
interval: "month",
limits: {
events: 1_000_000,
},
},
{
name: "basic2m",
priceId: "price_1R1fRMDFVprnAny24AMo0Vuu",
interval: "month",
limits: {
events: 2_000_000,
},
},
{
name: "pro100k",
priceId: "price_1R1fRmDFVprnAny27gL7XFCY",
interval: "month",
limits: {
events: 100_000,
},
},
{
name: "pro250k",
priceId: "price_1R1fSADFVprnAny2d7d4tXTs",
interval: "month",
limits: {
events: 250_000,
},
},
{
name: "pro500k",
priceId: "price_1R1fSkDFVprnAny2MzBvhPKs",
interval: "month",
limits: {
events: 500_000,
},
},
{
name: "pro1m",
priceId: "price_1R1fTMDFVprnAny2IdeB1bLV",
interval: "month",
limits: {
events: 1_000_000,
},
},
{
name: "pro2m",
priceId: "price_1R1fTXDFVprnAny2JBLVtkIU",
interval: "month",
limits: {
events: 2_000_000,
},
},
],
},
}),
]
: [
username(),
@ -53,7 +148,7 @@ export let auth: AuthType | null = betterAuth({
enabled: true,
},
},
plugins: pluginList,
plugins: pluginList as any,
trustedOrigins: ["http://localhost:3002"],
advanced: {
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
@ -63,6 +158,7 @@ export let auth: AuthType | null = betterAuth({
},
},
});
export function initAuth(allowedOrigins: string[]) {
auth = betterAuth({
basePath: "/auth",
@ -132,7 +228,7 @@ export function initAuth(allowedOrigins: string[]) {
},
},
},
plugins: pluginList,
plugins: pluginList as any,
trustedOrigins: allowedOrigins,
advanced: {
useSecureCookies: process.env.NODE_ENV === "production",
@ -141,34 +237,5 @@ export function initAuth(allowedOrigins: string[]) {
path: "/",
},
},
// Use database hooks to create an organization after user signup
databaseHooks: {
user: {
create: {
after: async (user) => {
// Create an organization for the new user
console.info(user);
// if (auth) {
// try {
// const orgName = user.name || user.username || "My Organization";
// await auth.api.organization.createOrganization({
// body: {
// name: orgName,
// },
// headers: {
// "x-user-id": user.id,
// },
// });
// } catch (error) {
// console.error(
// "Error creating organization for new user:",
// error
// );
// }
// }
},
},
},
},
});
}