This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View file

@ -0,0 +1,110 @@
"use client";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { usePurchases } from "@saas/payments/hooks/purchases";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import {} from "@ui/components/table";
import { BadgeCheckIcon, CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { CustomerPortalButton } from "../../settings/components/CustomerPortalButton";
import { SubscriptionStatusBadge } from "../../settings/components/SubscriptionStatusBadge";
export function ActivePlan({
organizationId,
}: {
organizationId?: string;
seats?: number;
}) {
const t = useTranslations();
const { planData } = usePlanData();
const { activePlan } = usePurchases(organizationId);
if (!activePlan) {
return null;
}
const activePlanData = planData[activePlan.id as keyof typeof planData];
if (!activePlanData) {
return null;
}
const price = "price" in activePlan ? activePlan.price : null;
return (
<SettingsItem title={t("settings.billing.activePlan.title")}>
<div className="rounded-lg border p-4">
<div className="">
<div className="flex items-center gap-2">
<BadgeCheckIcon className="size-6 text-primary" />
<h4 className="font-bold text-lg text-primary">
<span>{activePlanData.title}</span>
</h4>
{activePlan.status && (
<SubscriptionStatusBadge
status={activePlan.status}
/>
)}
</div>
{!!activePlanData.features?.length && (
<ul className="mt-2 grid list-none gap-2 text-sm">
{activePlanData.features.map((feature, key) => (
<li
key={key}
className="flex items-center justify-start"
>
<CheckIcon className="mr-2 size-4 text-primary" />
<span>{feature}</span>
</li>
))}
</ul>
)}
{price && (
<strong
className="mt-2 block font-medium text-2xl lg:text-3xl"
data-test="price-table-plan-price"
>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
}).format(price.amount)}
{"interval" in price && (
<span className="font-normal text-xs opacity-60">
{" / "}
{price.interval === "month"
? t("pricing.month", {
count: price.intervalCount ?? 1,
})
: t("pricing.year", {
count: price.intervalCount ?? 1,
})}
</span>
)}
{organizationId &&
"seatBased" in price &&
price.seatBased && (
<span className="font-normal text-xs opacity-60">
{" / "}
{t("pricing.perSeat")}
</span>
)}
</strong>
)}
</div>
{"purchaseId" in activePlan && activePlan.purchaseId && (
<div className="mt-4 flex justify-end">
<div className="flex w-full flex-col flex-wrap gap-2 md:flex-row">
<CustomerPortalButton
purchaseId={activePlan.purchaseId}
/>
</div>
</div>
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { usePurchases } from "@saas/payments/hooks/purchases";
import { Badge } from "@ui/components/badge";
export function ActivePlanBadge({
organizationId,
}: {
organizationId?: string;
}) {
const { planData } = usePlanData();
const { activePlan } = usePurchases(organizationId);
if (!activePlan) {
return null;
}
const activePlanData = planData[activePlan.id as keyof typeof planData];
if (!activePlanData) {
return null;
}
return (
<Badge
status="info"
className="flex items-center gap-1 px-1.5 text-xs normal-case"
>
{activePlanData.title}
</Badge>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { PricingTable } from "@saas/payments/components/PricingTable";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useTranslations } from "next-intl";
export function ChangePlan({
organizationId,
userId,
activePlanId,
}: {
organizationId?: string;
userId?: string;
activePlanId?: string;
}) {
const t = useTranslations();
return (
<SettingsItem
title={t("settings.billing.changePlan.title")}
description={t("settings.billing.changePlan.description")}
>
<PricingTable
organizationId={organizationId}
userId={userId}
activePlanId={activePlanId}
/>
</SettingsItem>
);
}

View file

@ -0,0 +1,300 @@
"use client";
import { LocaleLink } from "@i18n/routing";
import { type Config, config } from "@repo/config";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { useCreateCheckoutLinkMutation } from "@saas/payments/lib/api";
import type { PlanId } from "@saas/payments/types";
import { useLocaleCurrency } from "@shared/hooks/locale-currency";
import { useRouter } from "@shared/hooks/router";
import { Button } from "@ui/components/button";
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
import { cn } from "@ui/lib";
import {
ArrowRightIcon,
BadgePercentIcon,
CheckIcon,
PhoneIcon,
StarIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
const plans = config.payments.plans as Config["payments"]["plans"];
export function PricingTable({
className,
userId,
organizationId,
activePlanId,
}: {
className?: string;
userId?: string;
organizationId?: string;
activePlanId?: string;
}) {
const t = useTranslations();
const router = useRouter();
const localeCurrency = useLocaleCurrency();
const [loading, setLoading] = useState<PlanId | false>(false);
const [interval, setInterval] = useState<"month" | "year">("month");
const { planData } = usePlanData();
const createCheckoutLinkMutation = useCreateCheckoutLinkMutation();
const onSelectPlan = async (planId: PlanId, productId?: string) => {
if (!(userId || organizationId)) {
router.push("/auth/signup");
return;
}
const plan = plans[planId];
const price = plan.prices?.find(
(price) => price.productId === productId,
);
if (!price) {
return;
}
setLoading(planId);
try {
const { checkoutLink } =
await createCheckoutLinkMutation.mutateAsync({
type:
price.type === "one-time" ? "one-time" : "subscription",
productId: price.productId,
organizationId,
redirectUrl: window.location.href,
});
window.location.href = checkoutLink;
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const filteredPlans = Object.entries(plans).filter(
([planId]) =>
planId !== activePlanId && (!activePlanId || planId !== "free"),
);
const hasSubscriptions = filteredPlans.some(([_, plan]) =>
plan.prices?.some((price) => price.type === "recurring"),
);
return (
<div className={cn("@container", className)}>
{hasSubscriptions && (
<div className="mb-6 flex @xl:justify-center">
<Tabs
value={interval}
onValueChange={(value) =>
setInterval(value as typeof interval)
}
data-test="price-table-interval-tabs"
>
<TabsList className="border-foreground/10">
<TabsTrigger value="month">
{t("pricing.monthly")}
</TabsTrigger>
<TabsTrigger value="year">
{t("pricing.yearly")}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
<div
className={cn("grid grid-cols-1 gap-4", {
"@xl:grid-cols-2": filteredPlans.length >= 2,
"@3xl:grid-cols-3": filteredPlans.length >= 3,
"@4xl:grid-cols-4": filteredPlans.length >= 4,
})}
>
{filteredPlans
.filter(([planId]) => planId !== activePlanId)
.map(([planId, plan]) => {
const { isFree, isEnterprise, prices, recommended } =
plan;
const { title, description, features } =
planData[planId as keyof typeof planData];
let price = prices?.find(
(price) =>
!price.hidden &&
(price.type === "one-time" ||
price.interval === interval) &&
price.currency === localeCurrency,
);
if (isFree) {
price = {
amount: 0,
currency: localeCurrency,
interval,
productId: "",
type: "recurring",
};
}
if (!(price || isEnterprise)) {
return null;
}
return (
<div
key={planId}
className={cn("rounded-3xl border p-6", {
"border-2 border-primary": recommended,
})}
data-test="price-table-plan"
>
<div className="flex h-full flex-col justify-between gap-4">
<div>
{recommended && (
<div className="-mt-9 flex justify-center">
<div className="mb-2 flex h-6 w-auto items-center gap-1.5 rounded-full bg-primary px-2 py-1 font-semibold text-primary-foreground text-xs">
<StarIcon className="size-3" />
{t("pricing.recommended")}
</div>
</div>
)}
<h3
className={cn(
"my-0 font-semibold text-2xl",
{
"font-bold text-primary":
recommended,
},
)}
>
{title}
</h3>
{description && (
<div className="prose mt-2 text-foreground/60 text-sm">
{description}
</div>
)}
{!!features?.length && (
<ul className="mt-4 grid list-none gap-2 text-sm">
{features.map(
(feature, key) => (
<li
key={key}
className="flex items-center justify-start"
>
<CheckIcon className="mr-2 size-4 text-primary" />
<span>
{feature}
</span>
</li>
),
)}
</ul>
)}
{price &&
"trialPeriodDays" in price &&
price.trialPeriodDays && (
<div className="mt-4 flex items-center justify-start font-medium text-primary text-sm opacity-80">
<BadgePercentIcon className="mr-2 size-4" />
{t("pricing.trialPeriod", {
days: price.trialPeriodDays,
})}
</div>
)}
</div>
<div>
{price && (
<strong
className="block font-medium text-2xl lg:text-3xl"
data-test="price-table-plan-price"
>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
}).format(price.amount)}
{"interval" in price && (
<span className="font-normal text-xs opacity-60">
{" / "}
{interval === "month"
? t(
"pricing.month",
{
count:
price.intervalCount ??
1,
},
)
: t(
"pricing.year",
{
count:
price.intervalCount ??
1,
},
)}
</span>
)}
{organizationId &&
"seatBased" in price &&
price.seatBased && (
<span className="font-normal text-xs opacity-60">
{" / "}
{t(
"pricing.perSeat",
)}
</span>
)}
</strong>
)}
{isEnterprise ? (
<Button
className="mt-4 w-full"
variant="light"
asChild
>
<LocaleLink href="/contact">
<PhoneIcon className="mr-2 size-4" />
{t("pricing.contactSales")}
</LocaleLink>
</Button>
) : (
<Button
className="mt-4 w-full"
variant={
recommended
? "primary"
: "secondary"
}
onClick={() =>
onSelectPlan(
planId as PlanId,
price?.productId,
)
}
loading={loading === planId}
>
{userId || organizationId
? t("pricing.choosePlan")
: t("pricing.getStarted")}
<ArrowRightIcon className="ml-2 size-4" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import type { config } from "@repo/config";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
type ProductReferenceId = keyof (typeof config)["payments"]["plans"];
export function usePlanData() {
const t = useTranslations();
const planData: Record<
ProductReferenceId,
{
title: string;
description: ReactNode;
features: ReactNode[];
}
> = {
free: {
title: t("pricing.products.free.title"),
description: t("pricing.products.free.description"),
features: [
t("pricing.products.free.features.anotherFeature"),
t("pricing.products.free.features.limitedSupport"),
],
},
pro: {
title: t("pricing.products.pro.title"),
description: t("pricing.products.pro.description"),
features: [
t("pricing.products.pro.features.anotherFeature"),
t("pricing.products.pro.features.fullSupport"),
],
},
enterprise: {
title: t("pricing.products.enterprise.title"),
description: t("pricing.products.enterprise.description"),
features: [
t("pricing.products.enterprise.features.unlimitedProjects"),
t("pricing.products.enterprise.features.enterpriseSupport"),
],
},
lifetime: {
title: t("pricing.products.lifetime.title"),
description: t("pricing.products.lifetime.description"),
features: [
t("pricing.products.lifetime.features.noRecurringCosts"),
t("pricing.products.lifetime.features.extendSupport"),
],
},
};
return { planData };
}

View file

@ -0,0 +1,20 @@
import { type Config, config } from "@repo/config";
import { createPurchasesHelper } from "@repo/payments/lib/helper";
import { usePurchasesQuery } from "@saas/payments/lib/api";
const plans = config.payments.plans as Config["payments"]["plans"];
export const usePurchases = (organizationId?: string) => {
const { data: purchases } = usePurchasesQuery(organizationId);
const { activePlan, hasSubscription, hasPurchase } = createPurchasesHelper(
purchases ?? [],
);
return { purchases, activePlan, hasSubscription, hasPurchase };
};
export const useUserPurchases = () => usePurchases();
export const useOrganizationPurchases = (organizationId: string) =>
usePurchases(organizationId);

View file

@ -0,0 +1,80 @@
import { apiClient } from "@shared/lib/api-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { InferRequestType } from "hono";
export const createCustomerPortalLinkMutationKey = [
"create-customer-portal-link",
] as const;
export const useCreateCustomerPortalLinkMutation = () => {
return useMutation({
mutationKey: createCustomerPortalLinkMutationKey,
mutationFn: async (
query: InferRequestType<
(typeof apiClient.payments)["create-customer-portal-link"]["$post"]
>["query"],
) => {
const response = await apiClient.payments[
"create-customer-portal-link"
].$post({
query,
});
if (!response.ok) {
throw new Error("Failed to create customer portal link");
}
return response.json();
},
});
};
export const createCheckoutLinkMutationKey = ["create-checkout-link"] as const;
export const useCreateCheckoutLinkMutation = () => {
return useMutation({
mutationKey: createCheckoutLinkMutationKey,
mutationFn: async (
query: InferRequestType<
(typeof apiClient.payments)["create-checkout-link"]["$post"]
>["query"],
) => {
const response = await apiClient.payments[
"create-checkout-link"
].$post({
query,
});
if (!response.ok) {
throw new Error("Failed to create checkout link");
}
return response.json();
},
});
};
export const purchasesQueryKey = (organizationId?: string) =>
organizationId
? ["organization", "purchases", organizationId]
: (["user", "purchases"] as const);
export const usePurchasesQuery = (
organizationId?: string,
options?: { enabled?: boolean },
) => {
return useQuery({
queryKey: purchasesQueryKey(organizationId),
queryFn: async () => {
const response = await apiClient.payments.purchases.$get({
query: {
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch purchases");
}
return response.json();
},
...options,
});
};

View file

@ -0,0 +1,16 @@
import type { PlanId } from "@saas/payments/types";
import React from "react";
export const PurchasesContext = React.createContext<
| {
activeSubscription: {
id: string;
purchaseId?: string;
} | null;
loaded: boolean;
hasSubscription: (planIds?: PlanId[] | PlanId) => boolean;
refetchPurchases: () => Promise<void>;
hasPurchase: (planId: PlanId) => boolean;
}
| undefined
>(undefined);

View file

@ -0,0 +1,17 @@
import { getServerApiClient } from "@shared/lib/server";
import { cache } from "react";
export const getPurchases = cache(async (organizationId?: string) => {
const apiClient = await getServerApiClient();
const response = await apiClient.payments.purchases.$get({
query: {
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch purchases");
}
return response.json();
});

View file

@ -0,0 +1,3 @@
import type { config } from "@repo/config";
export type PlanId = keyof typeof config.payments.plans;