feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
apps/web/src/modules/billing/hooks/use-customer.ts
Normal file
5
apps/web/src/modules/billing/hooks/use-customer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
|
||||
export const useCustomer = () => useQuery(billing.queries.customer.get);
|
||||
38
apps/web/src/modules/billing/lib/api.ts
Normal file
38
apps/web/src/modules/billing/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
import type { InferRequestType } from "hono/client";
|
||||
|
||||
const KEY = "billing";
|
||||
|
||||
const queries = {
|
||||
customer: {
|
||||
get: {
|
||||
queryKey: [KEY, "customer"],
|
||||
queryFn: () => handle(api.billing.customer.$get)(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
portal: {
|
||||
get: {
|
||||
mutationKey: [KEY, "portal"],
|
||||
mutationFn: (data: InferRequestType<typeof api.billing.portal.$get>) =>
|
||||
handle(api.billing.portal.$get)(data),
|
||||
},
|
||||
},
|
||||
checkout: {
|
||||
create: {
|
||||
mutationKey: [KEY, "checkout"],
|
||||
mutationFn: (data: InferRequestType<typeof api.billing.checkout.$post>) =>
|
||||
handle(api.billing.checkout.$post)(data),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const billing = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
142
apps/web/src/modules/billing/pricing/constants/features.tsx
Normal file
142
apps/web/src/modules/billing/pricing/constants/features.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { PricingPlanType, FEATURES } from "@turbostarter/billing";
|
||||
|
||||
interface PlanFeature {
|
||||
readonly id: string;
|
||||
readonly available: boolean;
|
||||
readonly title: string;
|
||||
readonly addon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PLAN_FEATURES: Record<PricingPlanType, PlanFeature[]> = {
|
||||
[PricingPlanType.FREE]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].SYNC,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.sync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].BASIC_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.basicSupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].LIMITED_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.limitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].EMAIL_NOTIFICATIONS,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.emailNotifications",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].BASIC_REPORTS,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.basicReports",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_SYNC,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.advancedSync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].PRIORITY_SUPPORT,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.prioritySupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].MORE_STORAGE,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.moreStorage",
|
||||
},
|
||||
],
|
||||
[PricingPlanType.PREMIUM]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_SYNC,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.advancedSync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].PRIORITY_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.prioritySupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].MORE_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.moreStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].TEAM_COLLABORATION,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.teamCollaboration",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].SMS_NOTIFICATIONS,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.smsNotifications",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_REPORTS,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.advancedReports",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].UNLIMITED_STORAGE,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.unlimitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].CUSTOM_BRANDING,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.customBranding",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].DEDICATED_SUPPORT,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.dedicatedSupport",
|
||||
},
|
||||
],
|
||||
[PricingPlanType.ENTERPRISE]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].UNLIMITED_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.unlimitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].CUSTOM_BRANDING,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.customBranding",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].DEDICATED_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.dedicatedSupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].API_ACCESS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.apiAccess",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].USER_ROLES,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.userRoles",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].AUDIT_LOGS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.auditLogs",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].SINGLE_SIGN_ON,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.singleSignOn",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].ADVANCED_ANALYTICS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.advancedAnalytics",
|
||||
},
|
||||
],
|
||||
};
|
||||
66
apps/web/src/modules/billing/pricing/layout/discount.tsx
Normal file
66
apps/web/src/modules/billing/pricing/layout/discount.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import {
|
||||
calculatePriceDiscount,
|
||||
formatPrice,
|
||||
BillingDiscountType,
|
||||
} from "@turbostarter/billing";
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type {
|
||||
Discount as DiscountType,
|
||||
PricingPlanPrice,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface DiscountProps {
|
||||
readonly currency: string;
|
||||
readonly priceWithDiscount?: PricingPlanPrice & {
|
||||
discount: DiscountType | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const Discount = memo<DiscountProps>(
|
||||
({ priceWithDiscount, currency }) => {
|
||||
if (!priceWithDiscount?.discount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const discount = calculatePriceDiscount(
|
||||
priceWithDiscount,
|
||||
priceWithDiscount.discount,
|
||||
);
|
||||
|
||||
if (!discount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="sm mt-2 text-center md:text-lg">
|
||||
<Icons.Gift className="text-primary mr-1.5 mb-1.5 inline-block h-5 w-5" />
|
||||
<span className="text-primary">
|
||||
<Trans
|
||||
i18nKey="billing:discount.specialOffer"
|
||||
values={{
|
||||
discount:
|
||||
discount.type === BillingDiscountType.PERCENT
|
||||
? discount.percentage + "%"
|
||||
: formatPrice({
|
||||
amount:
|
||||
discount.original.amount - discount.discounted.amount,
|
||||
currency,
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
bold: <span className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Discount.displayName = "Discount";
|
||||
88
apps/web/src/modules/billing/pricing/layout/header.tsx
Normal file
88
apps/web/src/modules/billing/pricing/layout/header.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { BillingModel } from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@turbostarter/ui-web/tabs";
|
||||
|
||||
import {
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
import { Discount } from "./discount";
|
||||
|
||||
import type {
|
||||
Discount as DiscountType,
|
||||
PricingPlanPrice,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PricingHeaderProps {
|
||||
readonly currency: string;
|
||||
readonly model: BillingModel;
|
||||
readonly intervals: RecurringInterval[];
|
||||
readonly activeInterval: RecurringInterval;
|
||||
readonly onIntervalChange: (billing: RecurringInterval) => void;
|
||||
readonly priceWithDiscount?: PricingPlanPrice & {
|
||||
discount: DiscountType | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const PricingHeader = memo<PricingHeaderProps>(
|
||||
({
|
||||
model,
|
||||
activeInterval,
|
||||
intervals,
|
||||
onIntervalChange,
|
||||
priceWithDiscount,
|
||||
currency,
|
||||
}) => {
|
||||
const { t } = useTranslation("billing");
|
||||
|
||||
return (
|
||||
<SectionHeader>
|
||||
<SectionBadge>{t("pricing.label")}</SectionBadge>
|
||||
<SectionTitle>{t("pricing.title")}</SectionTitle>
|
||||
<SectionDescription className="text-muted-foreground max-w-2xl text-center">
|
||||
{t("pricing.description")}
|
||||
</SectionDescription>
|
||||
|
||||
<Discount
|
||||
{...(priceWithDiscount && {
|
||||
priceWithDiscount,
|
||||
})}
|
||||
currency={currency}
|
||||
/>
|
||||
|
||||
{model === BillingModel.RECURRING && intervals.length > 0 && (
|
||||
<Tabs
|
||||
className="mt-2 lg:mt-4"
|
||||
value={activeInterval}
|
||||
onValueChange={(value) =>
|
||||
onIntervalChange(value as RecurringInterval)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
{intervals.map((interval) => (
|
||||
<TabsTrigger
|
||||
key={interval}
|
||||
value={interval}
|
||||
className="capitalize"
|
||||
aria-controls={undefined}
|
||||
>
|
||||
{t(`interval.${interval}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</SectionHeader>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingHeader.displayName = "PricingHeader";
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
BillingModel,
|
||||
PricingPlanType,
|
||||
calculatePriceDiscount,
|
||||
calculateRecurringDiscount,
|
||||
getPlanPrice,
|
||||
getHighestDiscountForPrice,
|
||||
} from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
import { PLAN_FEATURES } from "~/modules/billing/pricing/constants/features";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
export const usePlan = (
|
||||
plan: PricingPlan,
|
||||
options: {
|
||||
model: BillingModel;
|
||||
interval: RecurringInterval;
|
||||
discounts: Discount[];
|
||||
currency?: string;
|
||||
},
|
||||
) => {
|
||||
const { t } = useTranslation("billing");
|
||||
const router = useRouter();
|
||||
const checkout = useMutation({
|
||||
...billing.mutations.checkout.create,
|
||||
onSuccess: (data) => {
|
||||
if (!data.url) {
|
||||
return toast.error(t("error.checkout"));
|
||||
}
|
||||
return router.push(data.url);
|
||||
},
|
||||
});
|
||||
|
||||
const getPortal = useMutation({
|
||||
...billing.mutations.portal.get,
|
||||
onSuccess: (data) => {
|
||||
if (!data.url) {
|
||||
return toast.error(t("error.portal"));
|
||||
}
|
||||
return router.push(data.url);
|
||||
},
|
||||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const price = getPlanPrice(plan, options);
|
||||
|
||||
const features = plan.id in PLAN_FEATURES ? PLAN_FEATURES[plan.id] : null;
|
||||
|
||||
const discountForPrice = price
|
||||
? getHighestDiscountForPrice(price, options.discounts)
|
||||
: null;
|
||||
|
||||
const discount =
|
||||
price && discountForPrice
|
||||
? calculatePriceDiscount(price, discountForPrice)
|
||||
: options.model === BillingModel.RECURRING
|
||||
? calculateRecurringDiscount(plan, options.interval)
|
||||
: null;
|
||||
|
||||
const handleCheckout = (user: User | null) => {
|
||||
if (!user) {
|
||||
const url = new URL(pathsConfig.auth.login);
|
||||
url.searchParams.set("redirectTo", pathsConfig.marketing.pricing);
|
||||
return router.push(url.toString());
|
||||
}
|
||||
|
||||
if (!price) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkout.mutate({
|
||||
json: {
|
||||
price: {
|
||||
id: price.id,
|
||||
},
|
||||
redirect: {
|
||||
success: `${appConfig.url}${pathsConfig.dashboard.user.index}`,
|
||||
cancel: `${appConfig.url}${pathname}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenPortal = (user: User | null) => {
|
||||
if (!user) {
|
||||
const url = new URL(pathsConfig.auth.login);
|
||||
url.searchParams.set("redirectTo", pathsConfig.marketing.pricing);
|
||||
return router.push(url.toString());
|
||||
}
|
||||
|
||||
getPortal.mutate({
|
||||
query: {
|
||||
redirectUrl: `${appConfig.url}${pathname}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hasPlan = (customerPlan: string | null) => {
|
||||
if (!customerPlan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPlanIndex = Object.values(PricingPlanType).indexOf(plan.id);
|
||||
const customerCurrentPlanIndex = customerPlan
|
||||
? Object.values(PricingPlanType).indexOf(customerPlan)
|
||||
: -1;
|
||||
|
||||
return currentPlanIndex <= customerCurrentPlanIndex;
|
||||
};
|
||||
|
||||
return {
|
||||
isPending: checkout.isPending || getPortal.isPending,
|
||||
price,
|
||||
features,
|
||||
discount,
|
||||
handleCheckout,
|
||||
handleOpenPortal,
|
||||
hasPlan,
|
||||
};
|
||||
};
|
||||
212
apps/web/src/modules/billing/pricing/plans/plan/plan.tsx
Normal file
212
apps/web/src/modules/billing/pricing/plans/plan/plan.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { BillingModel, formatPrice } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Card } from "@turbostarter/ui-web/card";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { usePlan } from "./hooks/use-plan";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PlanProps {
|
||||
readonly plan: PricingPlan;
|
||||
readonly user: User | null;
|
||||
readonly interval: RecurringInterval;
|
||||
readonly model: BillingModel;
|
||||
readonly currency: string;
|
||||
readonly discounts: Discount[];
|
||||
}
|
||||
|
||||
export const Plan = memo<PlanProps>(
|
||||
({ plan, interval, user, model, currency, discounts }) => {
|
||||
const { data: customer } = useCustomer();
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
const {
|
||||
features,
|
||||
price,
|
||||
discount,
|
||||
isPending,
|
||||
handleCheckout,
|
||||
handleOpenPortal,
|
||||
hasPlan,
|
||||
} = usePlan(plan, { model, interval, discounts, currency });
|
||||
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grow basis-[350px] rounded-lg">
|
||||
<Card
|
||||
className={cn(
|
||||
"relative flex h-full flex-col gap-6 px-7 py-6 md:p-8",
|
||||
plan.badge && "border-primary",
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<Badge className="hover:bg-primary absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1.5 uppercase">
|
||||
{isKey(plan.badge, i18n, "billing") ? t(plan.badge) : plan.badge}
|
||||
</Badge>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-lg font-semibold">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</span>
|
||||
<p className="relative flex items-end gap-1 py-2">
|
||||
{discount?.original &&
|
||||
"amount" in discount.original &&
|
||||
typeof discount.original.amount === "number" &&
|
||||
discount.percentage > 0 && (
|
||||
<span className="text-muted-foreground mr-2 text-lg line-through md:text-xl">
|
||||
{formatPrice(
|
||||
{
|
||||
amount: discount.original.amount,
|
||||
currency,
|
||||
},
|
||||
i18n.language,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-4xl font-bold tracking-tighter md:text-5xl">
|
||||
{price.custom
|
||||
? isKey(price.label, i18n, "billing")
|
||||
? t(price.label)
|
||||
: price.label
|
||||
: formatPrice(
|
||||
{
|
||||
amount:
|
||||
discount?.discounted &&
|
||||
"amount" in discount.discounted
|
||||
? discount.discounted.amount
|
||||
: price.amount,
|
||||
currency,
|
||||
},
|
||||
i18n.language,
|
||||
)}
|
||||
</span>
|
||||
{!price.custom && (
|
||||
<span className="text-muted-foreground shrink-0 text-lg">
|
||||
/{" "}
|
||||
{price.type === BillingModel.RECURRING
|
||||
? t(`interval.${price.interval}`)
|
||||
: t("interval.lifetime")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="text-sm">
|
||||
{isKey(plan.description, i18n, "billing")
|
||||
? t(plan.description)
|
||||
: plan.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{features?.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={cn("flex items-center gap-3 py-1", {
|
||||
"opacity-50": !feature.available,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
feature.available ? "bg-primary" : "border-primary border",
|
||||
)}
|
||||
>
|
||||
{feature.available ? (
|
||||
<Icons.CheckIcon className="text-primary-foreground w-3" />
|
||||
) : (
|
||||
<Icons.X className="text-primary w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-md">
|
||||
{isKey(feature.title, i18n, "billing")
|
||||
? t(feature.title)
|
||||
: feature.title}
|
||||
{"addon" in feature && (
|
||||
<span className="ml-2 whitespace-nowrap">
|
||||
{feature.addon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{"trialDays" in price &&
|
||||
price.trialDays &&
|
||||
!hasPlan(customer?.plan ?? null) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleCheckout(user)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("trial.period", { period: price.trialDays })
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{price.custom ? (
|
||||
<TurboLink href={price.href} className={buttonVariants()}>
|
||||
{hasPlan(customer?.plan ?? null)
|
||||
? t("manage.plan.title")
|
||||
: t("getStarted")}
|
||||
</TurboLink>
|
||||
) : price.amount === 0 ? (
|
||||
<TurboLink
|
||||
href={
|
||||
user
|
||||
? pathsConfig.dashboard.user.index
|
||||
: pathsConfig.auth.login
|
||||
}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
{user ? t("goToDashboard") : t("trial.cta")}
|
||||
</TurboLink>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() =>
|
||||
model === BillingModel.RECURRING &&
|
||||
hasPlan(customer?.plan ?? null)
|
||||
? handleOpenPortal(user)
|
||||
: handleCheckout(user)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : model === BillingModel.RECURRING &&
|
||||
hasPlan(customer?.plan ?? null) ? (
|
||||
t("manage.plan.title")
|
||||
) : model === BillingModel.RECURRING ? (
|
||||
t("subscribe")
|
||||
) : (
|
||||
t("getLifetimeAccess")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Plan.displayName = "Plan";
|
||||
56
apps/web/src/modules/billing/pricing/plans/plans.tsx
Normal file
56
apps/web/src/modules/billing/pricing/plans/plans.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { Plan } from "./plan/plan";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
BillingModel,
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PlansProps {
|
||||
readonly plans: PricingPlan[];
|
||||
readonly discounts: Discount[];
|
||||
readonly user: User | null;
|
||||
readonly interval: RecurringInterval;
|
||||
readonly model: BillingModel;
|
||||
readonly currency: string;
|
||||
}
|
||||
|
||||
export const Plans = memo<PlansProps>(
|
||||
({ plans, discounts, interval, user, model, currency }) => {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-stretch justify-center gap-8 md:gap-6 lg:gap-4">
|
||||
{plans.map((plan) => (
|
||||
<Plan
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
model={model}
|
||||
currency={currency}
|
||||
user={user}
|
||||
discounts={discounts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PlansSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-12 md:gap-6 lg:gap-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="grow-0 basis-[25rem] md:shrink-0">
|
||||
<Skeleton className="h-[32rem] w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Plans.displayName = "Plans";
|
||||
30
apps/web/src/modules/billing/pricing/pricing.tsx
Normal file
30
apps/web/src/modules/billing/pricing/pricing.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { env } from "@turbostarter/billing/env";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getQueryClient } from "~/lib/query/server";
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
|
||||
import { PricingSection } from "./section";
|
||||
|
||||
export const Pricing = async () => {
|
||||
const { user } = await getSession();
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (user) {
|
||||
await queryClient.prefetchQuery({
|
||||
...billing.queries.customer.get,
|
||||
queryFn: handle(api.billing.customer.$get),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PricingSection user={user} model={env.BILLING_MODEL} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
};
|
||||
84
apps/web/src/modules/billing/pricing/section.tsx
Normal file
84
apps/web/src/modules/billing/pricing/section.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import {
|
||||
RecurringInterval,
|
||||
RecurringIntervalDuration,
|
||||
config,
|
||||
getPriceWithHighestDiscount,
|
||||
} from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { Section, SectionHeader } from "~/modules/marketing/layout/section";
|
||||
|
||||
import { PricingHeader } from "./layout/header";
|
||||
import { Plans, PlansSkeleton } from "./plans/plans";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { BillingModel } from "@turbostarter/billing";
|
||||
|
||||
interface PricingSectionProps {
|
||||
readonly user: User | null;
|
||||
readonly model: BillingModel;
|
||||
}
|
||||
|
||||
export const PricingSection = memo<PricingSectionProps>(({ user, model }) => {
|
||||
const { t } = useTranslation("billing");
|
||||
|
||||
const intervals = [
|
||||
...new Set(
|
||||
config.plans.flatMap((plan) =>
|
||||
plan.prices
|
||||
.flatMap((price) => ("interval" in price ? price.interval : null))
|
||||
.filter((x): x is RecurringInterval => !!x),
|
||||
),
|
||||
),
|
||||
].sort((a, b) => RecurringIntervalDuration[a] - RecurringIntervalDuration[b]);
|
||||
|
||||
const [activeInterval, setActiveInterval] = useState<RecurringInterval>(
|
||||
intervals[0] ?? RecurringInterval.MONTH,
|
||||
);
|
||||
|
||||
const priceWithDiscount = getPriceWithHighestDiscount(
|
||||
config.plans,
|
||||
config.discounts,
|
||||
);
|
||||
|
||||
return (
|
||||
<Section id="pricing" className="gap-10 sm:gap-12 md:gap-16 lg:gap-20">
|
||||
<PricingHeader
|
||||
currency={t("currency")}
|
||||
model={model}
|
||||
intervals={intervals}
|
||||
activeInterval={activeInterval}
|
||||
onIntervalChange={setActiveInterval}
|
||||
{...(priceWithDiscount && { priceWithDiscount })}
|
||||
/>
|
||||
<Plans
|
||||
plans={config.plans}
|
||||
interval={activeInterval}
|
||||
model={model}
|
||||
currency={t("currency")}
|
||||
discounts={config.discounts}
|
||||
user={user}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
});
|
||||
|
||||
export const PricingSectionSkeleton = () => {
|
||||
return (
|
||||
<Section id="pricing" className="gap-10 sm:gap-12 md:gap-16 lg:gap-20">
|
||||
<SectionHeader className="flex flex-col items-center justify-center gap-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="mt-4 h-12 w-72" />
|
||||
<Skeleton className="h-8 w-96" />
|
||||
</SectionHeader>
|
||||
<PlansSkeleton />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
PricingSection.displayName = "PricingSection";
|
||||
Reference in New Issue
Block a user