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:
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