From 9492b035a0bcc043119a7964c0899154789797d8 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Fri, 5 Apr 2024 19:23:03 +0800 Subject: [PATCH] Update billing schema and enhance configuration display Updated the billing schema to include a more descriptive line item and an optional tiers element. Also, billing configuration was refactored and displayed more prominently in the UI. The plan feature listing now utilizes checkmarks to denote each feature and the product details are more clearly displayed. --- apps/web/config/billing.config.ts | 180 +---------------- apps/web/config/billing.sample.config.ts | 186 ++++++++++++++++++ .../billing/core/src/create-billing-schema.ts | 34 +++- .../src/components/line-item-details.tsx | 68 +++++-- .../gateway/src/components/plan-picker.tsx | 13 +- .../gateway/src/components/pricing-table.tsx | 59 ++++-- 6 files changed, 327 insertions(+), 213 deletions(-) create mode 100644 apps/web/config/billing.sample.config.ts diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index 10d35be9f..d0780c35f 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -1,174 +1,8 @@ -import { BillingProviderSchema, createBillingSchema } from '@kit/billing'; +/* +Replace this file with your own billing configuration file. +Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products. +This file will never be overwritten by git updates + */ +import sampleSchema from './billing.sample.config'; -// The billing provider to use. This should be set in the environment variables -// and should match the provider in the database. We also add it here so we can validate -// your configuration against the selected provider at build time. -const provider = BillingProviderSchema.parse( - process.env.NEXT_PUBLIC_BILLING_PROVIDER, -); - -export default createBillingSchema({ - // also update config.billing_provider in the DB to match the selected - provider, - // products configuration - products: [ - { - id: 'lifetime', - name: 'Lifetime', - description: 'The perfect plan for a lifetime', - currency: 'USD', - features: ['Feature 1', 'Feature 2', 'Feature 3'], - plans: [ - { - name: 'Lifetime', - id: 'lifetime', - paymentType: 'one-time', - lineItems: [ - { - id: '324643', - name: 'Base', - description: 'Base plan', - cost: 999.99, - type: 'base', - }, - ], - }, - ], - }, - { - id: 'starter', - name: 'Starter', - description: 'The perfect plan to get started', - currency: 'USD', - badge: `Value`, - plans: [ - { - name: 'Starter Monthly', - id: 'starter-monthly', - trialPeriod: 7, - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: '55476', - name: 'Base', - description: 'Base plan', - cost: 9.99, - type: 'base', - }, - ], - }, - { - name: 'Starter Yearly', - id: 'starter-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', - name: 'Base', - description: 'Base plan', - cost: 99.99, - type: 'base', - }, - ], - }, - ], - features: ['Feature 1', 'Feature 2', 'Feature 3'], - }, - { - id: 'pro', - name: 'Pro', - badge: `Popular`, - highlighted: true, - description: 'The perfect plan for professionals', - currency: 'USD', - plans: [ - { - name: 'Pro Monthly', - id: 'pro-monthly', - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', - name: 'Base', - description: 'Base plan', - cost: 19.99, - type: 'base', - }, - ], - }, - { - name: 'Pro Yearly', - id: 'pro-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', - name: 'Base', - description: 'Base plan', - cost: 199.99, - type: 'base', - }, - ], - }, - ], - features: [ - 'Feature 1', - 'Feature 2', - 'Feature 3', - 'Feature 4', - 'Feature 5', - ], - }, - { - id: 'enterprise', - name: 'Enterprise', - description: 'The perfect plan for enterprises', - currency: 'USD', - plans: [ - { - name: 'Enterprise Monthly', - id: 'enterprise-monthly', - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', - name: 'Base', - description: 'Base plan', - cost: 29.99, - type: 'base', - }, - ], - }, - { - name: 'Enterprise Yearly', - id: 'enterprise-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', - name: 'Base', - description: 'Base plan', - cost: 299.99, - type: 'base', - }, - ], - }, - ], - features: [ - 'Feature 1', - 'Feature 2', - 'Feature 3', - 'Feature 4', - 'Feature 5', - 'Feature 6', - 'Feature 7', - ], - }, - ], -}); +export default sampleSchema; diff --git a/apps/web/config/billing.sample.config.ts b/apps/web/config/billing.sample.config.ts new file mode 100644 index 000000000..078b9f23e --- /dev/null +++ b/apps/web/config/billing.sample.config.ts @@ -0,0 +1,186 @@ +/** + * This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace + * the configuration with your own billing provider and products. + */ +import { BillingProviderSchema, createBillingSchema } from '@kit/billing'; + +// The billing provider to use. This should be set in the environment variables +// and should match the provider in the database. We also add it here so we can validate +// your configuration against the selected provider at build time. +const provider = BillingProviderSchema.parse( + process.env.NEXT_PUBLIC_BILLING_PROVIDER, +); + +export default createBillingSchema({ + // also update config.billing_provider in the DB to match the selected + provider, + // products configuration + products: [ + { + id: 'lifetime', + name: 'Lifetime', + description: 'The perfect plan for a lifetime', + currency: 'USD', + features: ['Feature 1', 'Feature 2', 'Feature 3'], + plans: [ + { + name: 'Lifetime', + id: 'lifetime', + paymentType: 'one-time', + lineItems: [ + { + id: '324643', + name: 'Base', + cost: 999.99, + type: 'base', + }, + ], + }, + ], + }, + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialPeriod: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: '55476', + name: 'Base', + cost: 9.99, + type: 'base', + }, + { + id: '324644', + name: 'Addon 1', + cost: 99.99, + type: 'metered', + unit: 'GB', + included: 10, + }, + { + id: '324645', + name: 'Addon 2', + cost: 9.99, + type: 'per-seat', + included: 5, + }, + ], + }, + { + name: 'Starter Yearly', + id: 'starter-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', + name: 'Base', + cost: 99.99, + type: 'base', + }, + ], + }, + ], + features: ['Feature 1', 'Feature 2', 'Feature 3'], + }, + { + id: 'pro', + name: 'Pro', + badge: `Popular`, + highlighted: true, + description: 'The perfect plan for professionals', + currency: 'USD', + plans: [ + { + name: 'Pro Monthly', + id: 'pro-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', + name: 'Base', + cost: 19.99, + type: 'base', + }, + ], + }, + { + name: 'Pro Yearly', + id: 'pro-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', + name: 'Base', + cost: 199.99, + type: 'base', + }, + ], + }, + ], + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + ], + }, + { + id: 'enterprise', + name: 'Enterprise', + description: 'The perfect plan for enterprises', + currency: 'USD', + plans: [ + { + name: 'Enterprise Monthly', + id: 'enterprise-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', + name: 'Base', + cost: 29.99, + type: 'base', + }, + ], + }, + { + name: 'Enterprise Yearly', + id: 'enterprise-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', + name: 'Base', + cost: 299.99, + type: 'base', + }, + ], + }, + ], + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + 'Feature 6', + 'Feature 7', + ], + }, + ], +}); diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 65744a349..82c7fbf3f 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -26,7 +26,9 @@ export const LineItemSchema = z .min(1), description: z .string({ - description: 'Description of the line item. Displayed to the user.', + description: + 'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' + + ' from the line item. This is useful if you want to provide a more detailed description to the user.', }) .optional(), cost: z @@ -46,11 +48,33 @@ export const LineItemSchema = z description: 'Included amount of the line item. Displayed to the user.', }) .optional(), + tiers: z + .array( + z.object({ + upTo: z + .number({ + description: + 'Up to this amount the cost is the base cost. Displayed to the user.', + }) + .min(0), + cost: z + .number({ + description: + 'Cost of the line item after the upTo amount. Displayed to the user.', + }) + .min(0), + }), + ) + .optional(), }) - .refine((data) => data.type !== 'metered' || (data.unit && data.included), { - message: 'Metered line items must have a unit and included amount', - path: ['type', 'unit', 'included'], - }); + .refine( + (data) => + data.type !== 'metered' || (data.unit && data.included !== undefined), + { + message: 'Metered line items must have a unit and included amount', + path: ['type', 'unit', 'included'], + }, + ); export const PlanSchema = z .object({ diff --git a/packages/billing/gateway/src/components/line-item-details.tsx b/packages/billing/gateway/src/components/line-item-details.tsx index ca86bfa05..c7045b580 100644 --- a/packages/billing/gateway/src/components/line-item-details.tsx +++ b/packages/billing/gateway/src/components/line-item-details.tsx @@ -1,3 +1,4 @@ +import { Plus, PlusCircle } from 'lucide-react'; import { z } from 'zod'; import { LineItemSchema } from '@kit/billing'; @@ -5,6 +6,9 @@ import { formatCurrency } from '@kit/shared/utils'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; +const className = + 'flex text-secondary-foreground items-center justify-between text-sm'; + export function LineItemDetails( props: React.PropsWithChildren<{ lineItems: z.infer[]; @@ -12,22 +16,42 @@ export function LineItemDetails( selectedInterval?: string | undefined; }>, ) { - const className = - 'flex items-center justify-between text-sm border-b border-dashed pb-2 last:border-transparent'; - return ( -
+
{props.lineItems.map((item) => { + // If the item has a description, we render it as a simple text + // and pass the item as values to the translation so we can use + // the item properties in the translation. + if (item.description) { + return ( +
+ + + + + +
+ ); + } + switch (item.type) { case 'base': return (
- - - + + + + + + + - / + - - - + + + + + + @@ -63,14 +91,18 @@ export function LineItemDetails( case 'metered': return (
- - - + + + + + + + {item.included ? ( diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index bf5d05c7f..54a290e02 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -34,6 +34,7 @@ import { RadioGroupItem, RadioGroupItemLabel, } from '@kit/ui/radio-group'; +import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; @@ -410,8 +411,10 @@ function PlanDetails({

-
- + + +
+ @@ -423,16 +426,16 @@ function PlanDetails({
- + {selectedProduct.features.map((item) => { return ( -
+
- +
diff --git a/packages/billing/gateway/src/components/pricing-table.tsx b/packages/billing/gateway/src/components/pricing-table.tsx index 3f544d762..88b508695 100644 --- a/packages/billing/gateway/src/components/pricing-table.tsx +++ b/packages/billing/gateway/src/components/pricing-table.tsx @@ -5,8 +5,14 @@ import { useState } from 'react'; import Link from 'next/link'; import { ArrowRight, CheckCircle, Sparkles } from 'lucide-react'; +import { z } from 'zod'; -import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing'; +import { + BillingConfig, + LineItemSchema, + getBaseLineItem, + getPlanIntervals, +} from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; @@ -15,6 +21,8 @@ import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import { LineItemDetails } from './line-item-details'; + interface Paths { signUp: string; } @@ -23,9 +31,11 @@ export function PricingTable({ config, paths, CheckoutButtonRenderer, + displayPlanDetails = true, }: { config: BillingConfig; paths: Paths; + displayPlanDetails?: boolean; CheckoutButtonRenderer?: React.ComponentType<{ planId: string; @@ -83,6 +93,7 @@ export function PricingTable({ baseLineItem={basePlan} product={product} paths={paths} + displayPlanDetails={displayPlanDetails} CheckoutButton={CheckoutButtonRenderer} /> ); @@ -95,6 +106,7 @@ export function PricingTable({ function PricingItem( props: React.PropsWithChildren<{ className?: string; + displayPlanDetails: boolean; paths: { signUp: string; @@ -109,6 +121,7 @@ function PricingItem( plan: { id: string; + lineItems: z.infer[]; interval?: string; name?: string; href?: string; @@ -132,13 +145,19 @@ function PricingItem( ) { const highlighted = props.product.highlighted ?? false; + // we want to exclude the base plan from the list of line items + // since we are displaying the base plan separately as the main price + const lineItemsToDisplay = props.plan.lineItems.filter((item) => { + return item.type !== 'base'; + }); + return (
-
+
+
+ +
+
+ + +
+
+ +
+ + +
+
@@ -286,18 +323,16 @@ function ListItem({ }>) { return (
  • -
    - -
    +