mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-26 07:52:24 +00:00
1101 lines
49 KiB
TypeScript
1101 lines
49 KiB
TypeScript
"use client";
|
|
|
|
import { Button } from "@app/components/ui/button";
|
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
import { toast } from "@app/hooks/useToast";
|
|
import { useState, useEffect } from "react";
|
|
import { createApiClient } from "@app/lib/api";
|
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
import { formatAxiosError } from "@app/lib/api";
|
|
import { AxiosResponse } from "axios";
|
|
import {
|
|
SettingsContainer,
|
|
SettingsSection,
|
|
SettingsSectionHeader,
|
|
SettingsSectionTitle,
|
|
SettingsSectionDescription,
|
|
SettingsSectionBody,
|
|
SettingsSectionFooter
|
|
} from "@app/components/Settings";
|
|
import {
|
|
InfoSection,
|
|
InfoSectionContent,
|
|
InfoSections,
|
|
InfoSectionTitle
|
|
} from "@app/components/InfoSection";
|
|
import {
|
|
Credenza,
|
|
CredenzaBody,
|
|
CredenzaClose,
|
|
CredenzaContent,
|
|
CredenzaDescription,
|
|
CredenzaFooter,
|
|
CredenzaHeader,
|
|
CredenzaTitle
|
|
} from "@app/components/Credenza";
|
|
import { cn } from "@app/lib/cn";
|
|
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
|
import {
|
|
Alert,
|
|
AlertTitle,
|
|
AlertDescription
|
|
} from "@app/components/ui/alert";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent
|
|
} from "@app/components/ui/tooltip";
|
|
import {
|
|
GetOrgSubscriptionResponse,
|
|
GetOrgUsageResponse
|
|
} from "@server/routers/billing/types";
|
|
import { useTranslations } from "use-intl";
|
|
import Link from "next/link";
|
|
import { Tier } from "@server/types/Tiers";
|
|
import {
|
|
freeLimitSet,
|
|
tier1LimitSet,
|
|
tier2LimitSet,
|
|
tier3LimitSet
|
|
} from "@server/lib/billing/limitSet";
|
|
import { FeatureId } from "@server/lib/billing/features";
|
|
|
|
// Plan tier definitions matching the mockup
|
|
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
|
|
|
|
type PlanOption = {
|
|
id: PlanId;
|
|
name: string;
|
|
price: string;
|
|
priceDetail?: string;
|
|
tierType: Tier | null;
|
|
};
|
|
|
|
const planOptions: PlanOption[] = [
|
|
{
|
|
id: "starter",
|
|
name: "Starter",
|
|
price: "Free",
|
|
tierType: null
|
|
},
|
|
{
|
|
id: "home",
|
|
name: "Home",
|
|
price: "$12.50",
|
|
priceDetail: "/ month",
|
|
tierType: "tier1"
|
|
},
|
|
{
|
|
id: "team",
|
|
name: "Team",
|
|
price: "$4",
|
|
priceDetail: "per user / month",
|
|
tierType: "tier2"
|
|
},
|
|
{
|
|
id: "business",
|
|
name: "Business",
|
|
price: "$9",
|
|
priceDetail: "per user / month",
|
|
tierType: "tier3"
|
|
},
|
|
{
|
|
id: "enterprise",
|
|
name: "Enterprise",
|
|
price: "Custom",
|
|
tierType: null
|
|
}
|
|
];
|
|
|
|
// Tier limits mapping derived from limit sets
|
|
const tierLimits: Record<
|
|
Tier | "starter",
|
|
{ users: number; sites: number; domains: number; remoteNodes: number }
|
|
> = {
|
|
starter: {
|
|
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
|
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
|
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
|
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
|
},
|
|
tier1: {
|
|
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
|
|
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
|
|
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
|
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
|
},
|
|
tier2: {
|
|
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
|
|
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
|
|
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
|
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
|
},
|
|
tier3: {
|
|
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
|
|
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
|
|
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
|
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
|
},
|
|
enterprise: {
|
|
users: 0, // Custom for enterprise
|
|
sites: 0, // Custom for enterprise
|
|
domains: 0, // Custom for enterprise
|
|
remoteNodes: 0 // Custom for enterprise
|
|
}
|
|
};
|
|
|
|
export default function BillingPage() {
|
|
const { org } = useOrgContext();
|
|
const envContext = useEnvContext();
|
|
const api = createApiClient(envContext);
|
|
const t = useTranslations();
|
|
|
|
// Subscription state
|
|
const [allSubscriptions, setAllSubscriptions] = useState<
|
|
GetOrgSubscriptionResponse["subscriptions"]
|
|
>([]);
|
|
const [tierSubscription, setTierSubscription] = useState<
|
|
GetOrgSubscriptionResponse["subscriptions"][0] | null
|
|
>(null);
|
|
const [licenseSubscription, setLicenseSubscription] = useState<
|
|
GetOrgSubscriptionResponse["subscriptions"][0] | null
|
|
>(null);
|
|
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
|
|
|
// Usage and limits data
|
|
const [usageData, setUsageData] = useState<GetOrgUsageResponse["usage"]>(
|
|
[]
|
|
);
|
|
const [limitsData, setLimitsData] = useState<GetOrgUsageResponse["limits"]>(
|
|
[]
|
|
);
|
|
|
|
const [hasSubscription, setHasSubscription] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
|
|
|
|
// Usage IDs
|
|
const USERS = "users";
|
|
const SITES = "sites";
|
|
const DOMAINS = "domains";
|
|
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
|
|
|
// Confirmation dialog state
|
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
const [pendingTier, setPendingTier] = useState<{
|
|
tier: Tier | "starter";
|
|
action: "upgrade" | "downgrade";
|
|
planName: string;
|
|
price: string;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function fetchSubscription() {
|
|
setSubscriptionLoading(true);
|
|
try {
|
|
const res = await api.get<
|
|
AxiosResponse<GetOrgSubscriptionResponse>
|
|
>(`/org/${org.org.orgId}/billing/subscriptions`);
|
|
const { subscriptions } = res.data.data;
|
|
setAllSubscriptions(subscriptions);
|
|
|
|
// Find tier subscription
|
|
const tierSub = subscriptions.find(
|
|
({ subscription }) =>
|
|
subscription?.type === "tier1" ||
|
|
subscription?.type === "tier2" ||
|
|
subscription?.type === "tier3"
|
|
);
|
|
setTierSubscription(tierSub || null);
|
|
|
|
if (tierSub?.subscription) {
|
|
setCurrentTier(tierSub.subscription.type as Tier);
|
|
setHasSubscription(
|
|
tierSub.subscription.status === "active"
|
|
);
|
|
}
|
|
|
|
// Find license subscription
|
|
const licenseSub = subscriptions.find(
|
|
({ subscription }) => subscription?.type === "license"
|
|
);
|
|
setLicenseSubscription(licenseSub || null);
|
|
} catch (error) {
|
|
toast({
|
|
title: t("billingFailedToLoadSubscription"),
|
|
description: formatAxiosError(error),
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setSubscriptionLoading(false);
|
|
}
|
|
}
|
|
fetchSubscription();
|
|
}, [org.org.orgId]);
|
|
|
|
useEffect(() => {
|
|
async function fetchUsage() {
|
|
try {
|
|
const res = await api.get<AxiosResponse<GetOrgUsageResponse>>(
|
|
`/org/${org.org.orgId}/billing/usage`
|
|
);
|
|
const { usage, limits } = res.data.data;
|
|
setUsageData(usage);
|
|
setLimitsData(limits);
|
|
} catch (error) {
|
|
toast({
|
|
title: t("billingFailedToLoadUsage"),
|
|
description: formatAxiosError(error),
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
}
|
|
fetchUsage();
|
|
}, [org.org.orgId]);
|
|
|
|
const handleStartSubscription = async (tier: Tier) => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await api.post<AxiosResponse<string>>(
|
|
`/org/${org.org.orgId}/billing/create-checkout-session`,
|
|
{ tier }
|
|
);
|
|
const checkoutUrl = response.data.data;
|
|
if (checkoutUrl) {
|
|
window.location.href = checkoutUrl;
|
|
} else {
|
|
toast({
|
|
title: t("billingFailedToGetCheckoutUrl"),
|
|
description: t("billingPleaseTryAgainLater"),
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: t("billingCheckoutError"),
|
|
description: formatAxiosError(error),
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleModifySubscription = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await api.post<AxiosResponse<string>>(
|
|
`/org/${org.org.orgId}/billing/create-portal-session`,
|
|
{}
|
|
);
|
|
const portalUrl = response.data.data;
|
|
if (portalUrl) {
|
|
window.location.href = portalUrl;
|
|
} else {
|
|
toast({
|
|
title: t("billingFailedToGetPortalUrl"),
|
|
description: t("billingPleaseTryAgainLater"),
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: t("billingPortalError"),
|
|
description: formatAxiosError(error),
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleChangeTier = async (tier: Tier) => {
|
|
if (!hasSubscription) {
|
|
// If no subscription, start a new one
|
|
handleStartSubscription(tier);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
await api.post(`/org/${org.org.orgId}/billing/change-tier`, {
|
|
tier
|
|
});
|
|
|
|
// Poll the API to check if the tier change has been reflected
|
|
const pollForTierChange = async (targetTier: Tier) => {
|
|
const maxAttempts = 30; // 30 seconds with 1 second interval
|
|
let attempts = 0;
|
|
|
|
const poll = async (): Promise<boolean> => {
|
|
try {
|
|
const res = await api.get<
|
|
AxiosResponse<GetOrgSubscriptionResponse>
|
|
>(`/org/${org.org.orgId}/billing/subscriptions`);
|
|
const { subscriptions } = res.data.data;
|
|
|
|
// Find tier subscription
|
|
const tierSub = subscriptions.find(
|
|
({ subscription }) =>
|
|
subscription?.type === "tier1" ||
|
|
subscription?.type === "tier2" ||
|
|
subscription?.type === "tier3"
|
|
);
|
|
|
|
// Check if the tier has changed to the target tier
|
|
if (tierSub?.subscription?.type === targetTier) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (error) {
|
|
console.error("Error polling subscription:", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
while (attempts < maxAttempts) {
|
|
const success = await poll();
|
|
|
|
if (success) {
|
|
// Tier change reflected, refresh the page
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
attempts++;
|
|
|
|
if (attempts < maxAttempts) {
|
|
// Wait 1 second before next poll
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, 1000)
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we've exhausted all attempts, show an error
|
|
toast({
|
|
title: "Tier change processing",
|
|
description:
|
|
"Your tier change is taking longer than expected. Please refresh the page in a moment to see the changes.",
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
};
|
|
|
|
// Start polling for the tier change
|
|
pollForTierChange(tier);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Failed to change tier",
|
|
description: formatAxiosError(error),
|
|
variant: "destructive"
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const confirmTierChange = () => {
|
|
if (!pendingTier) return;
|
|
|
|
if (
|
|
pendingTier.action === "upgrade" ||
|
|
pendingTier.action === "downgrade"
|
|
) {
|
|
// If downgrading to starter (free tier), go to Stripe portal
|
|
if (pendingTier.tier === "starter") {
|
|
handleModifySubscription();
|
|
} else if (hasSubscription) {
|
|
handleChangeTier(pendingTier.tier);
|
|
} else {
|
|
handleStartSubscription(pendingTier.tier);
|
|
}
|
|
}
|
|
|
|
// setShowConfirmDialog(false);
|
|
// setPendingTier(null);
|
|
};
|
|
|
|
const showTierConfirmation = (
|
|
tier: Tier | "starter",
|
|
action: "upgrade" | "downgrade",
|
|
planName: string,
|
|
price: string
|
|
) => {
|
|
setPendingTier({ tier, action, planName, price });
|
|
setShowConfirmDialog(true);
|
|
};
|
|
|
|
const handleContactUs = () => {
|
|
window.open("https://pangolin.net/talk-to-us", "_blank");
|
|
};
|
|
|
|
// Get current plan ID from tier
|
|
const getCurrentPlanId = (): PlanId => {
|
|
if (!hasSubscription || !currentTier) return "starter";
|
|
const plan = planOptions.find((p) => p.tierType === currentTier);
|
|
return plan?.id || "starter";
|
|
};
|
|
|
|
const currentPlanId = getCurrentPlanId();
|
|
|
|
// Get button label and action for each plan
|
|
const getPlanAction = (plan: PlanOption) => {
|
|
if (plan.id === "enterprise") {
|
|
return {
|
|
label: "Contact Us",
|
|
action: handleContactUs,
|
|
variant: "outline" as const,
|
|
disabled: false
|
|
};
|
|
}
|
|
|
|
if (plan.id === currentPlanId) {
|
|
// If it's the starter plan (starter with no subscription), show as current but disabled
|
|
if (plan.id === "starter" && !hasSubscription) {
|
|
return {
|
|
label: "Current Plan",
|
|
action: () => {},
|
|
variant: "default" as const,
|
|
disabled: true
|
|
};
|
|
}
|
|
return {
|
|
label: "Modify Current Plan",
|
|
action: handleModifySubscription,
|
|
variant: "default" as const,
|
|
disabled: false
|
|
};
|
|
}
|
|
|
|
const currentIndex = planOptions.findIndex(
|
|
(p) => p.id === currentPlanId
|
|
);
|
|
const planIndex = planOptions.findIndex((p) => p.id === plan.id);
|
|
|
|
if (planIndex < currentIndex) {
|
|
return {
|
|
label: "Downgrade",
|
|
action: () => {
|
|
if (plan.tierType) {
|
|
showTierConfirmation(
|
|
plan.tierType,
|
|
"downgrade",
|
|
plan.name,
|
|
plan.price + (" " + plan.priceDetail || "")
|
|
);
|
|
} else if (plan.id === "starter") {
|
|
// Show confirmation for downgrading to starter (free tier)
|
|
showTierConfirmation(
|
|
"starter",
|
|
"downgrade",
|
|
plan.name,
|
|
plan.price
|
|
);
|
|
} else {
|
|
handleModifySubscription();
|
|
}
|
|
},
|
|
variant: "outline" as const,
|
|
disabled: false
|
|
};
|
|
}
|
|
|
|
return {
|
|
label: "Upgrade",
|
|
action: () => {
|
|
if (plan.tierType) {
|
|
showTierConfirmation(
|
|
plan.tierType,
|
|
"upgrade",
|
|
plan.name,
|
|
plan.price + (" " + plan.priceDetail || "")
|
|
);
|
|
} else {
|
|
handleModifySubscription();
|
|
}
|
|
},
|
|
variant: "outline" as const,
|
|
disabled: false
|
|
};
|
|
};
|
|
|
|
// Get usage value by feature ID
|
|
const getUsageValue = (featureId: string): number => {
|
|
const usage = usageData.find((u) => u.featureId === featureId);
|
|
return usage?.instantaneousValue || usage?.latestValue || 0;
|
|
};
|
|
|
|
// Get limit value by feature ID
|
|
const getLimitValue = (featureId: string): number | null => {
|
|
const limit = limitsData.find((l) => l.featureId === featureId);
|
|
return limit?.value ?? null;
|
|
};
|
|
|
|
// Check if usage exceeds limit for a specific feature
|
|
const isOverLimit = (featureId: string): boolean => {
|
|
const usage = getUsageValue(featureId);
|
|
const limit = getLimitValue(featureId);
|
|
return limit !== null && usage > limit;
|
|
};
|
|
|
|
// Calculate current usage cost for display
|
|
const getUserCount = () => getUsageValue(USERS);
|
|
const getPricePerUser = () => {
|
|
if (!tierSubscription?.items) return 0;
|
|
|
|
// Find the subscription item for USERS feature
|
|
const usersItem = tierSubscription.items.find(
|
|
(item) => item.featureId === USERS
|
|
);
|
|
|
|
console.log("Users subscription item:", usersItem);
|
|
|
|
// unitAmount is in cents, convert to dollars
|
|
if (usersItem?.unitAmount) {
|
|
return usersItem.unitAmount / 100;
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
// Get license key count
|
|
const getLicenseKeyCount = (): number => {
|
|
if (!licenseSubscription?.items) return 0;
|
|
return licenseSubscription.items.length;
|
|
};
|
|
|
|
// Check if downgrading to a tier would violate current usage limits
|
|
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
|
|
feature: string;
|
|
currentUsage: number;
|
|
newLimit: number;
|
|
}> => {
|
|
const violations: Array<{
|
|
feature: string;
|
|
currentUsage: number;
|
|
newLimit: number;
|
|
}> = [];
|
|
|
|
const limits = tierLimits[targetTier];
|
|
|
|
// Check users
|
|
const usersUsage = getUsageValue(USERS);
|
|
if (limits.users > 0 && usersUsage > limits.users) {
|
|
violations.push({
|
|
feature: "Users",
|
|
currentUsage: usersUsage,
|
|
newLimit: limits.users
|
|
});
|
|
}
|
|
|
|
// Check sites
|
|
const sitesUsage = getUsageValue(SITES);
|
|
if (limits.sites > 0 && sitesUsage > limits.sites) {
|
|
violations.push({
|
|
feature: "Sites",
|
|
currentUsage: sitesUsage,
|
|
newLimit: limits.sites
|
|
});
|
|
}
|
|
|
|
// Check domains
|
|
const domainsUsage = getUsageValue(DOMAINS);
|
|
if (limits.domains > 0 && domainsUsage > limits.domains) {
|
|
violations.push({
|
|
feature: "Domains",
|
|
currentUsage: domainsUsage,
|
|
newLimit: limits.domains
|
|
});
|
|
}
|
|
|
|
// Check remote nodes
|
|
const remoteNodesUsage = getUsageValue(REMOTE_EXIT_NODES);
|
|
if (limits.remoteNodes > 0 && remoteNodesUsage > limits.remoteNodes) {
|
|
violations.push({
|
|
feature: "Remote Exit Nodes",
|
|
currentUsage: remoteNodesUsage,
|
|
newLimit: limits.remoteNodes
|
|
});
|
|
}
|
|
|
|
return violations;
|
|
};
|
|
|
|
if (subscriptionLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center h-64">
|
|
<span>{t("billingLoadingSubscription")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SettingsContainer>
|
|
{/* Your Plan Section */}
|
|
<SettingsSection>
|
|
<SettingsSectionHeader>
|
|
<SettingsSectionTitle>
|
|
{t("billingYourPlan") || "Your Plan"}
|
|
</SettingsSectionTitle>
|
|
<SettingsSectionDescription>
|
|
{t("billingViewOrModifyPlan") ||
|
|
"View or modify your current plan"}
|
|
</SettingsSectionDescription>
|
|
</SettingsSectionHeader>
|
|
<SettingsSectionBody>
|
|
{/* Plan Cards Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
|
{planOptions.map((plan) => {
|
|
const isCurrentPlan = plan.id === currentPlanId;
|
|
const planAction = getPlanAction(plan);
|
|
|
|
return (
|
|
<div
|
|
key={plan.id}
|
|
className={cn(
|
|
"relative flex flex-col rounded-lg border p-4 transition-colors",
|
|
isCurrentPlan
|
|
? "border-primary bg-primary/10"
|
|
: "border-input hover:bg-accent/50"
|
|
)}
|
|
>
|
|
<div className="flex-1">
|
|
<div className="text-2xl">
|
|
{plan.name}
|
|
</div>
|
|
<div className="mt-1">
|
|
<span className="text-xl">
|
|
{plan.price}
|
|
</span>
|
|
{plan.priceDetail && (
|
|
<span className="text-sm text-muted-foreground ml-1">
|
|
{plan.priceDetail}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="mt-4">
|
|
<Button
|
|
variant={
|
|
isCurrentPlan
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={planAction.action}
|
|
disabled={
|
|
isLoading || planAction.disabled
|
|
}
|
|
loading={isLoading && isCurrentPlan}
|
|
>
|
|
{planAction.label}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SettingsSectionBody>
|
|
<SettingsSectionFooter>
|
|
<Link
|
|
href="https://pangolin.net/pricing"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Button variant="outline">
|
|
{t("billingViewPlanDetails") || "View Plan Details"}
|
|
<ExternalLink className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
</SettingsSectionFooter>
|
|
</SettingsSection>
|
|
|
|
{/* Usage and Limits Section */}
|
|
<SettingsSection>
|
|
<SettingsSectionHeader>
|
|
<SettingsSectionTitle>
|
|
{t("billingUsageAndLimits") || "Usage and Limits"}
|
|
</SettingsSectionTitle>
|
|
<SettingsSectionDescription>
|
|
{t("billingViewUsageAndLimits") ||
|
|
"View your plan's limits and current usage"}
|
|
</SettingsSectionDescription>
|
|
</SettingsSectionHeader>
|
|
<SettingsSectionBody>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Current Usage */}
|
|
<div className="border rounded-lg p-4">
|
|
<div className="text-sm text-muted-foreground mb-2">
|
|
{t("billingCurrentUsage") || "Current Usage"}
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold">
|
|
{getUserCount()}
|
|
</span>
|
|
<span className="text-lg">
|
|
{t("billingUsers") || "Users"}
|
|
</span>
|
|
{hasSubscription && getPricePerUser() > 0 && (
|
|
<div className="text-sm text-muted-foreground mt-1">
|
|
x ${getPricePerUser()} / month = $
|
|
{getUserCount() * getPricePerUser()} /
|
|
month
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Maximum Limits */}
|
|
<div className="border rounded-lg p-4">
|
|
<div className="text-sm text-muted-foreground mb-3">
|
|
{t("billingMaximumLimits") || "Maximum Limits"}
|
|
</div>
|
|
<InfoSections cols={4}>
|
|
<InfoSection>
|
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
|
{t("billingUsers") || "Users"}
|
|
</InfoSectionTitle>
|
|
<InfoSectionContent className="text-sm">
|
|
{isOverLimit(USERS) ? (
|
|
<Tooltip>
|
|
<TooltipTrigger className="flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
|
<span className={cn(
|
|
"text-orange-600 dark:text-orange-400 font-medium"
|
|
)}>
|
|
{getLimitValue(USERS) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(USERS) !== null &&
|
|
"users"}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<>
|
|
{getLimitValue(USERS) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(USERS) !== null &&
|
|
"users"}
|
|
</>
|
|
)}
|
|
</InfoSectionContent>
|
|
</InfoSection>
|
|
<InfoSection>
|
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
|
{t("billingSites") || "Sites"}
|
|
</InfoSectionTitle>
|
|
<InfoSectionContent className="text-sm">
|
|
{isOverLimit(SITES) ? (
|
|
<Tooltip>
|
|
<TooltipTrigger className="flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
|
<span className={cn(
|
|
"text-orange-600 dark:text-orange-400 font-medium"
|
|
)}>
|
|
{getLimitValue(SITES) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(SITES) !== null &&
|
|
"sites"}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<>
|
|
{getLimitValue(SITES) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(SITES) !== null &&
|
|
"sites"}
|
|
</>
|
|
)}
|
|
</InfoSectionContent>
|
|
</InfoSection>
|
|
<InfoSection>
|
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
|
{t("billingDomains") || "Domains"}
|
|
</InfoSectionTitle>
|
|
<InfoSectionContent className="text-sm">
|
|
{isOverLimit(DOMAINS) ? (
|
|
<Tooltip>
|
|
<TooltipTrigger className="flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
|
<span className={cn(
|
|
"text-orange-600 dark:text-orange-400 font-medium"
|
|
)}>
|
|
{getLimitValue(DOMAINS) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(DOMAINS) !== null &&
|
|
"domains"}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<>
|
|
{getLimitValue(DOMAINS) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(DOMAINS) !== null &&
|
|
"domains"}
|
|
</>
|
|
)}
|
|
</InfoSectionContent>
|
|
</InfoSection>
|
|
<InfoSection>
|
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
|
{t("billingRemoteNodes") ||
|
|
"Remote Nodes"}
|
|
</InfoSectionTitle>
|
|
<InfoSectionContent className="text-sm">
|
|
{isOverLimit(REMOTE_EXIT_NODES) ? (
|
|
<Tooltip>
|
|
<TooltipTrigger className="flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
|
<span className={cn(
|
|
"text-orange-600 dark:text-orange-400 font-medium"
|
|
)}>
|
|
{getLimitValue(REMOTE_EXIT_NODES) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
|
null && "remote nodes"}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<>
|
|
{getLimitValue(REMOTE_EXIT_NODES) ??
|
|
t("billingUnlimited") ??
|
|
"∞"}{" "}
|
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
|
null && "remote nodes"}
|
|
</>
|
|
)}
|
|
</InfoSectionContent>
|
|
</InfoSection>
|
|
</InfoSections>
|
|
</div>
|
|
</div>
|
|
</SettingsSectionBody>
|
|
</SettingsSection>
|
|
|
|
{/* Paid License Keys Section */}
|
|
{(licenseSubscription || getLicenseKeyCount() > 0) && (
|
|
<SettingsSection>
|
|
<SettingsSectionHeader>
|
|
<SettingsSectionTitle>
|
|
{t("billingPaidLicenseKeys") || "Paid License Keys"}
|
|
</SettingsSectionTitle>
|
|
<SettingsSectionDescription>
|
|
{t("billingManageLicenseSubscription") ||
|
|
"Manage your subscription for paid self-hosted license keys"}
|
|
</SettingsSectionDescription>
|
|
</SettingsSectionHeader>
|
|
<SettingsSectionBody>
|
|
<div className="w-full md:w-1/2">
|
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-1">
|
|
{t("billingCurrentKeys") || "Current Keys"}
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold">
|
|
{getLicenseKeyCount()}
|
|
</span>
|
|
<span className="text-lg">
|
|
{getLicenseKeyCount() === 1
|
|
? "key"
|
|
: "keys"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleModifySubscription}
|
|
disabled={isLoading}
|
|
loading={isLoading}
|
|
>
|
|
<CreditCard className="mr-2 h-4 w-4" />
|
|
{t("billingModifyCurrentPlan") ||
|
|
"Modify Current Plan"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</SettingsSectionBody>
|
|
</SettingsSection>
|
|
)}
|
|
|
|
{/* Tier Change Confirmation Dialog */}
|
|
<Credenza
|
|
open={showConfirmDialog}
|
|
onOpenChange={setShowConfirmDialog}
|
|
>
|
|
<CredenzaContent>
|
|
<CredenzaHeader>
|
|
<CredenzaTitle>
|
|
{pendingTier?.action === "upgrade"
|
|
? t("billingConfirmUpgrade") ||
|
|
"Confirm Upgrade"
|
|
: t("billingConfirmDowngrade") ||
|
|
"Confirm Downgrade"}
|
|
</CredenzaTitle>
|
|
<CredenzaDescription>
|
|
{pendingTier?.action === "upgrade"
|
|
? t("billingConfirmUpgradeDescription") ||
|
|
`You are about to upgrade to the ${pendingTier?.planName} plan.`
|
|
: t("billingConfirmDowngradeDescription") ||
|
|
`You are about to downgrade to the ${pendingTier?.planName} plan.`}
|
|
</CredenzaDescription>
|
|
</CredenzaHeader>
|
|
<CredenzaBody>
|
|
{pendingTier && pendingTier.tier && (
|
|
<div className="space-y-4">
|
|
<div className="border rounded-lg p-4">
|
|
<div className="text-2xl">
|
|
{pendingTier.planName}
|
|
</div>
|
|
<div className="mt-1">
|
|
<span className="text-xl">
|
|
{pendingTier.price}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{tierLimits[pendingTier.tier] && (
|
|
<div>
|
|
<h4 className="font-semibold mb-3">
|
|
{t("billingPlanIncludes") ||
|
|
"Plan Includes:"}
|
|
</h4>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
<span>
|
|
{
|
|
tierLimits[pendingTier.tier]
|
|
.users
|
|
}{" "}
|
|
{t("billingUsers") || "Users"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
<span>
|
|
{
|
|
tierLimits[pendingTier.tier]
|
|
.sites
|
|
}{" "}
|
|
{t("billingSites") || "Sites"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
<span>
|
|
{
|
|
tierLimits[pendingTier.tier]
|
|
.domains
|
|
}{" "}
|
|
{t("billingDomains") ||
|
|
"Domains"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
<span>
|
|
{
|
|
tierLimits[pendingTier.tier]
|
|
.remoteNodes
|
|
}{" "}
|
|
{t("billingRemoteNodes") ||
|
|
"Remote Nodes"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Warning for limit violations when downgrading */}
|
|
{pendingTier.action === "downgrade" && (() => {
|
|
const violations = checkLimitViolations(pendingTier.tier);
|
|
if (violations.length > 0) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>
|
|
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<p className="mb-3">
|
|
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
|
</p>
|
|
<ul className="space-y-2">
|
|
{violations.map((violation, index) => (
|
|
<li key={index} className="flex items-center gap-2">
|
|
<span className="font-medium">{violation.feature}:</span>
|
|
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
{/* Warning for feature loss when downgrading */}
|
|
{pendingTier.action === "downgrade" && (
|
|
<Alert variant="warning">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>
|
|
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CredenzaBody>
|
|
<CredenzaFooter>
|
|
<CredenzaClose asChild>
|
|
<Button variant="outline" disabled={isLoading}>
|
|
{t("cancel") || "Cancel"}
|
|
</Button>
|
|
</CredenzaClose>
|
|
<Button
|
|
onClick={confirmTierChange}
|
|
disabled={isLoading}
|
|
loading={isLoading}
|
|
>
|
|
{pendingTier?.action === "upgrade"
|
|
? t("billingConfirmUpgradeButton") ||
|
|
"Confirm Upgrade"
|
|
: t("billingConfirmDowngradeButton") ||
|
|
"Confirm Downgrade"}
|
|
</Button>
|
|
</CredenzaFooter>
|
|
</CredenzaContent>
|
|
</Credenza>
|
|
</SettingsContainer>
|
|
);
|
|
}
|