feat: add pricing plans and enhance payment configuration

- Introduced new pricing plans (Free, Pro, Lifetime) with detailed descriptions and features in both English and Chinese.
- Updated payment configuration to include new plans and their pricing structures.
- Refactored payment-related functions to retrieve pricing plans consistently across the application.
- Enhanced the PricingTable component to dynamically display available plans based on the new configuration.
- Removed deprecated payment configuration file to streamline the codebase.
This commit is contained in:
javayhu 2025-04-10 00:26:55 +08:00
parent 31a4823b54
commit 165673a998
14 changed files with 208 additions and 130 deletions

View File

@ -512,6 +512,43 @@
"button": "Upgrade"
}
},
"PricePlans": {
"free": {
"name": "Free",
"description": "Basic features for personal use",
"features": {
"projects": "Up to 3 projects",
"storage": "1 GB storage",
"analytics": "Basic analytics",
"support": "Community support"
}
},
"pro": {
"name": "Pro",
"description": "Advanced features for professionals",
"features": {
"projects": "Unlimited projects",
"storage": "10 GB storage",
"analytics": "Advanced analytics",
"support": "Priority support",
"domains": "Custom domains",
"collaboration": "Team collaboration"
}
},
"lifetime": {
"name": "Lifetime",
"description": "Premium features with one-time payment",
"features": {
"proFeatures": "All Pro features",
"storage": "100 GB storage",
"support": "Dedicated support",
"security": "Enterprise-grade security",
"integrations": "Advanced integrations",
"branding": "Custom branding",
"updates": "Lifetime updates"
}
}
},
"Mail": {
"common": {
"team": "{name} Team",

View File

@ -511,6 +511,43 @@
"button": "升级"
}
},
"PricePlans": {
"free": {
"name": "免费版",
"description": "适用于个人使用的基本功能",
"features": {
"projects": "最多3个项目",
"storage": "1GB存储空间",
"analytics": "基础分析功能",
"support": "社区支持"
}
},
"pro": {
"name": "专业版",
"description": "专业人士的高级功能",
"features": {
"projects": "无限项目",
"storage": "10GB存储空间",
"analytics": "高级分析功能",
"support": "优先支持",
"domains": "自定义域名",
"collaboration": "团队协作"
}
},
"lifetime": {
"name": "终身版",
"description": "一次性付款获得所有高级功能",
"features": {
"proFeatures": "所有专业版功能",
"storage": "100GB存储空间",
"support": "专属支持",
"security": "企业级安全",
"integrations": "高级集成",
"branding": "自定义品牌",
"updates": "终身更新"
}
}
},
"Mail": {
"common": {
"team": "{name} 团队",

View File

@ -3,7 +3,7 @@
import db from "@/db";
import { payment } from "@/db/schema";
import { getSession } from "@/lib/server";
import { getAllPlans } from "@/payment";
import { getAllPricePlans } from "@/payment";
import { PaymentTypes } from "@/payment/types";
import { and, eq } from "drizzle-orm";
import { createSafeActionClient } from 'next-safe-action';
@ -51,7 +51,7 @@ export const getLifetimeStatusAction = actionClient
try {
// Get lifetime plans
const plans = getAllPlans();
const plans = getAllPricePlans();
const lifetimePlanIds = plans
.filter(plan => plan.isLifetime)
.map(plan => plan.id);

View File

@ -1,7 +1,7 @@
import { HeaderSection } from '@/components/layout/header-section';
import { constructMetadata } from '@/lib/metadata';
import { getBaseUrlWithLocale } from '@/lib/urls/urls';
import { getAllPlans } from '@/payment';
import { getAllPricePlans } from '@/payment';
import { Metadata } from 'next';
import { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@ -29,7 +29,7 @@ export default async function PricingPageLayout({
const t = await getTranslations('PricingPage');
// Get all plans as an array
const plans = getAllPlans();
const plans = getAllPricePlans();
return (
<div className="mb-16">

View File

@ -1,14 +1,10 @@
import Container from '@/components/layout/container';
import { PricingTable } from '@/components/payment/pricing-table';
import { getAllPlans } from '@/payment';
export default async function PricingPage() {
// Get all plans as an array
const plans = getAllPlans();
return (
<Container className="mt-8 px-4 max-w-6xl">
<PricingTable plans={plans} />
<PricingTable />
</Container>
);
}

View File

@ -182,7 +182,7 @@ export function PricingCard({
{/* show features of this plan */}
<ul className="list-outside space-y-4 text-sm">
{plan.features.map((feature, i) => (
{plan.features?.map((feature, i) => (
<li key={i} className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-green-500 dark:text-green-400" />
<span>{feature}</span>

View File

@ -1,6 +1,7 @@
'use client';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { getPricePlanInfos } from '@/config';
import { cn } from '@/lib/utils';
import { PaymentTypes, PlanInterval, PlanIntervals, PricePlan } from '@/payment/types';
import { useTranslations } from 'next-intl';
@ -8,7 +9,6 @@ import { useState } from 'react';
import { PricingCard } from './pricing-card';
interface PricingTableProps {
plans: PricePlan[];
metadata?: Record<string, string>;
currentPlan?: PricePlan | null;
className?: string;
@ -23,7 +23,6 @@ interface PricingTableProps {
* 3. If a price is disabled, it will not be displayed in the pricing table
*/
export function PricingTable({
plans,
metadata,
currentPlan,
className,
@ -31,6 +30,10 @@ export function PricingTable({
const t = useTranslations('PricingPage');
const [interval, setInterval] = useState<PlanInterval>(PlanIntervals.MONTH);
// Get plans either from props or from the config
const paymentConfig = getPricePlanInfos();
const plans = Object.values(paymentConfig.plans);
// Current plan ID for comparison
const currentPlanId = currentPlan?.id || null;

View File

@ -5,6 +5,11 @@ import { InstagramIcon } from '@/components/icons/instagram';
import { LinkedInIcon } from '@/components/icons/linkedin';
import { TikTokIcon } from '@/components/icons/tiktok';
import { YouTubeIcon } from '@/components/icons/youtube';
import {
PaymentConfig,
PaymentTypes,
PlanIntervals
} from '@/payment/types';
import { Routes } from '@/routes';
import { MenuItem, NestedMenuItem, WebsiteConfig } from '@/types';
import {
@ -73,6 +78,51 @@ export const websiteConfig: WebsiteConfig = {
facebook: 'https://facebook.com/mksaas',
instagram: 'https://instagram.com/mksaas',
tiktok: 'https://tiktok.com/@mksaas',
},
payment: {
plans: {
free: {
id: "free",
prices: [],
isFree: true,
isLifetime: false,
},
pro: {
id: "pro",
prices: [
{
type: PaymentTypes.SUBSCRIPTION,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY!,
amount: 990,
currency: "USD",
interval: PlanIntervals.MONTH,
},
{
type: PaymentTypes.SUBSCRIPTION,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY!,
amount: 9900,
currency: "USD",
interval: PlanIntervals.YEAR,
},
],
isFree: false,
isLifetime: false,
recommended: true,
},
lifetime: {
id: "lifetime",
prices: [
{
type: PaymentTypes.ONE_TIME,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME!,
amount: 19900,
currency: "USD",
},
],
isFree: false,
isLifetime: true,
}
}
}
};
@ -563,3 +613,61 @@ export function getSocialLinks(): MenuItem[] {
return socialLinks;
}
/**
* Get price plans with translations for client components
*
* NOTICE: This function should only be used in client components
*
* @returns The price plans with translated content
*/
export function getPricePlanInfos(): PaymentConfig {
const t = useTranslations('PricePlans');
const { payment } = websiteConfig;
// Create a deep clone of the payment config to avoid modifying the original
const paymentConfig: PaymentConfig = {
plans: JSON.parse(JSON.stringify(payment.plans))
};
// Add translated content to each plan
if (paymentConfig.plans.free) {
paymentConfig.plans.free.name = t('free.name');
paymentConfig.plans.free.description = t('free.description');
paymentConfig.plans.free.features = [
t('free.features.projects'),
t('free.features.analytics'),
t('free.features.support'),
t('free.features.storage')
];
}
if (paymentConfig.plans.pro) {
paymentConfig.plans.pro.name = t('pro.name');
paymentConfig.plans.pro.description = t('pro.description');
paymentConfig.plans.pro.features = [
t('pro.features.projects'),
t('pro.features.analytics'),
t('pro.features.support'),
t('pro.features.storage'),
t('pro.features.domains'),
t('pro.features.collaboration')
];
}
if (paymentConfig.plans.lifetime) {
paymentConfig.plans.lifetime.name = t('lifetime.name');
paymentConfig.plans.lifetime.description = t('lifetime.description');
paymentConfig.plans.lifetime.features = [
t('lifetime.features.proFeatures'),
t('lifetime.features.security'),
t('lifetime.features.support'),
t('lifetime.features.storage'),
t('lifetime.features.integrations'),
t('lifetime.features.branding'),
t('lifetime.features.updates')
];
}
return paymentConfig;
}

View File

@ -1,98 +0,0 @@
import { PaymentConfig, PaymentTypes, PlanIntervals, PricePlan } from "../types";
/**
* Free plan definition
*/
const freePlan: PricePlan = {
id: "free",
name: "Free",
description: "Basic features for personal use",
features: [
"Up to 3 projects",
"Basic analytics",
"Community support",
"1 GB storage"
],
prices: [],
isFree: true,
isLifetime: false,
};
/**
* Pro plan definition
*/
const proPlan: PricePlan = {
id: "pro",
name: "Pro",
description: "Advanced features for professionals",
features: [
"Unlimited projects",
"Advanced analytics",
"Priority support",
"10 GB storage",
"Custom domains",
"Team collaboration"
],
prices: [
{
type: PaymentTypes.SUBSCRIPTION,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY!,
amount: 990,
currency: "USD",
interval: PlanIntervals.MONTH,
},
{
type: PaymentTypes.SUBSCRIPTION,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY!,
amount: 9900,
currency: "USD",
interval: PlanIntervals.YEAR,
},
],
isFree: false,
isLifetime: false,
recommended: true,
};
/**
* Lifetime plan definition
*/
const lifetimePlan: PricePlan = {
id: "lifetime",
name: "Lifetime",
description: "Premium features with one-time payment",
features: [
"All Pro features",
"Enterprise-grade security",
"Dedicated support",
"100 GB storage",
"Advanced integrations",
"Custom branding",
"Lifetime updates"
],
prices: [
{
type: PaymentTypes.ONE_TIME,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME!,
amount: 19900,
currency: "USD",
},
],
isFree: false,
isLifetime: true,
};
/**
* Payment configuration
*
* NOTICE:
* 1. One priceId can only be used by one plan.
*/
export const paymentConfig: PaymentConfig = {
plans: {
free: freePlan,
pro: proPlan,
lifetime: lifetimePlan,
},
defaultCurrency: "USD",
};

View File

@ -1,11 +1,6 @@
import { PaymentProvider, PricePlan, PaymentConfig, Customer, Subscription, Payment, PaymentStatus, PlanInterval, PaymentType, Price, CreateCheckoutParams, CheckoutResult, CreatePortalParams, PortalResult, getSubscriptionsParams } from "./types";
import { StripeProvider } from "./provider/stripe";
import { paymentConfig } from "./config/payment-config";
/**
* Default payment configuration
*/
export const defaultPaymentConfig: PaymentConfig = paymentConfig;
import { websiteConfig } from "@/config";
/**
* Global payment provider instance
@ -90,15 +85,15 @@ export const getSubscriptions = async (
* @returns Plan or undefined if not found
*/
export const getPlanById = (planId: string): PricePlan | undefined => {
return defaultPaymentConfig.plans[planId];
return websiteConfig.payment.plans[planId];
};
/**
* Get all available plans
* Get all price plans
* @returns Array of price plans
*/
export const getAllPlans = (): PricePlan[] => {
return Object.values(defaultPaymentConfig.plans);
export const getAllPricePlans = (): PricePlan[] => {
return Object.values(websiteConfig.payment.plans);
};
/**
@ -122,7 +117,7 @@ export const findPriceInPlan = (planId: string, priceId: string): Price | undefi
* @returns Plan or undefined if not found
*/
export const findPlanByPriceId = (priceId: string): PricePlan | undefined => {
const plans = getAllPlans();
const plans = getAllPricePlans();
for (const plan of plans) {
const matchingPrice = plan.prices.find(price => price.priceId === priceId);
if (matchingPrice) {

View File

@ -61,9 +61,9 @@ export interface Price {
*/
export interface PricePlan {
id: string; // Unique identifier for the plan
name: string; // Display name of the plan
description: string; // Description of the plan features
features: string[]; // List of features included in this plan
name?: string; // Display name of the plan
description?: string; // Description of the plan features
features?: string[]; // List of features included in this plan
prices: Price[]; // Available prices for this plan
isFree: boolean; // Whether this is a free plan
isLifetime: boolean; // Whether this is a lifetime plan
@ -76,7 +76,6 @@ export interface PricePlan {
*/
export interface PaymentConfig {
plans: Record<string, PricePlan>; // Plans indexed by ID
defaultCurrency: string; // Default currency
}
/**

View File

@ -67,8 +67,6 @@ STORAGE_REGION=us-east-1
STORAGE_ACCESS_KEY_ID=your-access-key
STORAGE_SECRET_ACCESS_KEY=your-secret-key
STORAGE_BUCKET_NAME=your-bucket-name
# Optional
STORAGE_ENDPOINT=https://custom-endpoint.com
STORAGE_PUBLIC_URL=https://cdn.example.com
STORAGE_FORCE_PATH_STYLE=true

View File

@ -1,7 +1,7 @@
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
import { Session } from '@/lib/auth';
import { getAllPlans } from '@/payment';
import { getAllPricePlans } from '@/payment';
import { PricePlan, Subscription } from '@/payment/types';
import { create } from 'zustand';
@ -55,8 +55,9 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
// Fetch subscription data
set({ isLoading: true, error: null });
// Get all available plans
const plans = getAllPlans();
// Get all price plans
let plans: PricePlan[] = getAllPricePlans();
const freePlan = plans.find(plan => plan.isFree);
const lifetimePlan = plans.find(plan => plan.isLifetime);

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import type { PaymentConfig } from '@/payment/types';
/**
* website config, without translations
@ -30,6 +31,7 @@ export type WebsiteConfig = {
instagram?: string;
tiktok?: string;
};
payment: PaymentConfig;
};
/**