知识库首页 模版 tutorial.md

tutorial

本地来源:模版/template/raphael-starterkit-v1-main/tutorial.md

Kontextlora.me (Raphael Starter Kit) - 完整项目代码结构和解析

目录

  1. 项目概述
  2. 技术栈
  3. 项目结构
  4. 核心配置文件解析
  5. 应用架构解析
  6. 认证系统详解
  7. 支付系统详解
  8. 数据库架构
  9. UI组件系统
  10. 工具函数和Hooks
  11. 部署指南

项目概述

本模版是一个基于 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>
            &lt;Link
              className=&quot;text-xs text-muted-foreground hover:text-foreground&quot;
              href=&quot;/forgot-password&quot;
            &gt;
              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?{" "}
        &lt;Link
          className=&quot;text-foreground font-medium hover:underline&quot;
          href=&quot;/sign-up&quot;
        &gt;
          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 系统包含多个组件协同工作:

  1. Toast Provider - 管理 toast 状态
  2. Toast - 单个 toast 组件
  3. Toaster - 渲染所有 toasts
  4. 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 };
}

部署指南

环境变量配置

  1. Supabase 配置 - 在 Supabase Dashboard 创建项目 - 获取 NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY - 获取 SUPABASE_SERVICE_ROLE_KEY(保密)

  2. Creem 配置 - 在 Creem Dashboard 创建账户 - 创建产品并获取产品 ID - 设置 webhook 端点:https://your-domain.com/api/webhooks/creem - 获取 CREEM_API_KEYCREEM_WEBHOOK_SECRET

  3. 站点配置 - 设置 NEXT_PUBLIC_SITE_URL 为你的域名 - 设置 CREEM_SUCCESS_URL 为支付成功后的重定向地址

数据库设置

  1. 运行数据库迁移: bash npx supabase db push

  2. 在 Supabase Dashboard 中启用 Google OAuth: - 导航到 Authentication > Providers - 启用 Google - 添加 OAuth 客户端 ID 和密钥

部署到 Vercel

  1. 推送代码到 GitHub

  2. Vercel 导入项目

  3. 配置环境变量

  4. 部署

部署后设置

  1. 更新 Creem webhook URL 为生产地址
  2. 更新 Supabase 的站点 URL
  3. 配置自定义域名
  4. 设置 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(仅本地保留,不入库不部署)