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:
@@ -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";
|
||||
Reference in New Issue
Block a user