mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-11 12:25:36 +02:00
init subscription (#51)
* init subscription * add subscription plans * fix docker * fix docker * fix docker * wip * wip
This commit is contained in:
parent
2d22dc6fee
commit
bcc1cc5d29
25 changed files with 1747 additions and 62 deletions
2
client/package-lock.json
generated
2
client/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
288
client/src/app/settings/subscription/page.tsx
Normal file
288
client/src/app/settings/subscription/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
client/src/app/settings/subscription/utils/constants.ts
Normal file
7
client/src/app/settings/subscription/utils/constants.ts
Normal 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;
|
105
client/src/app/settings/subscription/utils/planUtils.tsx
Normal file
105
client/src/app/settings/subscription/utils/planUtils.tsx
Normal 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);
|
||||
};
|
365
client/src/app/subscribe/page.tsx
Normal file
365
client/src/app/subscribe/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
client/src/components/ui/progress.tsx
Normal file
28
client/src/components/ui/progress.tsx
Normal 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 };
|
31
client/src/components/ui/separator.tsx
Normal file
31
client/src/components/ui/separator.tsx
Normal 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 };
|
28
client/src/components/ui/slider.tsx
Normal file
28
client/src/components/ui/slider.tsx
Normal 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 }
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
94
client/src/lib/stripe.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue