diff --git a/messages/en.json b/messages/en.json
index 9ea47d6..3f37d97 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -371,7 +371,51 @@
"cancel": "Cancel"
},
"billing": {
- "title": "Billing"
+ "title": "Billing & Subscription",
+ "description": "Manage your subscription and billing information",
+ "status": {
+ "active": "Active",
+ "trial": "Trial",
+ "free": "Free"
+ },
+ "interval": {
+ "month": "month",
+ "year": "year",
+ "oneTime": "one-time"
+ },
+ "currentPlan": {
+ "title": "Current Plan",
+ "description": "Your current subscription details"
+ },
+ "nextBillingDate": "Next billing date:",
+ "trialEnds": "Trial ends:",
+ "freePlanMessage": "You are currently on the free plan with limited features.",
+ "manageSubscription": "Manage Subscription",
+ "upgradeMessage": "Upgrade to a paid plan to access more features",
+ "paymentMethod": {
+ "title": "Payment Method",
+ "description": "Manage your payment methods",
+ "manageMessage": "Manage your payment methods through the Stripe Customer Portal.",
+ "securityMessage": "You can add, remove, or update your payment methods securely through the Stripe portal.",
+ "noMethodsMessage": "No payment methods on file.",
+ "upgradePromptMessage": "You'll be prompted to add a payment method when upgrading to a paid plan."
+ },
+ "managePaymentMethods": "Manage Payment Methods",
+ "upgradePlan": {
+ "title": "Upgrade Your Plan",
+ "description": "Choose a plan that works for you"
+ },
+ "trialDays": "{{days}} day trial",
+ "upgradeToPlan": "Upgrade to {{planName}}",
+ "customPricing": "Custom Pricing",
+ "contactSales": "Contact Sales",
+ "billingHistory": {
+ "title": "Billing History",
+ "description": "View and download your past invoices",
+ "accessMessage": "Access your billing history through the Stripe Customer Portal",
+ "noHistoryMessage": "No billing history available"
+ },
+ "viewBillingHistory": "View Billing History"
},
"notification": {
"title": "Notification",
diff --git a/messages/zh.json b/messages/zh.json
index 3a7e910..8935bd2 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -366,7 +366,51 @@
"cancel": "取消"
},
"billing": {
- "title": "账单"
+ "title": "账单与订阅",
+ "description": "管理您的订阅和账单信息",
+ "status": {
+ "active": "已激活",
+ "trial": "试用中",
+ "free": "免费版"
+ },
+ "interval": {
+ "month": "月",
+ "year": "年",
+ "oneTime": "一次性"
+ },
+ "currentPlan": {
+ "title": "当前方案",
+ "description": "您的当前订阅详情"
+ },
+ "nextBillingDate": "下次账单日期:",
+ "trialEnds": "试用结束日期:",
+ "freePlanMessage": "您当前使用的是功能有限的免费方案。",
+ "manageSubscription": "管理订阅",
+ "upgradeMessage": "升级到付费方案以获取更多功能",
+ "paymentMethod": {
+ "title": "支付方式",
+ "description": "管理您的支付方式",
+ "manageMessage": "通过 Stripe 客户门户管理您的支付方式。",
+ "securityMessage": "您可以通过 Stripe 门户安全地添加、删除或更新您的支付方式。",
+ "noMethodsMessage": "没有支付方式记录。",
+ "upgradePromptMessage": "升级到付费方案时,系统会提示您添加支付方式。"
+ },
+ "managePaymentMethods": "管理支付方式",
+ "upgradePlan": {
+ "title": "升级您的方案",
+ "description": "选择适合您的方案"
+ },
+ "trialDays": "{{days}} 天试用期",
+ "upgradeToPlan": "升级到 {{planName}}",
+ "customPricing": "定制价格",
+ "contactSales": "联系销售",
+ "billingHistory": {
+ "title": "账单历史",
+ "description": "查看并下载您的历史账单",
+ "accessMessage": "通过 Stripe 客户门户访问您的账单历史",
+ "noHistoryMessage": "没有可用的账单历史"
+ },
+ "viewBillingHistory": "查看账单历史"
},
"notification": {
"title": "通知",
diff --git a/src/actions/mail.ts b/src/actions/mail.ts
index 623a104..4b97b30 100644
--- a/src/actions/mail.ts
+++ b/src/actions/mail.ts
@@ -8,6 +8,10 @@ import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
+/**
+ * TODO: When using Zod for validation, how can I localize error messages?
+ * https://next-intl.dev/docs/environments/actions-metadata-route-handlers#server-actions
+ */
// Contact form schema for validation
const contactFormSchema = z.object({
name: z
diff --git a/src/actions/payment.ts b/src/actions/payment.ts
new file mode 100644
index 0000000..a378070
--- /dev/null
+++ b/src/actions/payment.ts
@@ -0,0 +1,109 @@
+'use server';
+
+import { getBaseUrlWithLocale } from "@/lib/urls/get-base-url";
+import { createCheckout, createCustomerPortal, getPlanById } from "@/payment";
+import { CreateCheckoutParams, CreatePortalParams } from "@/payment/types";
+import { getLocale } from "next-intl/server";
+import { createSafeActionClient } from 'next-safe-action';
+import { z } from 'zod';
+
+// Create a safe action client
+const actionClient = createSafeActionClient();
+
+// Checkout schema for validation
+const checkoutSchema = z.object({
+ planId: z.string().min(1, { message: 'Plan ID is required' }),
+ priceId: z.string().min(1, { message: 'Price ID is required' }),
+ email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
+ metadata: z.record(z.string()).optional(),
+});
+
+// Portal schema for validation
+const portalSchema = z.object({
+ customerId: z.string().min(1, { message: 'Customer ID is required' }),
+ returnUrl: z.string().url({ message: 'Return URL must be a valid URL' }).optional(),
+});
+
+/**
+ * Create a checkout session for a price plan
+ */
+export const createCheckoutAction = actionClient
+ .schema(checkoutSchema)
+ .action(async ({ parsedInput }) => {
+ try {
+ const { planId, priceId, email, metadata } = parsedInput;
+
+ // Get the current locale from the request
+ const locale = await getLocale();
+
+ // Check if plan exists
+ const plan = getPlanById(planId);
+ if (!plan) {
+ return {
+ success: false,
+ error: 'Plan not found',
+ };
+ }
+
+ // Create the checkout session with localized URLs
+ const baseUrlWithLocale = getBaseUrlWithLocale(locale);
+ const successUrl = `${baseUrlWithLocale}/payment/success?session_id={CHECKOUT_SESSION_ID}`;
+ const cancelUrl = `${baseUrlWithLocale}/payment/cancel`;
+ const params: CreateCheckoutParams = {
+ planId,
+ priceId,
+ customerEmail: email,
+ metadata,
+ successUrl,
+ cancelUrl,
+ };
+
+ const result = await createCheckout(params);
+
+ return {
+ success: true,
+ data: result,
+ };
+ } catch (error: any) {
+ console.error("Error creating checkout session:", error);
+ return {
+ success: false,
+ error: error.message || 'Failed to create checkout session',
+ };
+ }
+ });
+
+/**
+ * Create a customer portal session
+ */
+export const createPortalAction = actionClient
+ .schema(portalSchema)
+ .action(async ({ parsedInput }) => {
+ try {
+ const { customerId, returnUrl } = parsedInput;
+
+ // Get the current locale from the request
+ const locale = await getLocale();
+
+ // Create the portal session with localized URL if no custom return URL is provided
+ const baseUrlWithLocale = getBaseUrlWithLocale(locale);
+ const returnUrlWithLocale = returnUrl || `${baseUrlWithLocale}/account/billing`;
+ const params: CreatePortalParams = {
+ customerId,
+ returnUrl: returnUrlWithLocale,
+ };
+
+ const result = await createCustomerPortal(params);
+
+ return {
+ success: true,
+ data: result,
+ };
+ } catch (error: any) {
+ console.error("Error creating customer portal session:", error);
+ return {
+ success: false,
+ error: error.message || 'Failed to create customer portal session',
+ };
+ }
+ });
\ No newline at end of file
diff --git a/src/app/[locale]/(dashborad)/settings/billing/page.tsx b/src/app/[locale]/(dashborad)/settings/billing/page.tsx
index 8efc7f5..1cc63cf 100644
--- a/src/app/[locale]/(dashborad)/settings/billing/page.tsx
+++ b/src/app/[locale]/(dashborad)/settings/billing/page.tsx
@@ -1,6 +1,9 @@
-import { DashboardHeader } from '@/components/dashboard/dashboard-header';
+"use client";
+
import { useTranslations } from 'next-intl';
-
+import { DashboardHeader } from '@/components/dashboard/dashboard-header';
+import BillingCard from '@/components/settings/billing/billing-card';
+
export default function SettingsBillingPage() {
const t = useTranslations();
@@ -18,8 +21,9 @@ export default function SettingsBillingPage() {
return (
<>
-
+
+
>
);
diff --git a/src/app/[locale]/(marketing)/payment/cancel/page.tsx b/src/app/[locale]/(marketing)/payment/cancel/page.tsx
new file mode 100644
index 0000000..545705e
--- /dev/null
+++ b/src/app/[locale]/(marketing)/payment/cancel/page.tsx
@@ -0,0 +1,51 @@
+import Link from "next/link";
+import { XCircle } from "lucide-react";
+
+/**
+ * Payment Cancel Page
+ *
+ * This page is displayed when a payment has been cancelled
+ * It shows a cancellation message and provides links to try again or get help
+ */
+export default function PaymentCancelPage() {
+ return (
+
+
+
+
+
+
+
+ Payment Cancelled
+
+
+
+ Your payment process was cancelled. No charges were made to your account.
+
+
+
+
+
+ If you encountered any issues during checkout, please contact our support team.
+
+
+
+
+
+ Try Again
+
+
+ Get Help
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/[locale]/(marketing)/payment/success/page.tsx b/src/app/[locale]/(marketing)/payment/success/page.tsx
new file mode 100644
index 0000000..d928f17
--- /dev/null
+++ b/src/app/[locale]/(marketing)/payment/success/page.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import Link from "next/link";
+import { CheckCircle, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface PaymentSuccessState {
+ status: 'loading' | 'success' | 'error';
+ message?: string;
+}
+
+/**
+ * Payment Success Page
+ *
+ * This page is displayed when a payment has been successfully completed
+ * It verifies the checkout session and shows a success message
+ */
+export default function PaymentSuccessPage() {
+ const searchParams = useSearchParams();
+ const sessionId = searchParams.get('session_id');
+ const [state, setState] = useState({ status: 'loading' });
+
+ // Verify the checkout session when the page loads
+ useEffect(() => {
+ async function verifyCheckoutSession() {
+ if (!sessionId) {
+ setState({
+ status: 'success',
+ message: 'Thank you for your payment. Your transaction has been completed.'
+ });
+ return;
+ }
+
+ try {
+ // You could verify the session here via an API call
+ // For now, we'll just assume it's valid if sessionId exists
+ setState({
+ status: 'success',
+ message: 'Thank you for your payment. Your transaction has been completed successfully.'
+ });
+ } catch (error) {
+ console.error('Error verifying checkout session:', error);
+ setState({
+ status: 'error',
+ message: 'There was an issue verifying your payment. Please contact support if you believe this is an error.'
+ });
+ }
+ }
+
+ verifyCheckoutSession();
+ }, [sessionId]);
+
+ return (
+
+
+ {state.status === 'loading' ? (
+
+
+
+ Verifying your payment...
+
+
+ ) : state.status === 'success' ? (
+ <>
+
+
+
+
+
+ Payment Successful!
+
+
+
+ {state.message}
+
+
+
+
+
+ Your account will be updated shortly with your new plan benefits.
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ Verification Issue
+
+
+
+ {state.message}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/[locale]/(marketing)/pricing/page.tsx b/src/app/[locale]/(marketing)/pricing/page.tsx
index 322d8ff..805c020 100644
--- a/src/app/[locale]/(marketing)/pricing/page.tsx
+++ b/src/app/[locale]/(marketing)/pricing/page.tsx
@@ -1,10 +1,11 @@
-import Pricing3 from '@/components/blocks/pricing/pricing-3';
-import PricingComparator from '@/components/blocks/pricing/pricing-comparator';
import { constructMetadata } from '@/lib/metadata';
import { getBaseUrlWithLocale } from '@/lib/urls/get-base-url';
import { Metadata } from 'next';
import { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
+import { getAllPlans } from '@/payment';
+import { PricingTable } from '@/components/payment/pricing-table';
+import Container from '@/components/container';
export async function generateMetadata({
params,
@@ -30,13 +31,65 @@ export default async function PricingPage(props: PricingPageProps) {
const { locale } = params;
const t = await getTranslations('PricingPage');
- return (
- <>
-
-
+ // Get all plans as an array
+ const plans = getAllPlans();
-
+ return (
+
+
+
+
+
+ Simple, transparent pricing
+
+
+ Choose the plan that works best for you. All plans include core features, unlimited updates, and email support.
+
+
+
+
+
+
+
+ Frequently Asked Questions
+
+
+
+
+ Can I upgrade or downgrade my plan?
+
+
+ Yes, you can change your plan at any time. When upgrading, you'll be charged the prorated difference. When downgrading, the new rate will apply at the start of your next billing cycle.
+
+
+
+
+ Do you offer a free trial?
+
+
+ Yes, we offer a free trial for all our subscription plans. You won't be charged until your trial period ends, and you can cancel anytime.
+
+
+
+
+ How does billing work?
+
+
+ For subscription plans, you'll be billed monthly or yearly depending on your selection. For lifetime access, you'll be charged a one-time fee and never have to pay again.
+
+
+
+
+ Can I cancel my subscription?
+
+
+ Yes, you can cancel your subscription at any time from your account settings. After cancellation, your plan will remain active until the end of your current billing period.
+
+
+
+
+
- >
+
);
}
diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..59d3a30
--- /dev/null
+++ b/src/app/api/webhooks/stripe/route.ts
@@ -0,0 +1,57 @@
+import { handleWebhookEvent } from '@/payment';
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Disable body parsing as we need the raw body for signature verification
+ */
+export const config = {
+ api: {
+ bodyParser: false,
+ },
+};
+
+/**
+ * Stripe webhook handler
+ * This endpoint receives webhook events from Stripe and processes them
+ *
+ * @param req The incoming request
+ * @returns NextResponse
+ */
+export async function POST(req: NextRequest): Promise {
+ // Get the request body as text
+ const payload = await req.text();
+
+ // Get the Stripe signature from headers
+ const signature = req.headers.get('stripe-signature') || '';
+
+ try {
+ // Validate inputs
+ if (!payload) {
+ return NextResponse.json(
+ { error: 'Missing webhook payload' },
+ { status: 400 }
+ );
+ }
+
+ if (!signature) {
+ return NextResponse.json(
+ { error: 'Missing Stripe signature' },
+ { status: 400 }
+ );
+ }
+
+ // Process the webhook event
+ await handleWebhookEvent(payload, signature);
+
+ // Return success
+ return NextResponse.json({ received: true }, { status: 200 });
+ } catch (error) {
+ console.error('Error in webhook route:', error);
+
+ // Return error
+ return NextResponse.json(
+ { error: 'Webhook handler failed' },
+ { status: 400 }
+ );
+ }
+}
diff --git a/src/components/payment/checkout-button.tsx b/src/components/payment/checkout-button.tsx
new file mode 100644
index 0000000..0472ee5
--- /dev/null
+++ b/src/components/payment/checkout-button.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { createCheckoutAction } from '@/actions/payment';
+import { Button } from '@/components/ui/button';
+import { Loader2 } from 'lucide-react';
+import { useState } from 'react';
+
+interface CheckoutButtonProps {
+ planId: string;
+ priceId: string;
+ email?: string;
+ metadata?: Record;
+ variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost' | 'link' | null;
+ size?: 'default' | 'sm' | 'lg' | 'icon' | null;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * Checkout Button
+ *
+ * This client component creates a Stripe checkout session and redirects to it
+ * It's used to initiate the checkout process for a specific plan and price
+ */
+export function CheckoutButton({
+ planId,
+ priceId,
+ email,
+ metadata,
+ variant = 'default',
+ size = 'default',
+ className,
+ children,
+}: CheckoutButtonProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = async () => {
+ try {
+ setIsLoading(true);
+
+ // Create checkout session using server action
+ const result = await createCheckoutAction({
+ planId,
+ priceId,
+ email,
+ metadata,
+ });
+
+ // Redirect to checkout
+ if (result && result.data?.success && result.data.data?.url) {
+ window.location.href = result.data.data?.url;
+ }
+ // TODO: Handle error
+ } catch (error) {
+ console.error('Error creating checkout session:', error);
+ setIsLoading(false);
+ // Here you could display an error notification
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/payment/customer-portal-button.tsx b/src/components/payment/customer-portal-button.tsx
new file mode 100644
index 0000000..80596ce
--- /dev/null
+++ b/src/components/payment/customer-portal-button.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { createPortalAction } from '@/actions/payment';
+import { Button } from '@/components/ui/button';
+import { Loader2 } from 'lucide-react';
+import { useState } from 'react';
+
+interface CustomerPortalButtonProps {
+ customerId: string;
+ returnUrl?: string;
+ variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost' | 'link' | null;
+ size?: 'default' | 'sm' | 'lg' | 'icon' | null;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * Customer Portal Button
+ *
+ * This client component opens the Stripe customer portal
+ * It's used to let customers manage their billing, subscriptions, and payment methods
+ */
+export function CustomerPortalButton({
+ customerId,
+ returnUrl,
+ variant = 'outline',
+ size = 'default',
+ className,
+ children,
+}: CustomerPortalButtonProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = async () => {
+ try {
+ setIsLoading(true);
+
+ // Create customer portal session using server action
+ const result = await createPortalAction({
+ customerId,
+ returnUrl,
+ });
+
+ // Redirect to customer portal
+ if (result && result.data?.success && result.data.data?.url) {
+ window.location.href = result.data.data?.url;
+ }
+
+ // TODO: Handle error
+ } catch (error) {
+ console.error('Error creating customer portal:', error);
+ setIsLoading(false);
+ // Here you could display an error notification
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/payment/pricing-card.tsx b/src/components/payment/pricing-card.tsx
new file mode 100644
index 0000000..3712d6f
--- /dev/null
+++ b/src/components/payment/pricing-card.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { Check } from 'lucide-react';
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
+import { PlanInterval, PricePlan, Price, PaymentType } from '@/payment/types';
+import { CheckoutButton } from './checkout-button';
+import { cn } from '@/lib/utils';
+
+interface PricingCardProps {
+ plan: PricePlan;
+ interval: PlanInterval;
+ paymentType?: PaymentType; // 'recurring' or 'one_time'
+ email?: string;
+ metadata?: Record;
+ className?: string;
+ isCurrentPlan?: boolean;
+}
+
+/**
+ * Format a price for display
+ * @param price Price amount in currency units (dollars, euros, etc.)
+ * @param currency Currency code
+ * @returns Formatted price string
+ */
+function formatPrice(price: number | undefined, currency: string): string {
+ if (price === undefined) {
+ return 'Free';
+ }
+
+ const formatter = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 0,
+ });
+
+ return formatter.format(price / 100); // Convert from cents to dollars
+}
+
+/**
+ * Get the appropriate price object for the selected interval and payment type
+ * @param plan The price plan
+ * @param interval The selected interval (month or year)
+ * @param paymentType The payment type (recurring or one_time)
+ * @returns The price object or undefined if not found
+ */
+function getPriceForPlan(
+ plan: PricePlan,
+ interval: PlanInterval,
+ paymentType: PaymentType = 'recurring'
+): Price | undefined {
+ if (plan.isFree) {
+ return undefined;
+ }
+
+ return plan.prices.find(price => {
+ if (paymentType === 'one_time') {
+ return price.type === 'one_time';
+ }
+ return price.type === 'recurring' && price.interval === interval;
+ });
+}
+
+/**
+ * Pricing Card Component
+ *
+ * Displays a single pricing plan with features and action button
+ */
+export function PricingCard({
+ plan,
+ interval,
+ paymentType = 'recurring',
+ email,
+ metadata,
+ className,
+ isCurrentPlan = false,
+}: PricingCardProps) {
+ const price = getPriceForPlan(plan, interval, paymentType);
+ const formattedPrice = plan.isFree ? 'Free' : price ? formatPrice(price.amount, price.currency) : 'Not Available';
+
+ // Generate pricing label based on payment type and interval
+ let priceLabel = '';
+ if (!plan.isFree && price) {
+ if (paymentType === 'one_time') {
+ priceLabel = 'lifetime';
+ } else if (interval === 'month') {
+ priceLabel = '/month';
+ } else if (interval === 'year') {
+ priceLabel = '/year';
+ }
+ }
+
+ const isPlanAvailable = plan.isFree || !!price;
+ const hasTrialPeriod = price?.trialPeriodDays && price.trialPeriodDays > 0;
+
+ return (
+
+
+ {plan.recommended && (
+