commit
50af2e726c
@ -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",
|
||||
|
@ -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": "通知",
|
||||
|
@ -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
|
||||
@ -37,6 +41,7 @@ export const contactAction = actionClient
|
||||
|
||||
// Send email using the mail service
|
||||
// Customize the email template for your needs
|
||||
// TODO: add locale to the email or customize it by yourself?
|
||||
const result = await send({
|
||||
to: websiteConfig.mail.to,
|
||||
subject: `Contact Form: Message from ${name}`,
|
||||
|
109
src/actions/payment.ts
Normal file
109
src/actions/payment.ts
Normal file
@ -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',
|
||||
};
|
||||
}
|
||||
});
|
@ -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 (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
|
||||
<div className="px-4 py-8">
|
||||
<BillingCard />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
51
src/app/[locale]/(marketing)/payment/cancel/page.tsx
Normal file
51
src/app/[locale]/(marketing)/payment/cancel/page.tsx
Normal file
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4 py-12">
|
||||
<div className="w-full max-w-md p-8 space-y-4 bg-white border rounded-lg shadow-md dark:bg-gray-950 dark:border-gray-800">
|
||||
<div className="flex justify-center">
|
||||
<XCircle className="w-16 h-16 text-red-500" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white">
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
Your payment process was cancelled. No charges were made to your account.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="py-4 text-center text-sm bg-gray-50 dark:bg-gray-900 rounded-md">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
If you encountered any issues during checkout, please contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center justify-center px-6 py-2 font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Try Again
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center px-6 py-2 font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
Get Help
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
125
src/app/[locale]/(marketing)/payment/success/page.tsx
Normal file
125
src/app/[locale]/(marketing)/payment/success/page.tsx
Normal file
@ -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<PaymentSuccessState>({ 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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4 py-12">
|
||||
<div className="w-full max-w-md p-8 space-y-4 bg-white border rounded-lg shadow-md dark:bg-gray-950 dark:border-gray-800">
|
||||
{state.status === 'loading' ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-16 h-16 text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
Verifying your payment...
|
||||
</p>
|
||||
</div>
|
||||
) : state.status === 'success' ? (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white">
|
||||
Payment Successful!
|
||||
</h1>
|
||||
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
{state.message}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="py-4 text-center text-sm bg-gray-50 dark:bg-gray-900 rounded-md">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your account will be updated shortly with your new plan benefits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
passHref
|
||||
>
|
||||
<Button>Go to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center text-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white">
|
||||
Verification Issue
|
||||
</h1>
|
||||
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
{state.message}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Link
|
||||
href="/contact"
|
||||
passHref
|
||||
>
|
||||
<Button variant="outline">Contact Support</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
<div className="mt-8 flex flex-col gap-16 pb-16">
|
||||
<Pricing3 />
|
||||
// Get all plans as an array
|
||||
const plans = getAllPlans();
|
||||
|
||||
<PricingComparator />
|
||||
return (
|
||||
<Container>
|
||||
<div className="mt-8 flex flex-col gap-16 pb-16 px-4">
|
||||
<div className="py-12 w-full">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Simple, transparent pricing
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mx-auto">
|
||||
Choose the plan that works best for you. All plans include core features, unlimited updates, and email support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingTable plans={plans} />
|
||||
|
||||
<div className="mt-16 bg-gray-50 dark:bg-gray-900 rounded-lg p-6 md:p-8">
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-4">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg mb-2">
|
||||
Can I upgrade or downgrade my plan?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg mb-2">
|
||||
Do you offer a free trial?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg mb-2">
|
||||
How does billing work?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg mb-2">
|
||||
Can I cancel my subscription?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
57
src/app/api/webhooks/stripe/route.ts
Normal file
57
src/app/api/webhooks/stripe/route.ts
Normal file
@ -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<NextResponse> {
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
79
src/components/payment/checkout-button.tsx
Normal file
79
src/components/payment/checkout-button.tsx
Normal file
@ -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<string, string>;
|
||||
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 (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
74
src/components/payment/customer-portal-button.tsx
Normal file
74
src/components/payment/customer-portal-button.tsx
Normal file
@ -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 (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
children || 'Manage Billing'
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
178
src/components/payment/pricing-card.tsx
Normal file
178
src/components/payment/pricing-card.tsx
Normal file
@ -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<string, string>;
|
||||
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 (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex flex-col h-full overflow-hidden transition-all",
|
||||
plan.recommended && "border-blue-500 shadow-lg shadow-blue-100 dark:shadow-blue-900/20",
|
||||
isCurrentPlan && "border-green-500 shadow-lg shadow-green-100 dark:shadow-green-900/20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
{plan.recommended && (
|
||||
<div className="px-3 py-1 text-xs font-semibold text-white bg-blue-500 rounded-full w-fit mb-3">
|
||||
Recommended
|
||||
</div>
|
||||
)}
|
||||
{isCurrentPlan && (
|
||||
<div className="px-3 py-1 text-xs font-semibold text-white bg-green-500 rounded-full w-fit mb-3">
|
||||
Current Plan
|
||||
</div>
|
||||
)}
|
||||
<CardTitle className="text-xl">{plan.name}</CardTitle>
|
||||
<CardDescription className="min-h-12">{plan.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<div className="mb-3">
|
||||
<span className="text-3xl font-bold">{formattedPrice}</span>
|
||||
{priceLabel && (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-1">
|
||||
{priceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTrialPeriod && (
|
||||
<div className="mb-4">
|
||||
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded">
|
||||
{price.trialPeriodDays}-day free trial
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3 mb-6">
|
||||
{plan.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-start">
|
||||
<Check className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" />
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{plan.isFree ? (
|
||||
<div className="w-full">
|
||||
<div className="px-3 py-2 text-sm text-center bg-gray-100 dark:bg-gray-800 rounded-md w-full">
|
||||
Free Plan - No Payment Required
|
||||
</div>
|
||||
</div>
|
||||
) : isCurrentPlan ? (
|
||||
<div className="w-full">
|
||||
<div className="px-3 py-2 text-sm text-center bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-md w-full">
|
||||
Your Current Plan
|
||||
</div>
|
||||
</div>
|
||||
) : isPlanAvailable && price ? (
|
||||
<CheckoutButton
|
||||
planId={plan.id}
|
||||
priceId={price.productId}
|
||||
email={email}
|
||||
metadata={metadata}
|
||||
className="w-full"
|
||||
>
|
||||
{paymentType === 'one_time' ? 'Purchase Now' : 'Subscribe Now'}
|
||||
</CheckoutButton>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="px-3 py-2 text-sm text-center bg-gray-100 dark:bg-gray-800 rounded-md w-full">
|
||||
Not Available
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
136
src/components/payment/pricing-table.tsx
Normal file
136
src/components/payment/pricing-table.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { PricePlan } from '@/payment/types';
|
||||
import { PricingCard } from './pricing-card';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
|
||||
interface PricingTableProps {
|
||||
plans: PricePlan[];
|
||||
email?: string;
|
||||
metadata?: Record<string, string>;
|
||||
currentPlanId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing Table Component
|
||||
*
|
||||
* Displays all pricing plans with interval selection tabs for subscription plans
|
||||
* Free plans and one-time purchase plans are always displayed
|
||||
*/
|
||||
export function PricingTable({
|
||||
plans,
|
||||
email,
|
||||
metadata,
|
||||
currentPlanId,
|
||||
className,
|
||||
}: PricingTableProps) {
|
||||
const [interval, setInterval] = useState<'month' | 'year'>('month');
|
||||
|
||||
// Filter plans into free, subscription and one-time plans
|
||||
const freePlans = plans.filter(plan => plan.isFree);
|
||||
|
||||
const subscriptionPlans = plans.filter(plan =>
|
||||
!plan.isFree && plan.prices.some(price => price.type === 'recurring')
|
||||
);
|
||||
|
||||
const oneTimePlans = plans.filter(plan =>
|
||||
!plan.isFree && plan.prices.some(price => price.type === 'one_time')
|
||||
);
|
||||
|
||||
// Check if any plan has a monthly price option
|
||||
const hasMonthlyOption = subscriptionPlans.some(plan =>
|
||||
plan.prices.some(price => price.type === 'recurring' && price.interval === 'month')
|
||||
);
|
||||
|
||||
// Check if any plan has a yearly price option
|
||||
const hasYearlyOption = subscriptionPlans.some(plan =>
|
||||
plan.prices.some(price => price.type === 'recurring' && price.interval === 'year')
|
||||
);
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
setInterval(value as 'month' | 'year');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{(hasMonthlyOption || hasYearlyOption) && subscriptionPlans.length > 0 && (
|
||||
<div className="flex justify-center mb-8">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={interval}
|
||||
onValueChange={(value) => value && handleIntervalChange(value)}
|
||||
className="border rounded-lg p-1"
|
||||
>
|
||||
{hasMonthlyOption && (
|
||||
<ToggleGroupItem value="month" className="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground px-4 py-2">
|
||||
Monthly
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{hasYearlyOption && (
|
||||
<ToggleGroupItem value="year" className="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground px-4 py-2">
|
||||
Yearly
|
||||
<span className="ml-1 bg-green-100 text-green-800 text-xs px-2 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
Save 25%
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Render free plans (always visible) */}
|
||||
{freePlans.map((plan) => (
|
||||
<PricingCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
email={email}
|
||||
metadata={metadata}
|
||||
isCurrentPlan={currentPlanId === plan.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render subscription plans with the selected interval */}
|
||||
{subscriptionPlans.map((plan) => (
|
||||
<PricingCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
paymentType="recurring"
|
||||
email={email}
|
||||
metadata={metadata}
|
||||
isCurrentPlan={currentPlanId === plan.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render one-time plans (always visible) */}
|
||||
{oneTimePlans.map((plan) => (
|
||||
<PricingCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
paymentType="one_time"
|
||||
email={email}
|
||||
metadata={metadata}
|
||||
isCurrentPlan={currentPlanId === plan.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<h3 className="text-lg font-medium mb-4">Need a custom plan?</h3>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-flex items-center text-primary hover:underline"
|
||||
>
|
||||
Contact us for custom pricing
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
429
src/components/settings/billing/billing-card.tsx
Normal file
429
src/components/settings/billing/billing-card.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CustomerPortalButton } from '@/components/payment/customer-portal-button';
|
||||
import { CheckoutButton } from '@/components/payment/checkout-button';
|
||||
import { getAllPlans } from '@/payment';
|
||||
import { PricePlan } from '@/payment/types';
|
||||
|
||||
// Utility function to format prices
|
||||
const formatPrice = (amount: number, currency: string = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
// Mock user data - in a real app, this would come from your auth system
|
||||
const mockUser = {
|
||||
id: 'user_123',
|
||||
email: 'user@example.com',
|
||||
customerId: 'cus_mock123', // Stripe customer ID
|
||||
name: 'John Doe',
|
||||
};
|
||||
|
||||
// Mock subscription data
|
||||
const mockSubscription = {
|
||||
id: 'sub_mock123',
|
||||
status: 'active' as const,
|
||||
planId: 'pro',
|
||||
priceId: 'price_mock123',
|
||||
interval: 'month' as const,
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
};
|
||||
|
||||
// Mock trial subscription data
|
||||
const mockTrialSubscription = {
|
||||
id: 'sub_mocktrial123',
|
||||
status: 'trialing' as const,
|
||||
planId: 'pro',
|
||||
priceId: 'price_mocktrial123',
|
||||
interval: 'month' as const,
|
||||
currentPeriodEnd: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days from now
|
||||
};
|
||||
|
||||
// Helper function to check if a plan is an enterprise plan based on metadata
|
||||
const isEnterprisePlan = (plan: PricePlan): boolean => {
|
||||
return plan.id === 'enterprise' || plan.name.toLowerCase().includes('enterprise');
|
||||
};
|
||||
|
||||
export default function BillingCard() {
|
||||
const t = useTranslations('Dashboard.sidebar.settings.items.billing');
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [billingData, setBillingData] = useState<{
|
||||
subscription: typeof mockSubscription | typeof mockTrialSubscription | null;
|
||||
user: typeof mockUser;
|
||||
}>({
|
||||
subscription: null,
|
||||
user: mockUser,
|
||||
});
|
||||
|
||||
// Simulate fetching billing data
|
||||
useEffect(() => {
|
||||
const fetchBillingData = async () => {
|
||||
// In a real app, you would fetch this data from an API endpoint or server action
|
||||
// Example: const { data } = await getUserBillingData();
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Randomly select between different subscription states
|
||||
const random = Math.random();
|
||||
let subscription = null;
|
||||
if (random < 0.33) {
|
||||
subscription = mockSubscription;
|
||||
} else if (random < 0.66) {
|
||||
subscription = mockTrialSubscription;
|
||||
}
|
||||
|
||||
setBillingData({
|
||||
user: mockUser,
|
||||
subscription,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchBillingData();
|
||||
}, []);
|
||||
|
||||
// Get all available plans
|
||||
const plans = getAllPlans();
|
||||
|
||||
// Find current plan details if subscription exists
|
||||
const currentPlan = billingData.subscription
|
||||
? plans.find(plan => plan.id === billingData.subscription?.planId)
|
||||
: plans.find(plan => plan.isFree);
|
||||
|
||||
// Determine current price details if subscription exists
|
||||
const currentPrice = billingData.subscription && currentPlan?.prices.find(
|
||||
price => price.productId === billingData.subscription?.priceId
|
||||
);
|
||||
|
||||
// Calculate next billing date
|
||||
const nextBillingDate = billingData.subscription?.currentPeriodEnd
|
||||
? new Intl.DateTimeFormat('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(billingData.subscription.currentPeriodEnd)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8">
|
||||
<div className="max-w-5xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('title', {defaultValue: 'Billing & Subscription'})}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('description', {defaultValue: 'Manage your subscription and billing information'})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{/* Current Plan Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('currentPlan.title', {defaultValue: 'Current Plan'})}</CardTitle>
|
||||
<CardDescription>{t('currentPlan.description', {defaultValue: 'Your current subscription details'})}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{currentPlan?.name}</div>
|
||||
<Badge variant={currentPlan?.isFree ? 'outline' : 'default'}>
|
||||
{billingData.subscription?.status === 'active' ?
|
||||
t('status.active', {defaultValue: 'Active'}) :
|
||||
billingData.subscription?.status === 'trialing' ?
|
||||
t('status.trial', {defaultValue: 'Trial'}) :
|
||||
t('status.free', {defaultValue: 'Free'})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{billingData.subscription && currentPrice && (
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>
|
||||
{formatPrice(currentPrice.amount, currentPrice.currency)} / {currentPrice.interval === 'month' ?
|
||||
t('interval.month', {defaultValue: 'month'}) :
|
||||
currentPrice.interval === 'year' ?
|
||||
t('interval.year', {defaultValue: 'year'}) :
|
||||
t('interval.oneTime', {defaultValue: 'one-time'})}
|
||||
</div>
|
||||
|
||||
{nextBillingDate && (
|
||||
<div>{t('nextBillingDate', {defaultValue: 'Next billing date:'})} {nextBillingDate}</div>
|
||||
)}
|
||||
|
||||
{billingData.subscription.status === 'trialing' && (
|
||||
<div className="text-amber-500">
|
||||
{t('trialEnds', {defaultValue: 'Trial ends:'})} {new Intl.DateTimeFormat('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(billingData.subscription.currentPeriodEnd)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPlan?.isFree && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('freePlanMessage', {defaultValue: 'You are currently on the free plan with limited features.'})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{loading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : billingData.subscription ? (
|
||||
<CustomerPortalButton
|
||||
customerId={billingData.user.customerId}
|
||||
className="w-full"
|
||||
>
|
||||
{t('manageSubscription', {defaultValue: 'Manage Subscription'})}
|
||||
</CustomerPortalButton>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('upgradeMessage', {defaultValue: 'Upgrade to a paid plan to access more features'})}
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Payment Method Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('paymentMethod.title', {defaultValue: 'Payment Method'})}</CardTitle>
|
||||
<CardDescription>{t('paymentMethod.description', {defaultValue: 'Manage your payment methods'})}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : billingData.subscription ? (
|
||||
<div className="text-sm">
|
||||
<p>{t('paymentMethod.manageMessage', {defaultValue: 'Manage your payment methods through the Stripe Customer Portal.'})}</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t('paymentMethod.securityMessage', {defaultValue: 'You can add, remove, or update your payment methods securely through the Stripe portal.'})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<p>{t('paymentMethod.noMethodsMessage', {defaultValue: 'No payment methods on file.'})}</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t('paymentMethod.upgradePromptMessage', {defaultValue: 'You\'ll be prompted to add a payment method when upgrading to a paid plan.'})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{loading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : billingData.subscription ? (
|
||||
<CustomerPortalButton
|
||||
customerId={billingData.user.customerId}
|
||||
className="w-full"
|
||||
>
|
||||
{t('managePaymentMethods', {defaultValue: 'Manage Payment Methods'})}
|
||||
</CustomerPortalButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Options */}
|
||||
{!loading && !billingData.subscription && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('upgradePlan.title', {defaultValue: 'Upgrade Your Plan'})}</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('upgradePlan.description', {defaultValue: 'Choose a plan that works for you'})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{plans
|
||||
.filter(plan => !plan.isFree && !isEnterprisePlan(plan))
|
||||
.map(plan => {
|
||||
// Get monthly price if available, otherwise first price
|
||||
const price = plan.prices.find(p => p.type === 'recurring' && p.interval === 'month') || plan.prices[0];
|
||||
if (!price) return null;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>{plan.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<div className="mb-4">
|
||||
<span className="text-3xl font-bold">
|
||||
{formatPrice(price.amount, price.currency)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{price.interval === 'month' ?
|
||||
`/${t('interval.month', {defaultValue: 'month'})}` :
|
||||
price.interval === 'year' ?
|
||||
`/${t('interval.year', {defaultValue: 'year'})}` :
|
||||
''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{price.trialPeriodDays && price.trialPeriodDays > 0 && (
|
||||
<Badge variant="outline" className="mb-4">
|
||||
{t('trialDays', {
|
||||
defaultValue: '{{days}} day trial',
|
||||
days: price.trialPeriodDays
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary flex-shrink-0 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<CheckoutButton
|
||||
planId={plan.id}
|
||||
priceId={price.productId}
|
||||
email={billingData.user.email}
|
||||
metadata={{ userId: billingData.user.id }}
|
||||
className="w-full"
|
||||
>
|
||||
{t('upgradeToPlan', {
|
||||
defaultValue: 'Upgrade to {{planName}}',
|
||||
planName: plan.name
|
||||
})}
|
||||
</CheckoutButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
{plans
|
||||
.filter(plan => isEnterprisePlan(plan))
|
||||
.map(plan => (
|
||||
<Card key={plan.id} className="bg-muted/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>{plan.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<ul className="space-y-2 mb-6 md:mb-0">
|
||||
{plan.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary flex-shrink-0 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col items-start md:items-end">
|
||||
<span className="text-xl font-bold mb-2">{t('customPricing', {defaultValue: 'Custom Pricing'})}</span>
|
||||
<Button className="w-full md:w-auto" variant="default" asChild>
|
||||
<a href="mailto:sales@yourcompany.com">{t('contactSales', {defaultValue: 'Contact Sales'})}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing History */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('billingHistory.title', {defaultValue: 'Billing History'})}</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('billingHistory.description', {defaultValue: 'View and download your past invoices'})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
) : billingData.subscription ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('billingHistory.accessMessage', {defaultValue: 'Access your billing history through the Stripe Customer Portal'})}
|
||||
</p>
|
||||
<CustomerPortalButton
|
||||
customerId={billingData.user.customerId}
|
||||
className="mt-4"
|
||||
>
|
||||
{t('viewBillingHistory', {defaultValue: 'View Billing History'})}
|
||||
</CustomerPortalButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('billingHistory.noHistoryMessage', {defaultValue: 'No billing history available'})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import db from '@/db/index';
|
||||
import { account, session, user, verification } from '@/db/schema';
|
||||
import { defaultMessages } from '@/i18n/messages';
|
||||
import { getLocaleFromRequest } from '@/lib/utils';
|
||||
import { getLocaleFromRequest, addLocaleToUrl } from '@/lib/utils';
|
||||
import { send } from '@/mail';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
@ -48,12 +48,17 @@ export const auth = betterAuth({
|
||||
// https://www.better-auth.com/docs/authentication/email-password#forget-password
|
||||
async sendResetPassword({ user, url }, request) {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
// TODO: add locale to url
|
||||
// console.log('[Auth] Reset password original URL:', url);
|
||||
|
||||
// Add locale to URL if necessary
|
||||
const localizedUrl = addLocaleToUrl(url, locale);
|
||||
// console.log('[Auth] Reset password localized URL:', localizedUrl);
|
||||
|
||||
await send({
|
||||
to: user.email,
|
||||
template: 'forgotPassword',
|
||||
context: {
|
||||
url,
|
||||
url: localizedUrl,
|
||||
name: user.name,
|
||||
},
|
||||
locale,
|
||||
@ -66,12 +71,17 @@ export const auth = betterAuth({
|
||||
// https://www.better-auth.com/docs/authentication/email-password#require-email-verification
|
||||
sendVerificationEmail: async ({ user, url, token }, request) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
// TODO: add locale to url
|
||||
// console.log('[Auth] Verification email original URL:', url);
|
||||
|
||||
// Add locale to URL if necessary
|
||||
const localizedUrl = addLocaleToUrl(url, locale);
|
||||
// console.log('[Auth] Verification email localized URL:', localizedUrl);
|
||||
|
||||
await send({
|
||||
to: user.email,
|
||||
template: 'verifyEmail',
|
||||
context: {
|
||||
url,
|
||||
url: localizedUrl,
|
||||
name: user.name,
|
||||
},
|
||||
locale,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Locale } from "next-intl";
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ??
|
||||
@ -9,10 +10,10 @@ export function getBaseUrl(): string {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
export function shouldAppendLocale(locale?: string | null): boolean {
|
||||
export function shouldAppendLocale(locale?: Locale | null): boolean {
|
||||
return !!locale && locale !== routing.defaultLocale && locale !== 'default';
|
||||
}
|
||||
|
||||
export function getBaseUrlWithLocale(locale?: string | null): string {
|
||||
export function getBaseUrlWithLocale(locale?: Locale | null): string {
|
||||
return shouldAppendLocale(locale) ? `${baseUrl}/${locale}` : baseUrl;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { LOCALE_COOKIE_NAME, routing } from '@/i18n/routing';
|
||||
import { shouldAppendLocale } from '@/lib/urls/get-base-url';
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { parse as parseCookies } from 'cookie';
|
||||
import { Locale } from 'next-intl';
|
||||
@ -75,3 +76,51 @@ export function estimateReadingTime(
|
||||
const minutes = Math.ceil(words / wordsPerMinute);
|
||||
return minutes === 1 ? '1 minute read' : `${minutes} minutes read`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds locale to the callbackURL parameter in authentication URLs
|
||||
*
|
||||
* Example:
|
||||
* Input: http://localhost:3000/api/auth/reset-password/token?callbackURL=/auth/reset-password
|
||||
* Output: http://localhost:3000/api/auth/reset-password/token?callbackURL=/zh/auth/reset-password
|
||||
*
|
||||
* http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
|
||||
* Output: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/zh/dashboard
|
||||
*
|
||||
* @param url - The original URL with callbackURL parameter
|
||||
* @param locale - The locale to add to the callbackURL
|
||||
* @returns The URL with locale added to callbackURL if necessary
|
||||
*/
|
||||
export function addLocaleToUrl(url: string, locale: Locale): string {
|
||||
// If we shouldn't append locale, return original URL
|
||||
if (!shouldAppendLocale(locale)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the URL
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Check if there's a callbackURL parameter
|
||||
const callbackURL = urlObj.searchParams.get('callbackURL');
|
||||
|
||||
if (callbackURL) {
|
||||
// Only modify the callbackURL if it doesn't already include the locale
|
||||
if (!callbackURL.match(new RegExp(`^/${locale}(/|$)`))) {
|
||||
// Add locale to the callbackURL
|
||||
const localizedCallbackURL = callbackURL.startsWith('/')
|
||||
? `/${locale}${callbackURL}`
|
||||
: `/${locale}/${callbackURL}`;
|
||||
|
||||
// Update the search parameter
|
||||
urlObj.searchParams.set('callbackURL', localizedCallbackURL);
|
||||
}
|
||||
}
|
||||
|
||||
return urlObj.toString();
|
||||
} catch (e) {
|
||||
// If URL parsing fails, return the original URL
|
||||
console.warn('Failed to parse URL for locale insertion:', url, e);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
183
src/mail/README.md
Normal file
183
src/mail/README.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Email System
|
||||
|
||||
This module provides email functionality for the application. It supports sending emails using templates or raw content through different email providers.
|
||||
|
||||
## Structure
|
||||
|
||||
The email system is designed with the following components:
|
||||
|
||||
- **Provider Interface**: A common interface for email providers
|
||||
- **Email Templates**: React-based email templates for different purposes
|
||||
- **Configuration**: Configuration for email defaults and settings
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { send } from '@/mail';
|
||||
|
||||
// Send using a template
|
||||
await send({
|
||||
to: 'user@example.com',
|
||||
template: 'verifyEmail',
|
||||
context: {
|
||||
name: 'John Doe',
|
||||
url: 'https://example.com/verify?token=abc123',
|
||||
},
|
||||
locale: 'en', // Optional, defaults to config default locale
|
||||
});
|
||||
|
||||
// Send a raw email
|
||||
await send({
|
||||
to: 'user@example.com',
|
||||
subject: 'Welcome to our platform',
|
||||
html: '<h1>Hello!</h1><p>Welcome to our platform.</p>',
|
||||
text: 'Hello! Welcome to our platform.', // Optional
|
||||
});
|
||||
```
|
||||
|
||||
### Using the Mail Provider Directly
|
||||
|
||||
```typescript
|
||||
import { getMailProvider, sendTemplate, sendRawEmail } from '@/mail';
|
||||
|
||||
// Get the provider
|
||||
const provider = getMailProvider();
|
||||
|
||||
// Send template email
|
||||
const result = await sendTemplate({
|
||||
to: 'user@example.com',
|
||||
template: 'welcomeEmail',
|
||||
context: {
|
||||
name: 'John Doe',
|
||||
},
|
||||
});
|
||||
|
||||
// Check result
|
||||
if (result.success) {
|
||||
console.log('Email sent successfully!', result.messageId);
|
||||
} else {
|
||||
console.error('Failed to send email:', result.error);
|
||||
}
|
||||
|
||||
// Send raw email
|
||||
await sendRawEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Raw email example',
|
||||
html: '<p>This is a raw email</p>',
|
||||
});
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are React components stored in the `emails` directory. Each template has specific props and is rendered to HTML/text when sent.
|
||||
|
||||
### Available Templates
|
||||
|
||||
- `verifyEmail`: For email verification
|
||||
- `forgotPassword`: For password reset
|
||||
- `subscribeNewsletter`: For new user subscribed
|
||||
|
||||
### Creating a New Template
|
||||
|
||||
1. Create a React component in the `emails` directory
|
||||
2. Make sure it accepts `BaseEmailProps` plus any specific props
|
||||
3. Add it to the `EmailTemplates` export in `emails/index.ts`
|
||||
4. Add corresponding subject translations in the i18n messages
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
// emails/MyNewEmail.tsx
|
||||
import { BaseEmailProps } from '@/mail/types';
|
||||
import { Body, Container, Head, Html, Text } from '@react-email/components';
|
||||
|
||||
interface MyNewEmailProps extends BaseEmailProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function MyNewEmail({ username, messages, locale }: MyNewEmailProps) {
|
||||
return (
|
||||
<Html lang={locale}>
|
||||
<Head />
|
||||
<Body>
|
||||
<Container>
|
||||
<Text>Hello {username}!</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then add it to `emails/index.ts`:
|
||||
|
||||
```typescript
|
||||
import MyNewEmail from './MyNewEmail';
|
||||
|
||||
export const EmailTemplates = {
|
||||
// ... existing templates
|
||||
myNewEmail: MyNewEmail,
|
||||
};
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The email system configuration is defined in `config/mail-config.ts`. It includes settings like:
|
||||
|
||||
- Default "from" email address
|
||||
- Default locale for emails
|
||||
|
||||
## Providers
|
||||
|
||||
### Resend
|
||||
|
||||
[Resend](https://resend.com/) is the default email provider. It requires an API key set as `RESEND_API_KEY` in your environment variables.
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
To add a new email provider:
|
||||
|
||||
1. Create a new file in the `provider` directory
|
||||
2. Implement the `MailProvider` interface
|
||||
3. Update the `initializeMailProvider` function in `index.ts` to use your new provider
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
// provider/my-provider.ts
|
||||
import { MailProvider, SendEmailResult, SendRawEmailParams, SendTemplateParams } from '@/mail/types';
|
||||
|
||||
export class MyProvider implements MailProvider {
|
||||
constructor() {
|
||||
// Initialize your provider
|
||||
}
|
||||
|
||||
public async sendTemplate(params: SendTemplateParams): Promise<SendEmailResult> {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
public async sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult> {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
public getProviderName(): string {
|
||||
return 'my-provider';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then update `index.ts`:
|
||||
|
||||
```typescript
|
||||
import { MyProvider } from './provider/my-provider';
|
||||
|
||||
export const initializeMailProvider = (): MailProvider => {
|
||||
if (!mailProvider) {
|
||||
// Select provider based on configuration or environment
|
||||
mailProvider = new MyProvider();
|
||||
}
|
||||
return mailProvider;
|
||||
};
|
||||
```
|
11
src/mail/config/mail-config.ts
Normal file
11
src/mail/config/mail-config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { websiteConfig } from '@/config';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { MailConfig } from '@/mail/types';
|
||||
|
||||
/**
|
||||
* Default mail configuration
|
||||
*/
|
||||
export const mailConfig: MailConfig = {
|
||||
defaultFromEmail: websiteConfig.mail.from || 'noreply@example.com',
|
||||
defaultLocale: routing.defaultLocale,
|
||||
};
|
@ -1,5 +1,74 @@
|
||||
// Export the send function for direct import
|
||||
export { send } from './mail';
|
||||
import { MailProvider, MailConfig, SendTemplateParams, SendRawEmailParams, SendEmailResult, Template } from './types';
|
||||
import { ResendProvider } from './provider/resend';
|
||||
import { mailConfig } from './config/mail-config';
|
||||
|
||||
// Export mail templates
|
||||
/**
|
||||
* Default mail configuration
|
||||
*/
|
||||
export const defaultMailConfig: MailConfig = mailConfig;
|
||||
|
||||
/**
|
||||
* Global mail provider instance
|
||||
*/
|
||||
let mailProvider: MailProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the mail provider
|
||||
* @returns initialized mail provider
|
||||
*/
|
||||
export const initializeMailProvider = (): MailProvider => {
|
||||
if (!mailProvider) {
|
||||
mailProvider = new ResendProvider();
|
||||
}
|
||||
return mailProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the mail provider
|
||||
* @returns current mail provider instance
|
||||
* @throws Error if provider is not initialized
|
||||
*/
|
||||
export const getMailProvider = (): MailProvider => {
|
||||
if (!mailProvider) {
|
||||
return initializeMailProvider();
|
||||
}
|
||||
return mailProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
* @param params Parameters for sending the templated email
|
||||
* @returns Send result
|
||||
*/
|
||||
export const sendTemplate = async (params: SendTemplateParams):
|
||||
Promise<SendEmailResult> => {
|
||||
const provider = getMailProvider();
|
||||
return provider.sendTemplate(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a raw email
|
||||
* @param params Parameters for sending the raw email
|
||||
* @returns Send result
|
||||
*/
|
||||
export const sendRawEmail = async (params: SendRawEmailParams):
|
||||
Promise<SendEmailResult> => {
|
||||
const provider = getMailProvider();
|
||||
return provider.sendRawEmail(params);
|
||||
};
|
||||
|
||||
// Export from mail.ts
|
||||
export { send, getTemplate } from './mail';
|
||||
|
||||
// Export email templates
|
||||
export { EmailTemplates } from './emails';
|
||||
|
||||
// Export types for convenience
|
||||
export type {
|
||||
MailProvider,
|
||||
MailConfig,
|
||||
SendTemplateParams,
|
||||
SendRawEmailParams,
|
||||
SendEmailResult,
|
||||
Template,
|
||||
};
|
||||
|
@ -1,89 +1,44 @@
|
||||
import { getMessagesForLocale } from '@/i18n/messages';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { getMailProvider } from '@/mail';
|
||||
import { EmailTemplates } from '@/mail/emails';
|
||||
import { sendEmail } from '@/mail/provider/resend';
|
||||
import { SendRawEmailParams, SendTemplateParams, Template } from '@/mail/types';
|
||||
import { render } from '@react-email/render';
|
||||
import { Locale, Messages } from 'next-intl';
|
||||
import { Template } from '@/mail/types';
|
||||
|
||||
/**
|
||||
* send email
|
||||
*
|
||||
* 1. with given template, and context
|
||||
* 2. with given subject, text, and html
|
||||
* Send email using the configured mail provider
|
||||
*
|
||||
* @param params Email parameters
|
||||
* @returns Success status
|
||||
*/
|
||||
export async function send<T extends Template>(
|
||||
params: {
|
||||
to: string;
|
||||
locale?: Locale;
|
||||
} & (
|
||||
| {
|
||||
template: T;
|
||||
context: Omit<
|
||||
Parameters<(typeof EmailTemplates)[T]>[0],
|
||||
'locale' | 'messages'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}
|
||||
)
|
||||
export async function send(
|
||||
params: SendTemplateParams | SendRawEmailParams
|
||||
) {
|
||||
const { to, locale = routing.defaultLocale } = params;
|
||||
console.log('send, locale:', locale);
|
||||
const provider = getMailProvider();
|
||||
|
||||
let html: string;
|
||||
let text: string;
|
||||
let subject: string;
|
||||
|
||||
// if template is provided, get the template
|
||||
// otherwise, use the subject, text, and html
|
||||
if ('template' in params) {
|
||||
const { template, context } = params;
|
||||
const mailTemplate = await getTemplate({
|
||||
template,
|
||||
context,
|
||||
locale,
|
||||
});
|
||||
subject = mailTemplate.subject;
|
||||
text = mailTemplate.text;
|
||||
html = mailTemplate.html;
|
||||
// This is a template email
|
||||
const result = await provider.sendTemplate(params);
|
||||
return result.success;
|
||||
} else {
|
||||
subject = params.subject;
|
||||
text = params.text ?? '';
|
||||
html = params.html ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Error sending email', e);
|
||||
return false;
|
||||
// This is a raw email
|
||||
const result = await provider.sendRawEmail(params);
|
||||
return result.success;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get rendered email for given template, context, and locale
|
||||
* Get rendered email for given template, context, and locale
|
||||
*/
|
||||
async function getTemplate<T extends Template>({
|
||||
export async function getTemplate<T extends Template>({
|
||||
template,
|
||||
context,
|
||||
locale,
|
||||
locale = routing.defaultLocale,
|
||||
}: {
|
||||
template: T;
|
||||
context: Omit<
|
||||
Parameters<(typeof EmailTemplates)[T]>[0],
|
||||
'locale' | 'messages'
|
||||
>;
|
||||
locale: Locale;
|
||||
context: Record<string, any>;
|
||||
locale?: Locale;
|
||||
}) {
|
||||
const mainTemplate = EmailTemplates[template];
|
||||
const messages = await getMessagesForLocale(locale);
|
||||
@ -94,7 +49,7 @@ async function getTemplate<T extends Template>({
|
||||
messages,
|
||||
});
|
||||
|
||||
// get the subject from the messages
|
||||
// Get the subject from the messages
|
||||
const subject =
|
||||
'subject' in messages.Mail[template as keyof Messages['Mail']]
|
||||
? messages.Mail[template].subject
|
||||
@ -102,5 +57,6 @@ async function getTemplate<T extends Template>({
|
||||
|
||||
const html = await render(email);
|
||||
const text = await render(email, { plainText: true });
|
||||
|
||||
return { html, text, subject };
|
||||
}
|
||||
|
@ -1,36 +1,115 @@
|
||||
import { websiteConfig } from '@/config';
|
||||
import { SendEmailHandler } from '@/mail/types';
|
||||
import { MailProvider, SendEmailResult, SendRawEmailParams, SendTemplateParams } from '@/mail/types';
|
||||
import { getTemplate } from '@/mail/mail';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY || 'test_api_key';
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
/**
|
||||
* https://resend.com/docs/send-with-nextjs
|
||||
* Resend mail provider implementation
|
||||
*/
|
||||
export const sendEmail: SendEmailHandler = async ({ to, subject, html }) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn('RESEND_API_KEY not set, skipping email send');
|
||||
return false;
|
||||
export class ResendProvider implements MailProvider {
|
||||
private resend: Resend;
|
||||
private from: string;
|
||||
|
||||
/**
|
||||
* Initialize Resend provider with API key
|
||||
*/
|
||||
constructor() {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error('RESEND_API_KEY environment variable is not set.');
|
||||
}
|
||||
|
||||
if (!websiteConfig.mail.from) {
|
||||
throw new Error('Default from email address is not set in websiteConfig.');
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
this.resend = new Resend(apiKey);
|
||||
this.from = websiteConfig.mail.from;
|
||||
}
|
||||
|
||||
if (!websiteConfig.mail.from || !to || !subject || !html) {
|
||||
console.warn('Missing required fields for email send', { from: websiteConfig.mail.from, to, subject, html });
|
||||
return false;
|
||||
/**
|
||||
* Send an email using a template
|
||||
* @param params Parameters for sending a templated email
|
||||
* @returns Send result
|
||||
*/
|
||||
public async sendTemplate(params: SendTemplateParams): Promise<SendEmailResult> {
|
||||
const { to, template, context, locale } = params;
|
||||
|
||||
try {
|
||||
// Get rendered template
|
||||
const mailTemplate = await getTemplate({
|
||||
template,
|
||||
context,
|
||||
locale,
|
||||
});
|
||||
|
||||
// Send using raw email
|
||||
return this.sendRawEmail({
|
||||
to,
|
||||
subject: mailTemplate.subject,
|
||||
html: mailTemplate.html,
|
||||
text: mailTemplate.text,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending template email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: websiteConfig.mail.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
/**
|
||||
* Send a raw email
|
||||
* @param params Parameters for sending a raw email
|
||||
* @returns Send result
|
||||
*/
|
||||
public async sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult> {
|
||||
const { to, subject, html, text } = params;
|
||||
|
||||
if (error) {
|
||||
console.error('Error sending email', error);
|
||||
return false;
|
||||
if (!this.from || !to || !subject || !html) {
|
||||
console.warn('Missing required fields for email send', { from: this.from, to, subject, html });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await this.resend.emails.send({
|
||||
from: this.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error sending email', error);
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data?.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* Get the provider name
|
||||
* @returns Provider name
|
||||
*/
|
||||
public getProviderName(): string {
|
||||
return 'resend';
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +1,84 @@
|
||||
import { Locale, Messages } from 'next-intl';
|
||||
import { EmailTemplates } from './emails';
|
||||
|
||||
/**
|
||||
* Base email component props
|
||||
*/
|
||||
export interface BaseEmailProps {
|
||||
locale: Locale;
|
||||
messages: Messages;
|
||||
}
|
||||
|
||||
export interface SendEmailProps {
|
||||
/**
|
||||
* Common email sending parameters
|
||||
*/
|
||||
export interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
html: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export type Template = keyof typeof EmailTemplates;
|
||||
|
||||
export type SendEmailHandler = (params: SendEmailProps) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Email provider, currently only Resend is supported
|
||||
* Result of sending an email
|
||||
*/
|
||||
export interface EmailProvider {
|
||||
send: SendEmailHandler;
|
||||
export interface SendEmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email template types
|
||||
*/
|
||||
export type Template = keyof typeof EmailTemplates;
|
||||
|
||||
/**
|
||||
* Parameters for sending an email using a template
|
||||
*/
|
||||
export interface SendTemplateParams {
|
||||
to: string;
|
||||
template: Template;
|
||||
context: Record<string, any>;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for sending a raw email
|
||||
*/
|
||||
export interface SendRawEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail provider configuration
|
||||
*/
|
||||
export interface MailConfig {
|
||||
defaultFromEmail: string;
|
||||
defaultLocale: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail provider interface
|
||||
*/
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* Send an email using a template
|
||||
*/
|
||||
sendTemplate(params: SendTemplateParams): Promise<SendEmailResult>;
|
||||
|
||||
/**
|
||||
* Send a raw email
|
||||
*/
|
||||
sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult>;
|
||||
|
||||
/**
|
||||
* Get the provider's name
|
||||
*/
|
||||
getProviderName(): string;
|
||||
}
|
||||
|
148
src/newsletter/README.md
Normal file
148
src/newsletter/README.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Newsletter Module
|
||||
|
||||
This module provides functionality for managing newsletter subscriptions using various email service providers. Currently, it supports [Resend](https://resend.com) for handling newsletter subscriptions.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribe users to newsletters
|
||||
- Unsubscribe users from newsletters
|
||||
- Check subscription status
|
||||
- Provider-agnostic interface for easy integration with different newsletter services
|
||||
- Automatic configuration using environment variables
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { subscribe, unsubscribe, isSubscribed } from '@/src/newsletter';
|
||||
|
||||
// Subscribe a user to the newsletter
|
||||
const success = await subscribe('user@example.com');
|
||||
|
||||
// Unsubscribe a user from the newsletter
|
||||
const success = await unsubscribe('user@example.com');
|
||||
|
||||
// Check if a user is subscribed
|
||||
const subscribed = await isSubscribed('user@example.com');
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The newsletter module is configured using environment variables:
|
||||
|
||||
```
|
||||
# Required for Resend provider
|
||||
RESEND_API_KEY=your-resend-api-key
|
||||
RESEND_AUDIENCE_ID=your-audience-id
|
||||
```
|
||||
|
||||
Or you can configure it programmatically:
|
||||
|
||||
```typescript
|
||||
import { initializeNewsletterProvider } from '@/src/newsletter';
|
||||
|
||||
// Configure with Resend
|
||||
initializeNewsletterProvider({
|
||||
resend: {
|
||||
apiKey: 'your-api-key',
|
||||
audienceId: 'your-audience-id'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Using the Newsletter Provider Directly
|
||||
|
||||
If you need more control, you can interact with the newsletter provider directly:
|
||||
|
||||
```typescript
|
||||
import { getNewsletterProvider } from '@/src/newsletter';
|
||||
|
||||
const provider = getNewsletterProvider();
|
||||
|
||||
// Use provider methods directly
|
||||
const result = await provider.subscribe({ email: 'user@example.com' });
|
||||
```
|
||||
|
||||
### Creating a Provider Instance Manually
|
||||
|
||||
You can create a provider instance directly without configuring the global instance:
|
||||
|
||||
```typescript
|
||||
import { createNewsletterProvider, ResendNewsletterProvider } from '@/src/newsletter';
|
||||
|
||||
// Using the factory function
|
||||
const resendProvider = createNewsletterProvider('resend', {
|
||||
apiKey: 'your-api-key',
|
||||
audienceId: 'your-audience-id'
|
||||
});
|
||||
|
||||
// Or creating an instance directly
|
||||
const manualProvider = new ResendNewsletterProvider(
|
||||
'your-api-key',
|
||||
'your-audience-id'
|
||||
);
|
||||
```
|
||||
|
||||
### Using a Custom Provider Implementation
|
||||
|
||||
You can create and use your own newsletter provider implementation:
|
||||
|
||||
```typescript
|
||||
import { NewsletterProvider, SubscribeNewsletterProps } from '@/src/newsletter';
|
||||
|
||||
class CustomNewsletterProvider implements NewsletterProvider {
|
||||
async subscribe(params: SubscribeNewsletterProps): Promise<boolean> {
|
||||
// Your implementation
|
||||
return true;
|
||||
}
|
||||
|
||||
async unsubscribe(params: UnsubscribeNewsletterProps): Promise<boolean> {
|
||||
// Your implementation
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkSubscribeStatus(params: CheckSubscribeStatusProps): Promise<boolean> {
|
||||
// Your implementation
|
||||
return true;
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'CustomProvider';
|
||||
}
|
||||
}
|
||||
|
||||
// Use your custom provider directly
|
||||
const customProvider = new CustomNewsletterProvider();
|
||||
const result = await customProvider.subscribe({ email: 'user@example.com' });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Main Functions
|
||||
|
||||
- `subscribe(email)`: Subscribe a user to the newsletter
|
||||
- `unsubscribe(email)`: Unsubscribe a user from the newsletter
|
||||
- `isSubscribed(email)`: Check if a user is subscribed to the newsletter
|
||||
|
||||
### Provider Management
|
||||
|
||||
- `getNewsletterProvider()`: Get the configured newsletter provider instance
|
||||
- `initializeNewsletterProvider(config?)`: Initialize the newsletter provider with specific options
|
||||
- `createNewsletterProvider(type, config)`: Create a new provider instance of the specified type
|
||||
|
||||
### Provider Interface
|
||||
|
||||
The `NewsletterProvider` interface defines the following methods:
|
||||
|
||||
- `subscribe(params)`: Subscribe a user to the newsletter
|
||||
- `unsubscribe(params)`: Unsubscribe a user from the newsletter
|
||||
- `checkSubscribeStatus(params)`: Check if a user is subscribed to the newsletter
|
||||
- `getProviderName()`: Get the provider name
|
||||
|
||||
### Types
|
||||
|
||||
- `SubscribeNewsletterProps`: Parameters for subscribing a user
|
||||
- `UnsubscribeNewsletterProps`: Parameters for unsubscribing a user
|
||||
- `CheckSubscribeStatusProps`: Parameters for checking subscription status
|
||||
- `NewsletterConfig`: Configuration options for the newsletter module
|
@ -1,4 +1,126 @@
|
||||
// export the subscribe and unsubscribe functions
|
||||
export { subscribe } from './newsletter';
|
||||
export { unsubscribe } from './newsletter';
|
||||
export { isSubscribed } from './newsletter';
|
||||
import { ResendNewsletterProvider } from './provider/resend';
|
||||
import {
|
||||
CheckSubscribeStatusProps,
|
||||
NewsletterConfig,
|
||||
NewsletterProvider,
|
||||
SubscribeNewsletterP,
|
||||
UnsubscribeNewsletterProps
|
||||
} from './types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
NewsletterProvider,
|
||||
NewsletterConfig,
|
||||
SubscribeNewsletterP as SubscribeNewsletterProps,
|
||||
UnsubscribeNewsletterProps,
|
||||
CheckSubscribeStatusProps
|
||||
};
|
||||
|
||||
// Export provider implementation
|
||||
export { ResendNewsletterProvider } from './provider/resend';
|
||||
|
||||
/**
|
||||
* Global newsletter provider instance
|
||||
*/
|
||||
let newsletterProvider: NewsletterProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the newsletter provider
|
||||
* @returns initialized newsletter provider
|
||||
*/
|
||||
export const initializeNewsletterProvider = (config?: NewsletterConfig): NewsletterProvider => {
|
||||
if (newsletterProvider) {
|
||||
return newsletterProvider;
|
||||
}
|
||||
|
||||
// If no config is provided, use environment variables
|
||||
const resendApiKey = process.env.RESEND_API_KEY;
|
||||
const resendAudienceId = process.env.RESEND_AUDIENCE_ID;
|
||||
|
||||
if (config?.resend) {
|
||||
newsletterProvider = new ResendNewsletterProvider(
|
||||
config.resend.apiKey,
|
||||
config.resend.audienceId
|
||||
);
|
||||
} else if (resendApiKey && resendAudienceId) {
|
||||
newsletterProvider = new ResendNewsletterProvider(
|
||||
resendApiKey,
|
||||
resendAudienceId
|
||||
);
|
||||
} else {
|
||||
// Default for development/testing
|
||||
const testApiKey = 'test_api_key';
|
||||
const testAudienceId = 'test_audience_id';
|
||||
newsletterProvider = new ResendNewsletterProvider(testApiKey, testAudienceId);
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development') {
|
||||
console.warn(
|
||||
'Using Resend with test credentials. This should only happen in development/test environments.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return newsletterProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the newsletter provider
|
||||
* @returns current newsletter provider instance
|
||||
*/
|
||||
export const getNewsletterProvider = (): NewsletterProvider => {
|
||||
if (!newsletterProvider) {
|
||||
return initializeNewsletterProvider();
|
||||
}
|
||||
return newsletterProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe a user to the newsletter
|
||||
* @param email The email address to subscribe
|
||||
* @returns True if the subscription was successful, false otherwise
|
||||
*/
|
||||
export const subscribe = async (email: string): Promise<boolean> => {
|
||||
const provider = getNewsletterProvider();
|
||||
return provider.subscribe({ email });
|
||||
};
|
||||
|
||||
/**
|
||||
* Unsubscribe a user from the newsletter
|
||||
* @param email The email address to unsubscribe
|
||||
* @returns True if the unsubscription was successful, false otherwise
|
||||
*/
|
||||
export const unsubscribe = async (email: string): Promise<boolean> => {
|
||||
const provider = getNewsletterProvider();
|
||||
return provider.unsubscribe({ email });
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user is subscribed to the newsletter
|
||||
* @param email The email address to check
|
||||
* @returns True if the user is subscribed, false otherwise
|
||||
*/
|
||||
export const isSubscribed = async (email: string): Promise<boolean> => {
|
||||
const provider = getNewsletterProvider();
|
||||
return provider.checkSubscribeStatus({ email });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a newsletter provider based on the specified type
|
||||
* @param type The provider type
|
||||
* @param config The provider configuration
|
||||
* @returns A configured newsletter provider instance
|
||||
*/
|
||||
export const createNewsletterProvider = (
|
||||
type: string,
|
||||
config: Record<string, any>
|
||||
): NewsletterProvider => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'resend':
|
||||
return new ResendNewsletterProvider(
|
||||
config.apiKey,
|
||||
config.audienceId
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unsupported newsletter provider type: ${type}`);
|
||||
}
|
||||
};
|
@ -1,16 +0,0 @@
|
||||
import { subscribeNewsletter, unsubscribeNewsletter, checkSubscribeStatus } from './provider/resend';
|
||||
|
||||
export const subscribe = async (email: string) => {
|
||||
const subscribed = await subscribeNewsletter({ email });
|
||||
return subscribed;
|
||||
};
|
||||
|
||||
export const unsubscribe = async (email: string) => {
|
||||
const unsubscribed = await unsubscribeNewsletter({ email });
|
||||
return unsubscribed;
|
||||
};
|
||||
|
||||
export const isSubscribed = async (email: string) => {
|
||||
const subscribed = await checkSubscribeStatus({ email });
|
||||
return subscribed;
|
||||
};
|
@ -1,85 +1,115 @@
|
||||
import { CheckSubscribeStatusHandler, SubscribeNewsletterHandler, UnsubscribeNewsletterHandler } from '@/newsletter/types';
|
||||
import { CheckSubscribeStatusParams, NewsletterProvider, SubscribeNewsletterParams, UnsubscribeNewsletterParams } from '@/newsletter/types';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY || 'test_api_key';
|
||||
const audienceId = process.env.RESEND_AUDIENCE_ID || 'test_audience_id';
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
/**
|
||||
* https://resend.com/docs/dashboard/audiences/contacts
|
||||
* Implementation of the NewsletterProvider interface using Resend
|
||||
*/
|
||||
export const subscribeNewsletter: SubscribeNewsletterHandler = async ({ email }) => {
|
||||
if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) {
|
||||
console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping subscribe newsletter');
|
||||
return false;
|
||||
export class ResendNewsletterProvider implements NewsletterProvider {
|
||||
private resend: Resend;
|
||||
private audienceId: string;
|
||||
|
||||
constructor(apiKey: string, audienceId: string) {
|
||||
this.resend = new Resend(apiKey);
|
||||
this.audienceId = audienceId;
|
||||
}
|
||||
|
||||
const result = await resend.contacts.create({
|
||||
email,
|
||||
audienceId,
|
||||
unsubscribed: false,
|
||||
});
|
||||
const subscribed = !result.error;
|
||||
|
||||
if (!subscribed) {
|
||||
console.error('Error subscribing newsletter', result.error);
|
||||
return false;
|
||||
} else {
|
||||
console.log('Subscribed newsletter', email);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const unsubscribeNewsletter: UnsubscribeNewsletterHandler = async ({ email }) => {
|
||||
if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) {
|
||||
console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping unsubscribe newsletter');
|
||||
return false;
|
||||
getProviderName(): string {
|
||||
return 'Resend';
|
||||
}
|
||||
|
||||
const result = await resend.contacts.update({
|
||||
email,
|
||||
audienceId,
|
||||
unsubscribed: true,
|
||||
});
|
||||
const unsubscribed = !result.error;
|
||||
async subscribe({ email }: SubscribeNewsletterParams): Promise<boolean> {
|
||||
try {
|
||||
// First, list all contacts to find the one with the matching email
|
||||
const listResult = await this.resend.contacts.list({ audienceId: this.audienceId });
|
||||
if (listResult.error) {
|
||||
console.error('Error listing contacts:', listResult.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!unsubscribed) {
|
||||
console.error('Error unsubscribing newsletter', result.error);
|
||||
return false;
|
||||
} else {
|
||||
console.log('Unsubscribed newsletter', email);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
// Check if the contact with the given email exists in the list
|
||||
let contact = null;
|
||||
if (listResult.data && Array.isArray(listResult.data)) {
|
||||
contact = listResult.data.find(c => c.email === email);
|
||||
}
|
||||
|
||||
export const checkSubscribeStatus: CheckSubscribeStatusHandler = async ({ email }) => {
|
||||
if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) {
|
||||
console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping check subscribe status');
|
||||
return false;
|
||||
}
|
||||
// If the contact does not exist, create a new one
|
||||
if (!contact) {
|
||||
const createResult = await this.resend.contacts.create({
|
||||
email,
|
||||
audienceId: this.audienceId,
|
||||
unsubscribed: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// First, list all contacts to find the one with the matching email
|
||||
const listResult = await resend.contacts.list({ audienceId });
|
||||
|
||||
if (listResult.error) {
|
||||
console.error('Error listing contacts:', listResult.error);
|
||||
if (createResult.error) {
|
||||
console.error('Error creating contact', createResult.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the contact already exists, update it
|
||||
// NOTICE: we can not just create a new contact if this email already exists,
|
||||
// because Resend will response 201, but user is not subscribed
|
||||
const updateResult = await this.resend.contacts.update({
|
||||
email,
|
||||
audienceId: this.audienceId,
|
||||
unsubscribed: false,
|
||||
});
|
||||
|
||||
if (updateResult.error) {
|
||||
console.error('Error updating contact', updateResult.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Subscribed newsletter', email);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing newsletter', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the contact with the given email exists in the list
|
||||
// We need to check if data exists and is an array
|
||||
if (listResult.data && Array.isArray(listResult.data)) {
|
||||
// Now we can safely use array methods
|
||||
return listResult.data.some(contact =>
|
||||
contact.email === email && contact.unsubscribed === false
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking subscription status:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async unsubscribe({ email }: UnsubscribeNewsletterParams): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.resend.contacts.update({
|
||||
email,
|
||||
audienceId: this.audienceId,
|
||||
unsubscribed: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Error unsubscribing newsletter', result.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Unsubscribed newsletter', email);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing newsletter', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkSubscribeStatus({ email }: CheckSubscribeStatusParams): Promise<boolean> {
|
||||
try {
|
||||
// First, list all contacts to find the one with the matching email
|
||||
const listResult = await this.resend.contacts.list({ audienceId: this.audienceId });
|
||||
|
||||
if (listResult.error) {
|
||||
console.error('Error listing contacts:', listResult.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the contact with the given email exists in the list
|
||||
if (listResult.data && Array.isArray(listResult.data)) {
|
||||
return listResult.data.some(contact =>
|
||||
contact.email === email && contact.unsubscribed === false
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking subscription status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,20 @@
|
||||
export interface SubscribeNewsletterProps {
|
||||
export interface SubscribeNewsletterParams {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UnsubscribeNewsletterProps {
|
||||
export interface UnsubscribeNewsletterParams {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface CheckSubscribeStatusProps {
|
||||
export interface CheckSubscribeStatusParams {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type SubscribeNewsletterHandler = (params: SubscribeNewsletterProps) => Promise<boolean>;
|
||||
export type SubscribeNewsletterHandler = (params: SubscribeNewsletterParams) => Promise<boolean>;
|
||||
|
||||
export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterProps) => Promise<boolean>;
|
||||
export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterParams) => Promise<boolean>;
|
||||
|
||||
export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusProps) => Promise<boolean>;
|
||||
export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusParams) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Newsletter provider, currently only Resend is supported
|
||||
@ -23,4 +23,13 @@ export interface NewsletterProvider {
|
||||
subscribe: SubscribeNewsletterHandler;
|
||||
unsubscribe: UnsubscribeNewsletterHandler;
|
||||
checkSubscribeStatus: CheckSubscribeStatusHandler;
|
||||
getProviderName(): string;
|
||||
}
|
||||
|
||||
export interface NewsletterConfig {
|
||||
provider?: string;
|
||||
resend?: {
|
||||
apiKey: string;
|
||||
audienceId: string;
|
||||
};
|
||||
}
|
||||
|
235
src/payment/README.md
Normal file
235
src/payment/README.md
Normal file
@ -0,0 +1,235 @@
|
||||
# Payment Module
|
||||
|
||||
This module provides a flexible payment integration with Stripe, supporting both subscription and one-time payments.
|
||||
|
||||
## Structure
|
||||
|
||||
- `/payment/types.ts` - Type definitions for the payment module
|
||||
- `/payment/index.ts` - Main payment interface and global provider instance
|
||||
- `/payment/provider/stripe.ts` - Stripe payment provider implementation
|
||||
- `/payment/config/payment-config.ts` - Payment plans configuration
|
||||
- `/actions/payment.ts` - Server actions for payment operations
|
||||
- `/app/api/webhooks/stripe/route.ts` - API route for Stripe webhook events
|
||||
- `/app/[locale]/(marketing)/payment/success/page.tsx` - Success page for completed checkout
|
||||
- `/app/[locale]/(marketing)/payment/cancel/page.tsx` - Cancel page for abandoned checkout
|
||||
- `/components/payment/checkout-button.tsx` - Button component to initiate checkout
|
||||
- `/components/payment/customer-portal-button.tsx` - Button component to access Stripe customer portal
|
||||
- `/components/payment/pricing-card.tsx` - Component to display a single pricing plan
|
||||
- `/components/payment/pricing-table.tsx` - Component to display all pricing plans
|
||||
- `/app/[locale]/(marketing)/pricing/page.tsx` - Pricing page using the pricing table component
|
||||
- `/app/[locale]/(dashboard)/settings/billing/page.tsx` - Account billing page to manage subscriptions
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables are required:
|
||||
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Stripe Price IDs
|
||||
STRIPE_PRICE_PRO_MONTHLY=price_...
|
||||
STRIPE_PRICE_PRO_YEARLY=price_...
|
||||
STRIPE_PRICE_LIFETIME=price_...
|
||||
```
|
||||
|
||||
## Payment Plans
|
||||
|
||||
Payment plans are defined in `/payment/config/payment-config.ts`. Each plan can have multiple pricing options (monthly, yearly, one-time) with the following structure:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro Plan",
|
||||
description: "For professional users",
|
||||
isFree: false,
|
||||
recommended: true,
|
||||
features: ["Feature 1", "Feature 2"],
|
||||
prices: [
|
||||
{
|
||||
productId: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||||
type: "recurring",
|
||||
interval: "month",
|
||||
amount: 2900,
|
||||
currency: "USD",
|
||||
trialPeriodDays: 7
|
||||
},
|
||||
{
|
||||
productId: process.env.STRIPE_PRICE_PRO_YEARLY!,
|
||||
type: "recurring",
|
||||
interval: "year",
|
||||
amount: 24900,
|
||||
currency: "USD",
|
||||
trialPeriodDays: 7
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Server Actions
|
||||
|
||||
The payment module uses server actions for payment operations:
|
||||
|
||||
### In `/actions/payment.ts`:
|
||||
|
||||
```typescript
|
||||
// Create a checkout session
|
||||
export const createCheckoutAction = actionClient
|
||||
.schema(checkoutSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
// Implementation details
|
||||
// Returns { success: true, data: { url, id } } or { success: false, error }
|
||||
});
|
||||
|
||||
// Create a customer portal session
|
||||
export const createPortalAction = actionClient
|
||||
.schema(portalSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
// Implementation details
|
||||
// Returns { success: true, data: { url } } or { success: false, error }
|
||||
});
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### CheckoutButton
|
||||
|
||||
Creates a Stripe checkout session and redirects the user:
|
||||
|
||||
```tsx
|
||||
<CheckoutButton
|
||||
planId="pro"
|
||||
priceId={process.env.STRIPE_PRICE_PRO_MONTHLY!}
|
||||
email="user@example.com"
|
||||
metadata={{ userId: "user_123" }}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
Subscribe
|
||||
</CheckoutButton>
|
||||
```
|
||||
|
||||
### CustomerPortalButton
|
||||
|
||||
Redirects the user to the Stripe customer portal:
|
||||
|
||||
```tsx
|
||||
<CustomerPortalButton
|
||||
customerId="cus_123"
|
||||
returnUrl="/account/billing"
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Manage Subscription
|
||||
</CustomerPortalButton>
|
||||
```
|
||||
|
||||
### PricingTable
|
||||
|
||||
Displays all pricing plans with interval selection:
|
||||
|
||||
```tsx
|
||||
<PricingTable
|
||||
plans={plans}
|
||||
email="user@example.com"
|
||||
metadata={{ userId: "user_123" }}
|
||||
currentPlanId="pro"
|
||||
/>
|
||||
```
|
||||
|
||||
### PricingCard
|
||||
|
||||
Displays a single pricing plan with checkout button:
|
||||
|
||||
```tsx
|
||||
<PricingCard
|
||||
plan={plan}
|
||||
interval="month"
|
||||
paymentType="recurring"
|
||||
email="user@example.com"
|
||||
metadata={{ userId: "user_123" }}
|
||||
isCurrentPlan={false}
|
||||
/>
|
||||
```
|
||||
|
||||
## Webhooks
|
||||
|
||||
Stripe webhook events are handled via `/app/api/webhooks/stripe/route.ts`, which calls the `handleWebhookEvent` function from the payment module.
|
||||
|
||||
The webhook handler processes events like:
|
||||
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `payment_intent.succeeded`
|
||||
- `payment_intent.payment_failed`
|
||||
|
||||
Custom webhook handlers can be registered using:
|
||||
|
||||
```typescript
|
||||
registerWebhookHandler('checkout.session.completed', async (event) => {
|
||||
// Handle the event
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Steps
|
||||
|
||||
1. Set up Stripe account and get API keys
|
||||
2. Create products and prices in the Stripe dashboard that match your pricing configuration
|
||||
3. Add environment variables to your project
|
||||
4. Set up webhook endpoints in the Stripe dashboard:
|
||||
- `https://your-domain.com/api/webhooks/stripe`
|
||||
5. Add the pricing page and account billing components to your application
|
||||
6. Use the `CheckoutButton` and `CustomerPortalButton` components where needed
|
||||
|
||||
## Error Handling
|
||||
|
||||
The payment module includes error handling for:
|
||||
|
||||
- Missing environment variables
|
||||
- Failed checkout session creation
|
||||
- Invalid webhooks
|
||||
- User permission checks
|
||||
- Network/API failures
|
||||
|
||||
## Testing
|
||||
|
||||
For testing, use Stripe's test mode and test credit cards:
|
||||
|
||||
- 4242 4242 4242 4242 - Successful payment
|
||||
- 4000 0000 0000 3220 - 3D Secure authentication required
|
||||
- 4000 0000 0000 9995 - Insufficient funds failure
|
||||
|
||||
## Global Functions
|
||||
|
||||
The main payment interface in `/payment/index.ts` provides these global functions:
|
||||
|
||||
```typescript
|
||||
// Create a checkout session for a plan
|
||||
createCheckout(params: CreateCheckoutParams): Promise<CheckoutResult>;
|
||||
|
||||
// Create a customer portal session
|
||||
createCustomerPortal(params: CreatePortalParams): Promise<PortalResult>;
|
||||
|
||||
// Get a customer by ID
|
||||
getCustomer(params: GetCustomerParams): Promise<Customer | null>;
|
||||
|
||||
// Get a subscription by ID
|
||||
getSubscription(params: GetSubscriptionParams): Promise<Subscription | null>;
|
||||
|
||||
// Register a webhook event handler
|
||||
registerWebhookHandler(eventType: string, handler: WebhookEventHandler): void;
|
||||
|
||||
// Handle a webhook event
|
||||
handleWebhookEvent(payload: string, signature: string): Promise<void>;
|
||||
|
||||
// Get plan by ID
|
||||
getPlanById(planId: string): PricePlan | undefined;
|
||||
|
||||
// Get all available plans
|
||||
getAllPlans(): PricePlan[];
|
||||
|
||||
// Find price in a plan by ID
|
||||
findPriceInPlan(planId: string, priceId: string): Price | undefined;
|
||||
```
|
94
src/payment/config/payment-config.ts
Normal file
94
src/payment/config/payment-config.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { PaymentConfig, 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: "recurring",
|
||||
productId: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||||
amount: 2900,
|
||||
currency: "USD",
|
||||
interval: "month",
|
||||
trialPeriodDays: 7,
|
||||
},
|
||||
{
|
||||
type: "recurring",
|
||||
productId: process.env.STRIPE_PRICE_PRO_YEARLY!,
|
||||
amount: 24900,
|
||||
currency: "USD",
|
||||
interval: "year",
|
||||
trialPeriodDays: 7,
|
||||
},
|
||||
],
|
||||
isFree: 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: "one_time",
|
||||
productId: process.env.STRIPE_PRICE_LIFETIME!,
|
||||
amount: 99900,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
isFree: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Payment configuration
|
||||
*/
|
||||
export const paymentConfig: PaymentConfig = {
|
||||
plans: {
|
||||
free: freePlan,
|
||||
pro: proPlan,
|
||||
lifetime: lifetimePlan,
|
||||
},
|
||||
defaultCurrency: "USD",
|
||||
};
|
158
src/payment/index.ts
Normal file
158
src/payment/index.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { PaymentProvider, PricePlan, PaymentConfig, Customer, Subscription, Payment, PaymentStatus, PlanInterval, PaymentType, Price, CreateCheckoutParams, CheckoutResult, CreatePortalParams, PortalResult, GetCustomerParams, GetSubscriptionParams, WebhookEventHandler } from "./types";
|
||||
import { StripeProvider } from "./provider/stripe";
|
||||
import { paymentConfig } from "./config/payment-config";
|
||||
/**
|
||||
* Default payment configuration
|
||||
*/
|
||||
export const defaultPaymentConfig: PaymentConfig = paymentConfig;
|
||||
|
||||
/**
|
||||
* Global payment provider instance
|
||||
*/
|
||||
let paymentProvider: PaymentProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the payment provider
|
||||
* @returns initialized payment provider
|
||||
*/
|
||||
export const initializePaymentProvider = (): PaymentProvider => {
|
||||
if (!paymentProvider) {
|
||||
paymentProvider = new StripeProvider();
|
||||
}
|
||||
return paymentProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the payment provider
|
||||
* @returns current payment provider instance
|
||||
* @throws Error if provider is not initialized
|
||||
*/
|
||||
export const getPaymentProvider = (): PaymentProvider => {
|
||||
if (!paymentProvider) {
|
||||
return initializePaymentProvider();
|
||||
}
|
||||
return paymentProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a checkout session for a plan
|
||||
* @param params Parameters for creating the checkout session
|
||||
* @returns Checkout result
|
||||
*/
|
||||
export const createCheckout = async (
|
||||
params: CreateCheckoutParams
|
||||
): Promise<CheckoutResult> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.createCheckout(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
* @param params Parameters for creating the portal
|
||||
* @returns Portal result
|
||||
*/
|
||||
export const createCustomerPortal = async (
|
||||
params: CreatePortalParams
|
||||
): Promise<PortalResult> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.createCustomerPortal(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get customer details
|
||||
* @param params Parameters for retrieving the customer
|
||||
* @returns Customer data or null if not found
|
||||
*/
|
||||
export const getCustomer = async (
|
||||
params: GetCustomerParams
|
||||
): Promise<Customer | null> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.getCustomer(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get subscription details
|
||||
* @param params Parameters for retrieving the subscription
|
||||
* @returns Subscription data or null if not found
|
||||
*/
|
||||
export const getSubscription = async (
|
||||
params: GetSubscriptionParams
|
||||
): Promise<Subscription | null> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.getSubscription(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle webhook event
|
||||
* @param payload Raw webhook payload
|
||||
* @param signature Webhook signature
|
||||
*/
|
||||
export const handleWebhookEvent = async (
|
||||
payload: string,
|
||||
signature: string
|
||||
): Promise<void> => {
|
||||
const provider = getPaymentProvider();
|
||||
await provider.handleWebhookEvent(payload, signature);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register webhook event handler
|
||||
* @param eventType Webhook event type
|
||||
* @param handler Event handler function
|
||||
*/
|
||||
export const registerWebhookHandler = (
|
||||
eventType: string,
|
||||
handler: WebhookEventHandler
|
||||
): void => {
|
||||
const provider = getPaymentProvider();
|
||||
provider.registerWebhookHandler(eventType, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get plan by ID
|
||||
* @param planId Plan ID
|
||||
* @returns Plan or undefined if not found
|
||||
*/
|
||||
export const getPlanById = (planId: string): PricePlan | undefined => {
|
||||
return defaultPaymentConfig.plans[planId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available plans
|
||||
* @returns Array of price plans
|
||||
*/
|
||||
export const getAllPlans = (): PricePlan[] => {
|
||||
return Object.values(defaultPaymentConfig.plans);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find price in a plan by ID
|
||||
* @param planId Plan ID
|
||||
* @param priceId Price ID (Stripe price ID)
|
||||
* @returns Price or undefined if not found
|
||||
*/
|
||||
export const findPriceInPlan = (planId: string, priceId: string): Price | undefined => {
|
||||
const plan = getPlanById(planId);
|
||||
if (!plan) return undefined;
|
||||
|
||||
return plan.prices.find(price => price.productId === priceId);
|
||||
};
|
||||
|
||||
// Export types for convenience
|
||||
export type {
|
||||
PaymentProvider,
|
||||
PricePlan,
|
||||
PaymentConfig,
|
||||
Price,
|
||||
PaymentType,
|
||||
Customer,
|
||||
Subscription,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
PlanInterval,
|
||||
CreateCheckoutParams,
|
||||
CheckoutResult,
|
||||
CreatePortalParams,
|
||||
PortalResult,
|
||||
WebhookEventHandler,
|
||||
};
|
400
src/payment/provider/stripe.ts
Normal file
400
src/payment/provider/stripe.ts
Normal file
@ -0,0 +1,400 @@
|
||||
import Stripe from 'stripe';
|
||||
import { PaymentProvider, CreateCheckoutParams, CheckoutResult, CreatePortalParams, PortalResult, GetCustomerParams, Customer, GetSubscriptionParams, Subscription, PaymentStatus, PlanInterval, WebhookEventHandler, PaymentType } from '../types';
|
||||
import { getPlanById, findPriceInPlan } from '../index';
|
||||
|
||||
/**
|
||||
* Stripe payment provider implementation
|
||||
*/
|
||||
export class StripeProvider implements PaymentProvider {
|
||||
private stripe: Stripe;
|
||||
private webhookHandlers: Map<string, WebhookEventHandler[]>;
|
||||
private webhookSecret: string;
|
||||
|
||||
/**
|
||||
* Initialize Stripe provider with API key
|
||||
*/
|
||||
constructor() {
|
||||
const apiKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('STRIPE_SECRET_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
|
||||
if (!this.webhookSecret) {
|
||||
console.warn('STRIPE_WEBHOOK_SECRET is not set. Webhook signature verification will be skipped.');
|
||||
}
|
||||
|
||||
// Initialize Stripe without specifying apiVersion to use default/latest version
|
||||
this.stripe = new Stripe(apiKey);
|
||||
|
||||
this.webhookHandlers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Stripe subscription status to PaymentStatus
|
||||
* @param status Stripe subscription status
|
||||
* @returns PaymentStatus
|
||||
*/
|
||||
private mapSubscriptionStatus(status: Stripe.Subscription.Status): PaymentStatus {
|
||||
const statusMap: Record<string, PaymentStatus> = {
|
||||
active: 'active',
|
||||
canceled: 'canceled',
|
||||
incomplete: 'incomplete',
|
||||
incomplete_expired: 'failed',
|
||||
past_due: 'past_due',
|
||||
trialing: 'trialing',
|
||||
unpaid: 'unpaid',
|
||||
paused: 'past_due', // Map paused to past_due as a reasonable default
|
||||
};
|
||||
|
||||
return statusMap[status] || 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Stripe payment intent status to PaymentStatus
|
||||
* @param status Stripe payment intent status
|
||||
* @returns PaymentStatus
|
||||
*/
|
||||
private mapPaymentIntentStatus(status: Stripe.PaymentIntent.Status): PaymentStatus {
|
||||
const statusMap: Record<string, PaymentStatus> = {
|
||||
succeeded: 'completed',
|
||||
processing: 'processing',
|
||||
requires_payment_method: 'incomplete',
|
||||
requires_confirmation: 'incomplete',
|
||||
requires_action: 'incomplete',
|
||||
requires_capture: 'processing',
|
||||
canceled: 'canceled',
|
||||
};
|
||||
|
||||
return statusMap[status] || 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer in Stripe if not exists
|
||||
* @param email Customer email
|
||||
* @param name Optional customer name
|
||||
* @param metadata Optional metadata
|
||||
* @returns Stripe customer ID
|
||||
*/
|
||||
private async createOrGetCustomer(
|
||||
email: string,
|
||||
name?: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Search for existing customer
|
||||
const customers = await this.stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (customers.data.length > 0) {
|
||||
return customers.data[0].id;
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
const customer = await this.stripe.customers.create({
|
||||
email,
|
||||
name: name || undefined,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
} catch (error) {
|
||||
console.error('Error creating or getting customer:', error);
|
||||
throw new Error('Failed to create or get customer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkout session for a plan
|
||||
* @param params Parameters for creating the checkout session
|
||||
* @returns Checkout result
|
||||
*/
|
||||
public async createCheckout(params: CreateCheckoutParams): Promise<CheckoutResult> {
|
||||
const { planId, priceId, customerEmail, successUrl, cancelUrl, metadata } = params;
|
||||
|
||||
try {
|
||||
// Get plan and price
|
||||
const plan = getPlanById(planId);
|
||||
if (!plan) {
|
||||
throw new Error(`Plan with ID ${planId} not found`);
|
||||
}
|
||||
|
||||
// Free plan doesn't need a checkout session
|
||||
if (plan.isFree) {
|
||||
throw new Error('Cannot create checkout session for free plan');
|
||||
}
|
||||
|
||||
// Find price in plan
|
||||
const price = findPriceInPlan(planId, priceId);
|
||||
if (!price) {
|
||||
throw new Error(`Price with ID ${priceId} not found in plan ${planId}`);
|
||||
}
|
||||
|
||||
// Set up the line items
|
||||
const lineItems = [{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
}];
|
||||
|
||||
// Create checkout session parameters
|
||||
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
|
||||
line_items: lineItems,
|
||||
mode: price.type === 'recurring' ? 'subscription' : 'payment',
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
planId,
|
||||
priceId,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
|
||||
// If customer email is provided, add it to the checkout
|
||||
if (customerEmail) {
|
||||
checkoutParams.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
// Add trial period if it's a subscription and has trial days
|
||||
if (price.type === 'recurring' && price.trialPeriodDays && price.trialPeriodDays > 0) {
|
||||
checkoutParams.subscription_data = {
|
||||
trial_period_days: price.trialPeriodDays,
|
||||
metadata: {
|
||||
planId,
|
||||
priceId,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Create the checkout session
|
||||
const session = await this.stripe.checkout.sessions.create(checkoutParams);
|
||||
|
||||
return {
|
||||
url: session.url!,
|
||||
id: session.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
* @param params Parameters for creating the portal
|
||||
* @returns Portal result
|
||||
*/
|
||||
public async createCustomerPortal(params: CreatePortalParams): Promise<PortalResult> {
|
||||
const { customerId, returnUrl } = params;
|
||||
|
||||
try {
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
url: session.url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating customer portal:', error);
|
||||
throw new Error('Failed to create customer portal');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer details
|
||||
* @param params Parameters for retrieving the customer
|
||||
* @returns Customer data or null if not found
|
||||
*/
|
||||
public async getCustomer(params: GetCustomerParams): Promise<Customer | null> {
|
||||
const { customerId } = params;
|
||||
|
||||
try {
|
||||
const customer = await this.stripe.customers.retrieve(customerId);
|
||||
|
||||
if (customer.deleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: customer.id,
|
||||
email: customer.email || '',
|
||||
name: customer.name || undefined,
|
||||
metadata: customer.metadata as Record<string, string> || {},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting customer:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription details
|
||||
* @param params Parameters for retrieving the subscription
|
||||
* @returns Subscription data or null if not found
|
||||
*/
|
||||
public async getSubscription(params: GetSubscriptionParams): Promise<Subscription | null> {
|
||||
const { subscriptionId } = params;
|
||||
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Determine the interval if available
|
||||
let interval: PlanInterval | undefined = undefined;
|
||||
if (subscription.items.data[0]?.plan.interval === 'month' || subscription.items.data[0]?.plan.interval === 'year') {
|
||||
interval = subscription.items.data[0]?.plan.interval as PlanInterval;
|
||||
}
|
||||
|
||||
// Extract plan ID and price ID from metadata or use defaults
|
||||
const planId = subscription.metadata.planId || 'unknown';
|
||||
const priceId = subscription.metadata.priceId || subscription.items.data[0]?.price.id || 'unknown';
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customerId: subscription.customer as string,
|
||||
status: this.mapSubscriptionStatus(subscription.status),
|
||||
planId,
|
||||
priceId,
|
||||
interval,
|
||||
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at
|
||||
? new Date(subscription.canceled_at * 1000)
|
||||
: undefined,
|
||||
trialEndDate: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: undefined,
|
||||
createdAt: new Date(subscription.created * 1000),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting subscription:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register webhook event handler
|
||||
* @param eventType Webhook event type
|
||||
* @param handler Event handler function
|
||||
*/
|
||||
public registerWebhookHandler(eventType: string, handler: WebhookEventHandler): void {
|
||||
if (!this.webhookHandlers.has(eventType)) {
|
||||
this.webhookHandlers.set(eventType, []);
|
||||
}
|
||||
|
||||
this.webhookHandlers.get(eventType)?.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook event
|
||||
* @param payload Raw webhook payload
|
||||
* @param signature Webhook signature
|
||||
*/
|
||||
public async handleWebhookEvent(payload: string, signature: string): Promise<void> {
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
// Verify the event signature if webhook secret is available
|
||||
if (this.webhookSecret) {
|
||||
event = this.stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
this.webhookSecret
|
||||
);
|
||||
} else {
|
||||
// Parse the event payload without verification
|
||||
event = JSON.parse(payload) as Stripe.Event;
|
||||
}
|
||||
|
||||
console.log(`Received Stripe webhook event: ${event.type}`);
|
||||
|
||||
// Process the event based on type
|
||||
const handlers = this.webhookHandlers.get(event.type) || [];
|
||||
const defaultHandlers = this.webhookHandlers.get('*') || [];
|
||||
|
||||
const allHandlers = [...handlers, ...defaultHandlers];
|
||||
|
||||
// If no custom handlers are registered, use default handling logic
|
||||
if (allHandlers.length === 0) {
|
||||
await this.defaultWebhookHandler(event);
|
||||
} else {
|
||||
// Execute all registered handlers
|
||||
await Promise.all(allHandlers.map(handler => handler(event)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling webhook event:', error);
|
||||
throw new Error('Failed to handle webhook event');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default webhook handler for common event types
|
||||
* @param event Stripe event
|
||||
*/
|
||||
private async defaultWebhookHandler(event: Stripe.Event): Promise<void> {
|
||||
const eventType = event.type;
|
||||
|
||||
try {
|
||||
// Handle subscription events
|
||||
if (eventType.startsWith('customer.subscription.')) {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
console.log(`Subscription ${subscription.id} is ${subscription.status}`);
|
||||
|
||||
// Process based on subscription status
|
||||
switch (eventType) {
|
||||
case 'customer.subscription.created':
|
||||
// Handle subscription creation
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
// Handle subscription update
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
// Handle subscription cancellation
|
||||
break;
|
||||
case 'customer.subscription.trial_will_end':
|
||||
// Handle trial ending soon
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle payment events
|
||||
else if (eventType.startsWith('payment_intent.')) {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
console.log(`Payment ${paymentIntent.id} is ${paymentIntent.status}`);
|
||||
|
||||
switch (eventType) {
|
||||
case 'payment_intent.succeeded':
|
||||
// Handle successful payment
|
||||
break;
|
||||
case 'payment_intent.payment_failed':
|
||||
// Handle failed payment
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle checkout events
|
||||
else if (eventType.startsWith('checkout.')) {
|
||||
if (eventType === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
console.log(`Checkout session ${session.id} completed`);
|
||||
|
||||
// Handle completed checkout
|
||||
if (session.mode === 'subscription') {
|
||||
// Handle subscription checkout
|
||||
const subscriptionId = session.subscription as string;
|
||||
console.log(`New subscription: ${subscriptionId}`);
|
||||
} else if (session.mode === 'payment') {
|
||||
// Handle one-time payment checkout
|
||||
const paymentIntentId = session.payment_intent as string;
|
||||
console.log(`One-time payment: ${paymentIntentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in default webhook handler:', error);
|
||||
}
|
||||
}
|
||||
}
|
193
src/payment/types.ts
Normal file
193
src/payment/types.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { Stripe } from 'stripe';
|
||||
import { Locale, Messages } from 'next-intl';
|
||||
|
||||
/**
|
||||
* Interval types for subscription plans
|
||||
*/
|
||||
export type PlanInterval = 'month' | 'year';
|
||||
|
||||
/**
|
||||
* Payment type (recurring or one-time)
|
||||
*/
|
||||
export type PaymentType = 'recurring' | 'one_time';
|
||||
|
||||
/**
|
||||
* Status of a payment or subscription
|
||||
*/
|
||||
export type PaymentStatus =
|
||||
| 'active' // Subscription is active
|
||||
| 'canceled' // Subscription has been canceled
|
||||
| 'incomplete' // Payment not completed
|
||||
| 'past_due' // Payment is past due
|
||||
| 'trialing' // In trial period
|
||||
| 'unpaid' // Payment failed
|
||||
| 'completed' // One-time payment completed
|
||||
| 'processing' // Payment is processing
|
||||
| 'failed'; // Payment failed
|
||||
|
||||
/**
|
||||
* Price definition for a plan
|
||||
*/
|
||||
export interface Price {
|
||||
type: PaymentType; // Type of payment (recurring or one_time)
|
||||
productId: string; // Stripe price ID
|
||||
amount: number; // Price amount in currency units (dollars, euros, etc.)
|
||||
currency: string; // Currency code (e.g., USD)
|
||||
interval?: PlanInterval; // Billing interval for recurring payments
|
||||
trialPeriodDays?: number; // Free trial period in days
|
||||
}
|
||||
|
||||
/**
|
||||
* Price plan definition
|
||||
*/
|
||||
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
|
||||
prices: Price[]; // Available prices for this plan
|
||||
isFree: boolean; // Whether this is a free plan
|
||||
recommended?: boolean; // Whether to mark this plan as recommended in UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment configuration
|
||||
*/
|
||||
export interface PaymentConfig {
|
||||
plans: Record<string, PricePlan>; // Plans indexed by ID
|
||||
defaultCurrency: string; // Default currency
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer data
|
||||
*/
|
||||
export interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription data
|
||||
*/
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
customerId: string;
|
||||
status: PaymentStatus;
|
||||
planId: string;
|
||||
priceId: string;
|
||||
interval?: PlanInterval;
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt?: Date;
|
||||
trialEndDate?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment data
|
||||
*/
|
||||
export interface Payment {
|
||||
id: string;
|
||||
customerId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: PaymentStatus;
|
||||
createdAt: Date;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a checkout session
|
||||
*/
|
||||
export interface CreateCheckoutParams {
|
||||
planId: string;
|
||||
priceId: string;
|
||||
customerEmail?: string;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
metadata?: Record<string, string>;
|
||||
locale?: Locale;
|
||||
messages?: Messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a checkout session
|
||||
*/
|
||||
export interface CheckoutResult {
|
||||
url: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a customer portal
|
||||
*/
|
||||
export interface CreatePortalParams {
|
||||
customerId: string;
|
||||
returnUrl?: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a customer portal
|
||||
*/
|
||||
export interface PortalResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for retrieving a customer
|
||||
*/
|
||||
export interface GetCustomerParams {
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for retrieving a subscription
|
||||
*/
|
||||
export interface GetSubscriptionParams {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook event handler
|
||||
*/
|
||||
export type WebhookEventHandler = (event: Stripe.Event) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Payment provider interface
|
||||
*/
|
||||
export interface PaymentProvider {
|
||||
/**
|
||||
* Create a checkout session for a plan
|
||||
*/
|
||||
createCheckout(params: CreateCheckoutParams): Promise<CheckoutResult>;
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
*/
|
||||
createCustomerPortal(params: CreatePortalParams): Promise<PortalResult>;
|
||||
|
||||
/**
|
||||
* Get customer details
|
||||
*/
|
||||
getCustomer(params: GetCustomerParams): Promise<Customer | null>;
|
||||
|
||||
/**
|
||||
* Get subscription details
|
||||
*/
|
||||
getSubscription(params: GetSubscriptionParams): Promise<Subscription | null>;
|
||||
|
||||
/**
|
||||
* Handle webhook events
|
||||
*/
|
||||
handleWebhookEvent(payload: string, signature: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Register webhook event handlers
|
||||
*/
|
||||
registerWebhookHandler(eventType: string, handler: WebhookEventHandler): void;
|
||||
}
|
160
src/storage/README.md
Normal file
160
src/storage/README.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Storage Module
|
||||
|
||||
This module provides a unified interface for storing and retrieving files using various cloud storage providers. Currently, it supports Amazon S3 and compatible services like Cloudflare R2.
|
||||
|
||||
## Features
|
||||
|
||||
- Upload files to cloud storage
|
||||
- Generate pre-signed URLs for direct browser-to-storage uploads
|
||||
- Delete files from storage
|
||||
- Client-side upload helpers for both small and large files
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { uploadFile, deleteFile, getPresignedUploadUrl } from '@/src/storage';
|
||||
|
||||
// Upload a file
|
||||
const { url, key } = await uploadFile(
|
||||
fileBuffer,
|
||||
'original-filename.jpg',
|
||||
'image/jpeg',
|
||||
'uploads/images'
|
||||
);
|
||||
|
||||
// Delete a file
|
||||
await deleteFile(key);
|
||||
|
||||
// Generate a pre-signed URL for direct upload
|
||||
const { url, key } = await getPresignedUploadUrl(
|
||||
'filename.jpg',
|
||||
'image/jpeg',
|
||||
'uploads/images'
|
||||
);
|
||||
```
|
||||
|
||||
## Client-Side Upload
|
||||
|
||||
For client-side uploads, use the `uploadFileFromBrowser` function:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { uploadFileFromBrowser } from '@/src/storage';
|
||||
|
||||
// In your component
|
||||
async function handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
|
||||
try {
|
||||
// This will automatically use the most appropriate upload method
|
||||
// based on the file size
|
||||
const { url, key } = await uploadFileFromBrowser(file, 'uploads/images');
|
||||
console.log('File uploaded:', url);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The storage module is configured using environment variables:
|
||||
|
||||
```
|
||||
# Required
|
||||
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
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Using the Storage Provider Directly
|
||||
|
||||
If you need more control, you can interact with the storage provider directly:
|
||||
|
||||
```typescript
|
||||
import { getStorageProvider } from '@/src/storage';
|
||||
|
||||
const provider = getStorageProvider();
|
||||
|
||||
// Use provider methods directly
|
||||
const result = await provider.uploadFile({
|
||||
file: fileBuffer,
|
||||
filename: 'example.pdf',
|
||||
contentType: 'application/pdf',
|
||||
folder: 'documents'
|
||||
});
|
||||
```
|
||||
|
||||
### Using a Custom Provider Implementation
|
||||
|
||||
You can create and use your own storage provider implementation:
|
||||
|
||||
```typescript
|
||||
import { StorageProvider, UploadFileParams, UploadFileResult } from '@/src/storage';
|
||||
|
||||
class CustomStorageProvider implements StorageProvider {
|
||||
// Implement the required methods
|
||||
async uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
|
||||
// Your implementation
|
||||
}
|
||||
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
// Your implementation
|
||||
}
|
||||
|
||||
async getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult> {
|
||||
// Your implementation
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'CustomProvider';
|
||||
}
|
||||
}
|
||||
|
||||
// Then use it
|
||||
const customProvider = new CustomStorageProvider();
|
||||
const result = await customProvider.uploadFile({
|
||||
file: fileBuffer,
|
||||
filename: 'example.jpg',
|
||||
contentType: 'image/jpeg'
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Main Functions
|
||||
|
||||
- `uploadFile(file, filename, contentType, folder?)`: Upload a file to storage
|
||||
- `deleteFile(key)`: Delete a file from storage
|
||||
- `getPresignedUploadUrl(filename, contentType, folder?, expiresIn?)`: Generate a pre-signed URL
|
||||
- `uploadFileFromBrowser(file, folder?)`: Upload a file from the browser
|
||||
|
||||
### Provider Interface
|
||||
|
||||
The `StorageProvider` interface defines the following methods:
|
||||
|
||||
- `uploadFile(params)`: Upload a file to storage
|
||||
- `deleteFile(key)`: Delete a file from storage
|
||||
- `getPresignedUploadUrl(params)`: Generate a pre-signed URL
|
||||
- `getProviderName()`: Get the provider name
|
||||
|
||||
### Configuration
|
||||
|
||||
The `StorageConfig` interface defines the configuration options:
|
||||
|
||||
- `region`: Storage region (e.g., 'us-east-1')
|
||||
- `endpoint?`: Custom endpoint URL for S3-compatible services
|
||||
- `accessKeyId`: Access key ID for authentication
|
||||
- `secretAccessKey`: Secret access key for authentication
|
||||
- `bucketName`: Storage bucket name
|
||||
- `publicUrl?`: Public URL for accessing files
|
||||
- `forcePathStyle?`: Whether to use path-style URLs
|
16
src/storage/config/storage-config.ts
Normal file
16
src/storage/config/storage-config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { StorageConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Default storage configuration
|
||||
*
|
||||
* This configuration is loaded from environment variables
|
||||
*/
|
||||
export const storageConfig: StorageConfig = {
|
||||
region: process.env.STORAGE_REGION || '',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
|
||||
bucketName: process.env.STORAGE_BUCKET_NAME || '',
|
||||
publicUrl: process.env.STORAGE_PUBLIC_URL,
|
||||
forcePathStyle: process.env.STORAGE_FORCE_PATH_STYLE !== 'false',
|
||||
};
|
@ -1,13 +1,55 @@
|
||||
import { storageConfig } from './config/storage-config';
|
||||
import { S3Provider } from './provider/s3';
|
||||
import {
|
||||
uploadFile as s3UploadFile,
|
||||
deleteFile as s3DeleteFile,
|
||||
getPresignedUploadUrl as s3GetPresignedUploadUrl,
|
||||
StorageError,
|
||||
ConfigurationError,
|
||||
UploadError
|
||||
} from './provider/s3';
|
||||
PresignedUploadUrlParams,
|
||||
StorageConfig,
|
||||
StorageError,
|
||||
StorageProvider,
|
||||
UploadError,
|
||||
UploadFileParams,
|
||||
UploadFileResult
|
||||
} from './types';
|
||||
|
||||
export { StorageError, ConfigurationError, UploadError };
|
||||
// Re-export types for convenience
|
||||
export { ConfigurationError, StorageError, UploadError };
|
||||
export type {
|
||||
PresignedUploadUrlParams, StorageConfig, StorageProvider, UploadFileParams,
|
||||
UploadFileResult
|
||||
};
|
||||
|
||||
/**
|
||||
* Default storage configuration
|
||||
*/
|
||||
export const defaultStorageConfig: StorageConfig = storageConfig;
|
||||
|
||||
/**
|
||||
* Global storage provider instance
|
||||
*/
|
||||
let storageProvider: StorageProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the storage provider
|
||||
* @returns initialized storage provider
|
||||
*/
|
||||
export const initializeStorageProvider = (): StorageProvider => {
|
||||
if (!storageProvider) {
|
||||
storageProvider = new S3Provider();
|
||||
}
|
||||
return storageProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the storage provider
|
||||
* @returns current storage provider instance
|
||||
* @throws Error if provider is not initialized
|
||||
*/
|
||||
export const getStorageProvider = (): StorageProvider => {
|
||||
if (!storageProvider) {
|
||||
return initializeStorageProvider();
|
||||
}
|
||||
return storageProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the configured storage provider
|
||||
@ -23,8 +65,9 @@ export const uploadFile = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
return s3UploadFile(file, filename, contentType, folder);
|
||||
): Promise<UploadFileResult> => {
|
||||
const provider = getStorageProvider();
|
||||
return provider.uploadFile({ file, filename, contentType, folder });
|
||||
};
|
||||
|
||||
/**
|
||||
@ -34,7 +77,8 @@ export const uploadFile = async (
|
||||
* @returns Promise that resolves when the file is deleted
|
||||
*/
|
||||
export const deleteFile = async (key: string): Promise<void> => {
|
||||
return s3DeleteFile(key);
|
||||
const provider = getStorageProvider();
|
||||
return provider.deleteFile(key);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -51,8 +95,9 @@ export const getPresignedUploadUrl = async (
|
||||
contentType: string,
|
||||
folder?: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
return s3GetPresignedUploadUrl(filename, contentType, folder, expiresIn);
|
||||
): Promise<UploadFileResult> => {
|
||||
const provider = getStorageProvider();
|
||||
return provider.getPresignedUploadUrl({ filename, contentType, folder, expiresIn });
|
||||
};
|
||||
|
||||
/**
|
||||
@ -66,7 +111,7 @@ export const getPresignedUploadUrl = async (
|
||||
export const uploadFileFromBrowser = async (
|
||||
file: File,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
): Promise<UploadFileResult> => {
|
||||
try {
|
||||
// For small files (< 10MB), use direct upload
|
||||
if (file.size < 10 * 1024 * 1024) {
|
||||
|
@ -1,197 +1,207 @@
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Define error types for better error handling
|
||||
export class StorageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UploadError';
|
||||
}
|
||||
}
|
||||
import { storageConfig } from '../config/storage-config';
|
||||
import {
|
||||
ConfigurationError,
|
||||
PresignedUploadUrlParams,
|
||||
StorageConfig,
|
||||
StorageError,
|
||||
StorageProvider,
|
||||
UploadError,
|
||||
UploadFileParams,
|
||||
UploadFileResult
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* S3 client configuration
|
||||
* Amazon S3 storage provider implementation
|
||||
*
|
||||
* This provider works with Amazon S3 and compatible services like Cloudflare R2
|
||||
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
|
||||
* https://www.npmjs.com/package/@aws-sdk/client-s3
|
||||
* https://www.cloudflare.com/lp/pg-cloudflare-r2-vs-aws-s3/
|
||||
* https://docs.uploadthing.com/uploading-files
|
||||
* https://developers.cloudflare.com/r2/
|
||||
*/
|
||||
const getS3Client = (): S3Client => {
|
||||
const region = process.env.STORAGE_REGION;
|
||||
const endpoint = process.env.STORAGE_ENDPOINT;
|
||||
export class S3Provider implements StorageProvider {
|
||||
private config: StorageConfig;
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
// TODO: set region to 'auto' if not set???
|
||||
if (!region) {
|
||||
throw new ConfigurationError('STORAGE_REGION environment variable is not set');
|
||||
constructor(config: StorageConfig = storageConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
const clientOptions: any = {
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Add custom endpoint for S3-compatible services like Cloudflare R2
|
||||
if (endpoint) {
|
||||
clientOptions.endpoint = endpoint;
|
||||
// For services like R2 that don't use path-style URLs
|
||||
if (process.env.STORAGE_FORCE_PATH_STYLE === 'false') {
|
||||
clientOptions.forcePathStyle = false;
|
||||
} else {
|
||||
clientOptions.forcePathStyle = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new S3Client(clientOptions);
|
||||
};
|
||||
|
||||
// Generate a unique filename with the original extension
|
||||
const generateUniqueFilename = (originalFilename: string): string => {
|
||||
const extension = originalFilename.split('.').pop() || '';
|
||||
const uuid = randomUUID();
|
||||
return `${uuid}${extension ? `.${extension}` : ''}`;
|
||||
};
|
||||
|
||||
// Upload a file to S3
|
||||
export const uploadFile = async (
|
||||
file: Buffer | Blob,
|
||||
originalFilename: string,
|
||||
contentType: string,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
console.error('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
/**
|
||||
* Get the S3 client instance
|
||||
*/
|
||||
private getS3Client(): S3Client {
|
||||
if (this.s3Client) {
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
const filename = generateUniqueFilename(originalFilename);
|
||||
const key = folder ? `${folder}/${filename}` : filename;
|
||||
const { region, endpoint, accessKeyId, secretAccessKey, forcePathStyle } = this.config;
|
||||
|
||||
// Convert Blob to Buffer if needed
|
||||
let fileBuffer: Buffer;
|
||||
if (file instanceof Blob) {
|
||||
fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
} else {
|
||||
fileBuffer = file;
|
||||
if (!region) {
|
||||
throw new ConfigurationError('Storage region is not configured');
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileBuffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
// Generate the URL
|
||||
const publicUrl = process.env.STORAGE_PUBLIC_URL;
|
||||
let url: string;
|
||||
|
||||
if (publicUrl) {
|
||||
// Use custom domain if provided
|
||||
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
|
||||
console.log('uploadFile, public url', url);
|
||||
} else {
|
||||
// Generate a pre-signed URL if no public URL is provided
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
url = await getSignedUrl(s3, getCommand, { expiresIn: 3600 * 24 * 7 }); // 7 days
|
||||
console.log('uploadFile, signed url', url);
|
||||
}
|
||||
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
console.error('ConfigurationError', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
|
||||
throw new UploadError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a file from S3
|
||||
export const deleteFile = async (key: string): Promise<void> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
}
|
||||
|
||||
const command = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
const clientOptions: any = {
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
};
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
...command,
|
||||
Body: '',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('deleteFile', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file deletion';
|
||||
throw new StorageError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate a pre-signed URL for direct browser uploads
|
||||
export const getPresignedUploadUrl = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder?: string,
|
||||
expiresIn: number = 3600 // 1 hour default
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
// Add custom endpoint for S3-compatible services like Cloudflare R2
|
||||
if (endpoint) {
|
||||
clientOptions.endpoint = endpoint;
|
||||
clientOptions.forcePathStyle = forcePathStyle !== false;
|
||||
}
|
||||
|
||||
const key = folder ? `${folder}/${filename}` : filename;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, command, { expiresIn });
|
||||
console.log('getPresignedUploadUrl', url);
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
console.error('getPresignedUploadUrl', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred while generating presigned URL';
|
||||
throw new StorageError(message);
|
||||
this.s3Client = new S3Client(clientOptions);
|
||||
return this.s3Client;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique filename with the original extension
|
||||
*/
|
||||
private generateUniqueFilename(originalFilename: string): string {
|
||||
const extension = originalFilename.split('.').pop() || '';
|
||||
const uuid = randomUUID();
|
||||
return `${uuid}${extension ? `.${extension}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3
|
||||
*/
|
||||
public async uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
|
||||
try {
|
||||
const { file, filename, contentType, folder } = params;
|
||||
const s3 = this.getS3Client();
|
||||
const { bucketName } = this.config;
|
||||
|
||||
if (!bucketName) {
|
||||
throw new ConfigurationError('Storage bucket name is not configured');
|
||||
}
|
||||
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
const key = folder ? `${folder}/${uniqueFilename}` : uniqueFilename;
|
||||
|
||||
// Convert Blob to Buffer if needed
|
||||
let fileBuffer: Buffer;
|
||||
if (file instanceof Blob) {
|
||||
fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
} else {
|
||||
fileBuffer = file;
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Body: fileBuffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
// Generate the URL
|
||||
const { publicUrl } = this.config;
|
||||
let url: string;
|
||||
|
||||
if (publicUrl) {
|
||||
// Use custom domain if provided
|
||||
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
|
||||
console.log('uploadFile, public url', url);
|
||||
} else {
|
||||
// Generate a pre-signed URL if no public URL is provided
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
});
|
||||
url = await getSignedUrl(s3, getCommand, { expiresIn: 3600 * 24 * 7 }); // 7 days
|
||||
console.log('uploadFile, signed url', url);
|
||||
}
|
||||
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
console.error('uploadFile, configuration error', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
|
||||
console.error('uploadFile, error', message);
|
||||
throw new UploadError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from S3
|
||||
*/
|
||||
public async deleteFile(key: string): Promise<void> {
|
||||
try {
|
||||
const s3 = this.getS3Client();
|
||||
const { bucketName } = this.config;
|
||||
|
||||
if (!bucketName) {
|
||||
throw new ConfigurationError('Storage bucket name is not configured');
|
||||
}
|
||||
|
||||
const command = {
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
};
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
...command,
|
||||
Body: '',
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file deletion';
|
||||
console.error('deleteFile, error', message);
|
||||
throw new StorageError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pre-signed URL for direct browser uploads
|
||||
*/
|
||||
public async getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult> {
|
||||
try {
|
||||
const { filename, contentType, folder, expiresIn = 3600 } = params;
|
||||
const s3 = this.getS3Client();
|
||||
const { bucketName } = this.config;
|
||||
|
||||
if (!bucketName) {
|
||||
throw new ConfigurationError('Storage bucket name is not configured');
|
||||
}
|
||||
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
const key = folder ? `${folder}/${uniqueFilename}` : uniqueFilename;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, command, { expiresIn });
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred while generating presigned URL';
|
||||
console.error('getPresignedUploadUrl, error', message);
|
||||
throw new StorageError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider name
|
||||
*/
|
||||
public getProviderName(): string {
|
||||
return 'S3';
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
export { ConfigurationError, StorageError, UploadError } from '../types';
|
||||
|
||||
|
89
src/storage/types.ts
Normal file
89
src/storage/types.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Storage configuration
|
||||
*/
|
||||
export interface StorageConfig {
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
publicUrl?: string;
|
||||
forcePathStyle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage provider error types
|
||||
*/
|
||||
export class StorageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UploadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file parameters
|
||||
*/
|
||||
export interface UploadFileParams {
|
||||
file: Buffer | Blob;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file result
|
||||
*/
|
||||
export interface UploadFileResult {
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presigned upload URL parameters
|
||||
*/
|
||||
export interface PresignedUploadUrlParams {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
folder?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage provider interface
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
/**
|
||||
* Upload a file to storage
|
||||
*/
|
||||
uploadFile(params: UploadFileParams): Promise<UploadFileResult>;
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
*/
|
||||
deleteFile(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Generate a pre-signed URL for client-side uploads
|
||||
*/
|
||||
getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult>;
|
||||
|
||||
/**
|
||||
* Get the provider's name
|
||||
*/
|
||||
getProviderName(): string;
|
||||
}
|
Loading…
Reference in New Issue
Block a user