Kontextlora.me (Raphael Starter Kit) - 完整项目代码结构和解析
目录
项目概述
本模版是一个基于 Raphael Starter Kit 的现代化 SaaS 应用启动模板,专门为快速构建具有全球认证和支付功能的应用而设计。该项目特别优化了中国市场的支付体验(通过 Creem.io)。
主要特性
- 🔐 完整的认证系统 - 邮箱/密码和 OAuth (Google, GitHub)
- 💳 灵活的支付系统 - 订阅制和积分制双模式
- 🌓 深色模式支持 - 系统级主题切换
- 📱 响应式设计 - 移动端优先
- 🔒 安全第一 - RLS、webhook 验证、安全会话管理
- ⚡ 高性能 - Next.js 14+、服务端组件、优化的构建
技术栈
前端技术
- Next.js 14+ - React 框架,使用 App Router
- React 19 - UI 库
- TypeScript 5.7 - 类型安全
- Tailwind CSS 3.4 - 原子化 CSS 框架
- shadcn/ui - 基于 Radix UI 的组件库
后端技术
- Supabase - 认证、数据库(PostgreSQL)、实时功能
- Creem.io - 支付处理(针对中国市场优化)
开发工具
- ESLint - 代码规范
- Prettier - 代码格式化
- PostCSS - CSS 处理
项目结构
kontextlora.me/
├── app/ # Next.js App Router 目录
│ ├── (auth-pages)/ # 认证页面组
│ │ ├── forgot-password/ # 忘记密码页面
│ │ ├── sign-in/ # 登录页面
│ │ └── sign-up/ # 注册页面
│ ├── api/ # API 路由
│ │ ├── creem/ # Creem 支付集成
│ │ └── webhooks/ # Webhook 处理器
│ ├── auth/ # 认证回调路由
│ ├── dashboard/ # 受保护的仪表板区域
│ ├── actions.ts # 服务器动作
│ ├── globals.css # 全局样式
│ ├── layout.tsx # 根布局
│ └── page.tsx # 首页
│
├── components/ # React 组件
│ ├── ui/ # 基础 UI 组件
│ ├── dashboard/ # 仪表板特定组件
│ ├── home/ # 首页区块组件
│ ├── header.tsx # 页头组件
│ ├── footer.tsx # 页脚组件
│ └── ... # 其他组件
│
├── config/ # 配置文件
│ └── subscriptions.ts # 定价层级配置
│
├── hooks/ # 自定义 React Hooks
│ ├── use-subscription.ts
│ ├── use-toast.ts
│ └── use-user.ts
│
├── lib/ # 工具库
│ └── utils.ts # 通用工具函数
│
├── public/ # 静态资源
│ └── images/ # 图片资源
│
├── supabase/ # 数据库迁移
│ └── migrations/ # SQL 架构文件
│
├── types/ # TypeScript 类型定义
│ ├── creem.ts # 支付类型
│ └── subscriptions.ts # 订阅类型
│
├── utils/ # 工具函数
│ ├── creem/ # Creem 支付工具
│ ├── supabase/ # Supabase 客户端工具
│ └── utils.ts # 其他工具函数
│
└── [配置文件] # 项目配置
├── package.json # 依赖管理
├── tsconfig.json # TypeScript 配置
├── tailwind.config.ts # Tailwind CSS 配置
├── middleware.ts # Next.js 中间件
└── .env.example # 环境变量模板
核心配置文件解析
package.json 解析
{
"private": true, // 第1行:标记为私有包,防止意外发布到 npm
"scripts": { // 第3-7行:定义可执行脚本
"dev": "next dev", // 启动开发服务器
"build": "next build", // 构建生产版本
"start": "next start" // 启动生产服务器
},
"dependencies": { // 第8-29行:生产依赖
"@headlessui/react": "^2.2.0", // 无样式的 UI 组件库
"@heroicons/react": "^2.2.0", // Hero 图标库
"@radix-ui/react-avatar": "^1.1.3", // 头像组件
"@radix-ui/react-checkbox": "^1.1.1", // 复选框组件
"@radix-ui/react-dialog": "^1.1.6", // 对话框组件
"@radix-ui/react-dropdown-menu": "^2.1.1", // 下拉菜单组件
"@radix-ui/react-label": "^2.1.0", // 标签组件
"@radix-ui/react-slot": "^1.1.0", // 插槽组件(多态组件)
"@radix-ui/react-toast": "^1.2.6", // 提示组件
"@supabase/ssr": "latest", // Supabase SSR 支持
"@supabase/supabase-js": "latest", // Supabase 客户端
"autoprefixer": "10.4.20", // CSS 自动前缀
"class-variance-authority": "^0.7.0", // 组件变体管理
"clsx": "^2.1.1", // 条件类名工具
"lucide-react": "^0.468.0", // Lucide 图标库
"next": "latest", // Next.js 框架
"next-themes": "^0.4.3", // 主题管理
"prettier": "^3.3.3", // 代码格式化
"react": "19.0.0", // React 库
"react-dom": "19.0.0" // React DOM
},
"devDependencies": { // 第30-39行:开发依赖
"@types/node": "22.10.2", // Node.js 类型定义
"@types/react": "^19.0.2", // React 类型定义
"@types/react-dom": "19.0.2", // React DOM 类型定义
"postcss": "8.4.49", // CSS 处理器
"tailwind-merge": "^2.5.2", // Tailwind 类合并
"tailwindcss": "3.4.17", // Tailwind CSS
"tailwindcss-animate": "^1.0.7", // Tailwind 动画插件
"typescript": "5.7.2" // TypeScript 编译器
}
}
tsconfig.json 解析
{
"compilerOptions": {
"target": "es5", // 第3行:编译目标为 ES5,确保浏览器兼容性
"lib": ["dom", "dom.iterable", "esnext"], // 第4行:包含的类型库
"allowJs": true, // 第5行:允许编译 JS 文件
"skipLibCheck": true, // 第6行:跳过声明文件检查,提高编译速度
"strict": true, // 第7行:启用所有严格类型检查选项
"forceConsistentCasingInFileNames": true, // 第8行:强制文件名大小写一致
"noEmit": true, // 第9行:不输出编译文件(Next.js 处理)
"esModuleInterop": true, // 第10行:启用 ES 模块互操作
"module": "esnext", // 第11行:使用最新的模块系统
"moduleResolution": "node", // 第12行:使用 Node.js 模块解析策略
"resolveJsonModule": true, // 第13行:允许导入 JSON 文件
"isolatedModules": true, // 第14行:确保每个文件可独立编译
"jsx": "preserve", // 第15行:保留 JSX,由 Next.js 处理
"incremental": true, // 第16行:启用增量编译
"plugins": [
{
"name": "next" // 第19行:Next.js TypeScript 插件
}
],
"paths": {
"@/*": ["./*"] // 第23行:路径别名,@ 指向项目根目录
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], // 第26行:包含的文件
"exclude": ["node_modules"] // 第27行:排除 node_modules
}
tailwind.config.ts 解析
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"], // 第4行:使用类名控制深色模式
content: [ // 第5-10行:需要处理的文件路径
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "", // 第11行:CSS 类前缀(空表示无前缀)
theme: {
container: { // 第13-19行:容器配置
center: true, // 居中对齐
padding: "2rem", // 内边距
screens: {
"2xl": "1400px", // 2xl 断点最大宽度
},
},
extend: { // 第20行:扩展默认主题
colors: { // 第21-54行:自定义颜色系统
border: "hsl(var(--border))", // 边框颜色
input: "hsl(var(--input))", // 输入框颜色
ring: "hsl(var(--ring))", // 聚焦环颜色
background: "hsl(var(--background))", // 背景颜色
foreground: "hsl(var(--foreground))", // 前景颜色
primary: { // 主色调
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: { // 次要色调
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: { // 危险/删除色调
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: { // 柔和色调
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: { // 强调色调
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: { // 弹出框色调
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: { // 卡片色调
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: { // 第56-60行:自定义圆角
lg: "var(--radius)", // 大圆角
md: "calc(var(--radius) - 2px)", // 中圆角
sm: "calc(var(--radius) - 4px)", // 小圆角
},
keyframes: { // 第61-69行:自定义动画关键帧
"accordion-down": { // 手风琴展开动画
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": { // 手风琴收起动画
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: { // 第71-74行:动画配置
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")], // 第77行:使用动画插件
} satisfies Config; // 第78行:类型检查
export default config;
.env.example 解析
# 第1-3行:Supabase 配置
# 从 Supabase 项目设置 > API 获取这些值
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL= # Supabase 项目 URL(公开)
NEXT_PUBLIC_SUPABASE_ANON_KEY= # Supabase 匿名密钥(公开)
# 第6-7行:Supabase 服务密钥
# 从项目 API 设置获取(service_role key)
SUPABASE_SERVICE_ROLE_KEY= # 服务角色密钥(私密)
# 第9-11行:Creem 支付配置
CREEM_WEBHOOK_SECRET= # Webhook 签名密钥
CREEM_API_KEY= # Creem API 密钥
CREEM_API_URL=https://test-api.creem.io/v1 # Creem API 地址
# 第13-14行:站点配置
# 用于认证回调的站点 URL
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# 第16-17行:支付成功后的重定向 URL
CREEM_SUCCESS_URL=http://localhost:3000/dashboard
应用架构解析
根布局 (app/layout.tsx)
import Header from "@/components/header"; // 第1行:导入页头组件
import { Footer } from "@/components/footer"; // 第2行:导入页脚组件
import { Geist } from "next/font/google"; // 第3行:导入 Google 字体
import { ThemeProvider } from "next-themes"; // 第4行:导入主题提供者
import { createClient } from "@/utils/supabase/server"; // 第5行:导入 Supabase 客户端
import { Toaster } from "@/components/ui/toaster"; // 第6行:导入提示组件
import "./globals.css"; // 第7行:导入全局样式
// 第9-11行:确定基础 URL,用于元数据
const baseUrl = process.env.BASE_URL
? `https://${process.env.BASE_URL}`
: "http://localhost:3000";
// 第13-17行:导出元数据,用于 SEO
export const metadata = {
metadataBase: new URL(baseUrl), // 基础 URL
title: "Raphael Starter Kit", // 页面标题
description: "The fastest way to build apps with global authentication and payments", // 描述
};
// 第19-22行:配置 Geist 字体
const geistSans = Geist({
display: "swap", // 字体显示策略
subsets: ["latin"], // 字符子集
});
// 第24-28行:根布局组件定义
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode; // 子组件类型
}>) {
const supabase = await createClient(); // 第29行:创建 Supabase 客户端
const {
data: { user }, // 第31行:获取当前用户
} = await supabase.auth.getUser(); // 第32行:调用获取用户 API
return (
// 第35行:HTML 根元素,应用字体,抑制水合警告
<html lang="en" className={geistSans.className} suppressHydrationWarning>
{/* 第36行:body 元素,设置背景和文字颜色 */}
<body className="bg-background text-foreground">
{/* 第37-42行:主题提供者配置 */}
<ThemeProvider
attribute="class" // 使用 class 属性控制主题
defaultTheme="system" // 默认跟随系统主题
enableSystem // 启用系统主题检测
disableTransitionOnChange // 切换时禁用过渡动画
>
{/* 第43行:主容器,使用相对定位和最小高度 */}
<div className="relative min-h-screen">
<Header user={user} /> {/* 第44行:页头,传入用户信息 */}
<main className="flex-1">{children}</main> {/* 第45行:主内容区 */}
<Footer /> {/* 第46行:页脚 */}
</div>
<Toaster /> {/* 第48行:全局提示组件 */}
</ThemeProvider>
</body>
</html>
);
}
中间件 (middleware.ts)
import { type NextRequest } from "next/server"; // 第1行:导入请求类型
import { updateSession } from "@/utils/supabase/middleware"; // 第2行:导入会话更新函数
// 第4-6行:中间件函数,处理每个请求
export async function middleware(request: NextRequest) {
return await updateSession(request); // 更新/刷新用户会话
}
// 第8-20行:中间件配置
export const config = {
matcher: [ // 匹配器配置
/*
* 匹配所有请求路径,除了:
* - _next/static (静态文件)
* - _next/image (图片优化文件)
* - favicon.ico (网站图标)
* - 图片文件 - .svg, .png, .jpg, .jpeg, .gif, .webp
* 可以根据需要修改此模式以包含更多路径
*/
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
主页 (app/page.tsx)
import Hero from "@/components/home/hero"; // 第1行:导入主视觉区组件
import Features from "@/components/home/features"; // 第2行:导入特性展示组件
import Stats from "@/components/home/stats"; // 第3行:导入统计数据组件
import Pricing from "@/components/home/pricing"; // 第4行:导入价格组件
import FAQ from "@/components/home/faq"; // 第5行:导入常见问题组件
import Contact from "@/components/home/contact"; // 第6行:导入联系组件
import LogoCloud from "@/components/home/logocloud"; // 第7行:导入客户标志组件
// 第9-21行:主页组件
export default async function Home() {
return (
// 第11行:使用 flex 布局,响应式间距
<div className="flex flex-col gap-8 md:gap-12 lg:gap-24">
<Hero /> {/* 第12行:主视觉区 */}
<LogoCloud /> {/* 第13行:客户标志云 */}
<Features /> {/* 第14行:特性展示 */}
<Stats /> {/* 第15行:统计数据 */}
<Pricing /> {/* 第16行:价格方案 */}
<FAQ /> {/* 第17行:常见问题 */}
<Contact /> {/* 第18行:联系方式 */}
</div>
);
}
认证系统详解
服务器动作 (app/actions.ts)
"use server"; // 第1行:标记为服务器端代码
import { encodedRedirect } from "@/utils/utils"; // 第3行:导入编码重定向函数
import { createClient } from "@/utils/supabase/server"; // 第4行:导入 Supabase 客户端
import { headers } from "next/headers"; // 第5行:导入请求头
import { redirect } from "next/navigation"; // 第6行:导入重定向函数
// 第8-36行:注册动作
export const signUpAction = async (formData: FormData) => {
const email = formData.get("email")?.toString(); // 第9行:获取邮箱
const password = formData.get("password")?.toString(); // 第10行:获取密码
const supabase = await createClient(); // 第11行:创建客户端
const origin = (await headers()).get("origin"); // 第12行:获取源地址
// 第14-20行:验证必填字段
if (!email || !password) {
return encodedRedirect(
"error", // 消息类型
"/sign-up", // 重定向路径
"Email and password are required" // 错误消息
);
}
// 第22-28行:调用 Supabase 注册 API
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`, // 邮件确认回调地址
},
});
// 第30-35行:处理结果
if (error) {
console.error(error.code + " " + error.message); // 记录错误
return encodedRedirect("error", "/sign-up", error.message);
} else {
return encodedRedirect("success", "/dashboard", "Thanks for signing up!");
}
};
// 第38-53行:登录动作
export const signInAction = async (formData: FormData) => {
const email = formData.get("email") as string; // 第39行:获取邮箱
const password = formData.get("password") as string; // 第40行:获取密码
const supabase = await createClient(); // 第41行:创建客户端
// 第43-46行:使用邮箱密码登录
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
// 第48-52行:处理结果
if (error) {
return encodedRedirect("error", "/sign-in", error.message);
}
return redirect("/dashboard"); // 成功后直接重定向
};
// 第55-87行:忘记密码动作
export const forgotPasswordAction = async (formData: FormData) => {
const email = formData.get("email")?.toString(); // 第56行:获取邮箱
const supabase = await createClient(); // 第57行:创建客户端
const origin = (await headers()).get("origin"); // 第58行:获取源地址
const callbackUrl = formData.get("callbackUrl")?.toString(); // 第59行:获取回调URL
// 第61-63行:验证邮箱
if (!email) {
return encodedRedirect("error", "/forgot-password", "Email is required");
}
// 第65-67行:发送重置密码邮件
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/dashboard/reset-password`,
});
// 第69-86行:处理结果
if (error) {
console.error(error.message);
return encodedRedirect(
"error",
"/forgot-password",
"Could not reset password"
);
}
if (callbackUrl) {
return redirect(callbackUrl); // 如果有回调URL,重定向
}
return encodedRedirect(
"success",
"/forgot-password",
"Check your email for a link to reset your password."
);
};
// 第89-124行:重置密码动作
export const resetPasswordAction = async (formData: FormData) => {
const supabase = await createClient(); // 第90行:创建客户端
const password = formData.get("password") as string; // 第92行:获取新密码
const confirmPassword = formData.get("confirmPassword") as string; // 第93行:获取确认密码
// 第95-101行:验证密码字段
if (!password || !confirmPassword) {
encodedRedirect(
"error",
"/dashboard/reset-password",
"Password and confirm password are required"
);
}
// 第103-109行:验证密码匹配
if (password !== confirmPassword) {
encodedRedirect(
"error",
"/dashboard/reset-password",
"Passwords do not match"
);
}
// 第111-113行:更新用户密码
const { error } = await supabase.auth.updateUser({
password: password,
});
// 第115-123行:处理结果
if (error) {
encodedRedirect(
"error",
"/dashboard/reset-password",
"Password update failed"
);
}
encodedRedirect("success", "/dashboard/reset-password", "Password updated");
};
// 第126-130行:登出动作
export const signOutAction = async () => {
const supabase = await createClient(); // 第127行:创建客户端
await supabase.auth.signOut(); // 第128行:调用登出API
return redirect("/sign-in"); // 第129行:重定向到登录页
};
// 第132-183行:创建支付会话
export async function createCheckoutSession(
productId: string, // 产品ID
email: string, // 用户邮箱
userId: string, // 用户ID
productType: "subscription" | "credits", // 产品类型
credits_amount?: number, // 积分数量(可选)
discountCode?: string // 折扣码(可选)
) {
try {
// 第141-152行:构建请求体
const requestBody: any = {
product_id: productId,
// request_id: `${userId}-${Date.now()}`, // 可选:唯一请求ID
customer: {
email: email, // 客户邮箱
},
metadata: { // 元数据
user_id: userId, // 用户ID
product_type: productType, // 产品类型
credits: credits_amount || 0, // 积分数量
},
};
// 第154-157行:添加成功重定向URL(如果配置)
if (process.env.CREEM_SUCCESS_URL) {
requestBody.success_url = process.env.CREEM_SUCCESS_URL;
}
// 第159-162行:添加折扣码(如果提供)
if (discountCode) {
requestBody.discount_code = discountCode;
}
// 第164-171行:调用 Creem API 创建结账会话
const response = await fetch(process.env.CREEM_API_URL + "/checkouts", {
method: "POST",
headers: {
"x-api-key": process.env.CREEM_API_KEY!, // API密钥
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
// 第173-175行:检查响应状态
if (!response.ok) {
throw new Error("Failed to create checkout session");
}
// 第177-178行:返回结账URL
const data = await response.json();
return data.checkout_url;
} catch (error) {
// 第179-182行:错误处理
console.error("Error creating checkout session:", error);
throw error;
}
}
登录页面 (app/(auth-pages)/sign-in/page.tsx)
import Link from "next/link";
import { signInAction } from "@/app/actions";
import { FormMessage, Message } from "@/components/form-message";
import { SubmitButton } from "@/components/submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import GoogleSignInButton from "@/components/google-signin-button";
export default async function Login(props: { searchParams: Promise<Message> }) {
const searchParams = await props.searchParams;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-medium">Sign in</h1>
<p className="text-sm text-muted-foreground">
Sign in to your account to continue
</p>
</div>
{/* OAuth 登录 */}
<GoogleSignInButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
{/* 邮箱密码登录表单 */}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
required
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
className="text-xs text-muted-foreground hover:text-foreground"
href="/forgot-password"
>
Forgot Password?
</Link>
</div>
<Input
id="password"
type="password"
name="password"
placeholder="Your password"
required
/>
</div>
<SubmitButton pendingText="Signing In..." formAction={signInAction}>
Sign in
</SubmitButton>
<FormMessage message={searchParams} />
</form>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link
className="text-foreground font-medium hover:underline"
href="/sign-up"
>
Sign up
</Link>
</p>
</div>
);
}
支付系统详解
Webhook 处理器 (app/api/webhooks/creem/route.ts)
import { NextRequest, NextResponse } from 'next/server';
import { verifyCreemWebhookSignature } from '@/utils/creem/verify-signature';
import { createServiceRoleClient } from '@/utils/supabase/service-role';
import {
createOrUpdateCustomer,
createOrUpdateSubscription,
addCreditsToCustomer
} from '@/utils/supabase/subscriptions';
import type { CreemWebhookEvent, CreemCheckout } from '@/types/creem';
export async function POST(req: NextRequest) {
try {
// 获取原始请求体和签名
const body = await req.text();
const signature = req.headers.get('x-creem-signature');
// 验证 webhook 签名
if (!signature || !process.env.CREEM_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Missing signature or secret' }, { status: 401 });
}
const isValid = await verifyCreemWebhookSignature(
body,
signature,
process.env.CREEM_WEBHOOK_SECRET
);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// 解析事件数据
const event: CreemWebhookEvent = JSON.parse(body);
// 处理不同的事件类型
switch (event.type) {
case 'checkout.completed':
await handleCheckoutCompleted(event.data as CreemCheckout);
break;
case 'subscription.active':
case 'subscription.paid':
case 'subscription.canceled':
case 'subscription.expired':
await handleSubscriptionUpdate(event);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
}
}
// 处理结账完成事件
async function handleCheckoutCompleted(checkout: CreemCheckout) {
const { customer, order, metadata } = checkout;
const userId = metadata?.user_id;
if (!userId) {
throw new Error('No user_id in checkout metadata');
}
// 创建或更新客户
const dbCustomer = await createOrUpdateCustomer(customer, userId);
// 根据产品类型处理
if (metadata.product_type === 'subscription' && order.subscription) {
// 处理订阅
await createOrUpdateSubscription(order.subscription, dbCustomer.id);
} else if (metadata.product_type === 'credits' && metadata.credits) {
// 处理积分充值
await addCreditsToCustomer(
dbCustomer.id,
metadata.credits,
order.id,
`Credit purchase - Order ${order.order_number}`
);
}
}
// 处理订阅更新事件
async function handleSubscriptionUpdate(event: CreemWebhookEvent) {
const supabase = createServiceRoleClient();
const subscription = event.data;
// 查找对应的数据库记录
const { data: dbSubscription } = await supabase
.from('subscriptions')
.select('*')
.eq('creem_subscription_id', subscription.id)
.single();
if (dbSubscription) {
// 更新订阅状态
await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_start: subscription.current_period_start,
current_period_end: subscription.current_period_end,
canceled_at: subscription.canceled_at,
updated_at: new Date().toISOString()
})
.eq('id', dbSubscription.id);
}
}
支付配置 (config/subscriptions.ts)
export const subscriptionTiers = [
{
id: "starter",
name: "Starter",
description: "Perfect for individuals and small projects",
price: 11,
currency: "USD",
interval: "month",
creemProductId: "YOUR_CREEM_STARTER_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
features: [
"1 user",
"5 projects",
"Basic support",
"1GB storage",
"Basic analytics"
],
},
{
id: "business",
name: "Business",
description: "Great for growing teams and businesses",
price: 29,
currency: "USD",
interval: "month",
creemProductId: "YOUR_CREEM_BUSINESS_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
features: [
"5 users",
"Unlimited projects",
"Priority support",
"10GB storage",
"Advanced analytics",
"API access",
"Custom integrations"
],
recommended: true,
},
{
id: "enterprise",
name: "Enterprise",
description: "For large teams with advanced needs",
price: 99,
currency: "USD",
interval: "month",
creemProductId: "YOUR_CREEM_ENTERPRISE_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
features: [
"Unlimited users",
"Unlimited projects",
"24/7 phone support",
"100GB storage",
"Advanced analytics",
"API access",
"Custom integrations",
"SSO/SAML",
"Dedicated account manager"
],
},
];
export const creditPackages = [
{
id: "basic",
name: "Basic Pack",
credits: 3,
price: 9,
currency: "USD",
creemProductId: "YOUR_CREEM_BASIC_CREDITS_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
},
{
id: "standard",
name: "Standard Pack",
credits: 6,
price: 13,
currency: "USD",
creemProductId: "YOUR_CREEM_STANDARD_CREDITS_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
recommended: true,
},
{
id: "premium",
name: "Premium Pack",
credits: 9,
price: 29,
currency: "USD",
creemProductId: "YOUR_CREEM_PREMIUM_CREDITS_PRODUCT_ID", // 替换为实际的 Creem 产品 ID
},
];
数据库架构
数据库迁移文件 (supabase/migrations/20240326000000_init_tables.sql)
-- 第1-2行:启用 RLS (行级别安全)
alter table auth.users enable row level security;
-- 第4-18行:创建客户表,链接 Supabase 用户和 Creem 客户
create table public.customers (
id uuid primary key default uuid_generate_v4(), -- 主键
user_id uuid references auth.users(id) on delete cascade not null, -- 用户ID外键
creem_customer_id text not null unique, -- Creem客户ID
email text not null, -- 邮箱
name text, -- 姓名
country text, -- 国家
credits integer default 0 not null, -- 积分余额
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null,
metadata jsonb default '{}'::jsonb, -- 元数据
constraint customers_email_match check (email = lower(email)), -- 邮箱小写约束
constraint credits_non_negative check (credits >= 0) -- 积分非负约束
);
-- 第20-30行:创建积分历史表,跟踪积分交易
create table public.credits_history (
id uuid primary key default uuid_generate_v4(),
customer_id uuid references public.customers(id) on delete cascade not null,
amount integer not null, -- 变动数量
type text not null check (type in ('add', 'subtract')), -- 类型:增加或减少
description text, -- 描述
creem_order_id text, -- Creem订单ID
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
metadata jsonb default '{}'::jsonb
);
-- 第32-46行:创建订阅表
create table public.subscriptions (
id uuid primary key default uuid_generate_v4(),
customer_id uuid references public.customers(id) on delete cascade not null,
creem_subscription_id text not null unique, -- Creem订阅ID
creem_product_id text not null, -- Creem产品ID
status text not null check (status in ( -- 订阅状态枚举
'incomplete', 'expired', 'active', 'past_due',
'canceled', 'unpaid', 'paused', 'trialing'
)),
current_period_start timestamp with time zone not null, -- 当前计费周期开始
current_period_end timestamp with time zone not null, -- 当前计费周期结束
canceled_at timestamp with time zone, -- 取消时间
trial_end timestamp with time zone, -- 试用结束时间
metadata jsonb default '{}'::jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 第48-56行:创建索引以提高查询性能
create index customers_user_id_idx on public.customers(user_id);
create index customers_creem_customer_id_idx on public.customers(creem_customer_id);
create index subscriptions_customer_id_idx on public.subscriptions(customer_id);
create index subscriptions_status_idx on public.subscriptions(status);
create index credits_history_customer_id_idx on public.credits_history(customer_id);
create index credits_history_created_at_idx on public.credits_history(created_at);
-- 第58-65行:创建更新时间触发器函数
create or replace function public.handle_updated_at()
returns trigger as $$
begin
new.updated_at = timezone('utc'::text, now()); -- 更新时间戳
return new;
end;
$$ language plpgsql security definer;
-- 第67-76行:创建更新时间触发器
create trigger handle_customers_updated_at
before update on public.customers
for each row
execute function public.handle_updated_at();
create trigger handle_subscriptions_updated_at
before update on public.subscriptions
for each row
execute function public.handle_updated_at();
-- 第78-127行:创建 RLS 策略
-- 客户表策略
create policy "Users can view their own customer data"
on public.customers for select
using (auth.uid() = user_id); -- 用户只能查看自己的数据
create policy "Users can update their own customer data"
on public.customers for update
using (auth.uid() = user_id); -- 用户只能更新自己的数据
create policy "Service role can manage customer data"
on public.customers for all
using (auth.role() = 'service_role'); -- 服务角色可以管理所有数据
-- 订阅表策略
create policy "Users can view their own subscriptions"
on public.subscriptions for select
using (
exists (
select 1 from public.customers
where customers.id = subscriptions.customer_id
and customers.user_id = auth.uid()
)
);
create policy "Service role can manage subscriptions"
on public.subscriptions for all
using (auth.role() = 'service_role');
-- 积分历史表策略
create policy "Users can view their own credits history"
on public.credits_history for select
using (
exists (
select 1 from public.customers
where customers.id = credits_history.customer_id
and customers.user_id = auth.uid()
)
);
create policy "Service role can manage credits history"
on public.credits_history for all
using (auth.role() = 'service_role');
-- 第124-127行:授予服务角色权限
grant all on public.customers to service_role;
grant all on public.subscriptions to service_role;
grant all on public.credits_history to service_role;
UI组件系统
Button 组件 (components/ui/button.tsx)
import * as React from "react"; // 第1行:导入 React
import { Slot } from "@radix-ui/react-slot"; // 第2行:导入 Slot 组件
import { cva, type VariantProps } from "class-variance-authority"; // 第3行:导入 CVA
import { cn } from "@/lib/utils"; // 第5行:导入类名工具
// 第7-34行:定义按钮变体
const buttonVariants = cva(
// 基础样式
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: { // 样式变体
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: { // 尺寸变体
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: { // 默认变体
variant: "default",
size: "default",
},
},
);
// 第36-40行:按钮属性接口
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; // 是否作为子组件
}
// 第42-53行:按钮组件
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"; // 选择组件类型
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button"; // 第54行:设置显示名称
export { Button, buttonVariants }; // 第56行:导出组件和变体
Toast 组件系统
Toast 系统包含多个组件协同工作:
- Toast Provider - 管理 toast 状态
- Toast - 单个 toast 组件
- Toaster - 渲染所有 toasts
- useToast Hook - 管理 toast 操作
// hooks/use-toast.ts
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
工具函数和Hooks
通用工具函数 (lib/utils.ts)
import { clsx, type ClassValue } from "clsx"; // 第1行:导入 clsx
import { twMerge } from "tailwind-merge"; // 第2行:导入 tailwind-merge
// 第4-6行:类名合并函数
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); // 合并并去重 Tailwind 类名
}
useSubscription Hook
import { useEffect, useState } from 'react';
import { createClient } from '@/utils/supabase/client';
import { getUserSubscription } from '@/utils/supabase/subscriptions';
export function useSubscription(userId?: string) {
const [isSubscribed, setIsSubscribed] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [willEndOn, setWillEndOn] = useState<Date | null>(null);
const [isInGracePeriod, setIsInGracePeriod] = useState(false);
const [daysLeft, setDaysLeft] = useState(0);
const [loading, setLoading] = useState(true);
const checkSubscription = async () => {
if (!userId) {
setLoading(false);
return;
}
try {
const subscription = await getUserSubscription(userId);
if (subscription) {
setStatus(subscription.status);
// 检查是否在活跃状态
const isActive = ['active', 'trialing'].includes(subscription.status);
setIsSubscribed(isActive);
// 检查是否在宽限期
if (subscription.status === 'canceled' && subscription.current_period_end) {
const endDate = new Date(subscription.current_period_end);
const now = new Date();
if (endDate > now) {
setIsInGracePeriod(true);
setIsSubscribed(true); // 宽限期内仍可访问
setWillEndOn(endDate);
// 计算剩余天数
const msPerDay = 24 * 60 * 60 * 1000;
const days = Math.ceil((endDate.getTime() - now.getTime()) / msPerDay);
setDaysLeft(days);
}
}
// 设置结束日期
if (subscription.trial_end && subscription.status === 'trialing') {
setWillEndOn(new Date(subscription.trial_end));
} else if (subscription.current_period_end) {
setWillEndOn(new Date(subscription.current_period_end));
}
}
} catch (error) {
console.error('Error checking subscription:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
checkSubscription();
}, [userId]);
const refresh = () => {
setLoading(true);
checkSubscription();
};
return {
isSubscribed,
status,
willEndOn,
isInGracePeriod,
daysLeft,
loading,
refresh
};
}
useUser Hook
import { useEffect, useState } from 'react';
import { createClient } from '@/utils/supabase/client';
import type { User } from '@supabase/supabase-js';
export function useUser() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const supabase = createClient();
// 获取初始用户
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
setLoading(false);
};
getUser();
// 监听认证状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);
return () => {
subscription.unsubscribe();
};
}, []);
return { user, loading };
}
部署指南
环境变量配置
-
Supabase 配置 - 在 Supabase Dashboard 创建项目 - 获取
NEXT_PUBLIC_SUPABASE_URL和NEXT_PUBLIC_SUPABASE_ANON_KEY- 获取SUPABASE_SERVICE_ROLE_KEY(保密) -
Creem 配置 - 在 Creem Dashboard 创建账户 - 创建产品并获取产品 ID - 设置 webhook 端点:
https://your-domain.com/api/webhooks/creem- 获取CREEM_API_KEY和CREEM_WEBHOOK_SECRET -
站点配置 - 设置
NEXT_PUBLIC_SITE_URL为你的域名 - 设置CREEM_SUCCESS_URL为支付成功后的重定向地址
数据库设置
-
运行数据库迁移:
bash npx supabase db push -
在 Supabase Dashboard 中启用 Google OAuth: - 导航到 Authentication > Providers - 启用 Google - 添加 OAuth 客户端 ID 和密钥
部署到 Vercel
-
推送代码到 GitHub
-
在 Vercel 导入项目
-
配置环境变量
-
部署
部署后设置
- 更新 Creem webhook URL 为生产地址
- 更新 Supabase 的站点 URL
- 配置自定义域名
- 设置 SSL 证书
总结
Kontextlora.me (Raphael Starter Kit) 是一个功能完整、架构清晰的 SaaS 启动模板。它提供了:
- ✅ 完整的认证系统(邮箱/密码 + OAuth)
- ✅ 灵活的支付系统(订阅 + 积分)
- ✅ 现代化的 UI 组件库
- ✅ 类型安全的开发体验
- ✅ 生产就绪的架构设计
- ✅ 针对中国市场的支付优化
通过本教程,你应该能够: 1. 理解项目的整体架构 2. 掌握各个模块的实现细节 3. 根据需求进行定制开发 4. 成功部署到生产环境
如需进一步的帮助,请参考: - Next.js 文档 - Supabase 文档 - Tailwind CSS 文档 - shadcn/ui 文档
本文档为站内渲染。原始文件本地路径:saas/source/templates/模版-template-raphael-starterkit-v1-main-tutorial-ad80d0.md(仅本地保留,不入库不部署)