This commit is contained in:
zhu
2026-05-09 16:50:17 +08:00
parent 2c038e8c0c
commit fd9cf1a52f
13 changed files with 3951 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
{
"file": ".trellis/spec/frontend/index.md",
"reason": "Confirm the implementation follows the expected frontend stack."
}
{
"file": ".trellis/spec/frontend/directory-structure.md",
"reason": "Check billing files live in the expected route-local structure."
}
{
"file": ".trellis/spec/frontend/component-guidelines.md",
"reason": "Review component naming, style isolation, and required comments."
}
{
"file": ".trellis/spec/frontend/state-management.md",
"reason": "Verify existing global stores are reused instead of duplicated."
}
{
"file": ".trellis/spec/frontend/type-safety.md",
"reason": "Check local interfaces and types are documented."
}
{
"file": ".trellis/spec/frontend/quality-guidelines.md",
"reason": "Verify async actions, loading states, and defensive rendering."
}

View File

@@ -0,0 +1,24 @@
{
"file": ".trellis/spec/frontend/index.md",
"reason": "Frontend stack and high-level conventions for Next.js, Zustand, SCSS, and TypeScript."
}
{
"file": ".trellis/spec/frontend/directory-structure.md",
"reason": "Route-local organization and naming conventions for the billing module."
}
{
"file": ".trellis/spec/frontend/component-guidelines.md",
"reason": "Component, style isolation, and documentation rules for new billing UI pieces."
}
{
"file": ".trellis/spec/frontend/state-management.md",
"reason": "Rules for consuming existing Zustand stores and avoiding redundant state."
}
{
"file": ".trellis/spec/frontend/type-safety.md",
"reason": "Type and JSDoc rules for route-local billing interfaces."
}
{
"file": ".trellis/spec/frontend/quality-guidelines.md",
"reason": "Async handling, loading states, defensive data access, and React quality checks."
}

View File

@@ -0,0 +1,22 @@
# Replicate Billing Dashboard Page
## Goal
Rebuild the existing `/web/app/dashboard/billing` page inside `/src/app/dashboard/billing` with equivalent UI and
behavior while adapting the implementation to the current `src` architecture.
## Requirements
- Preserve the billing page content, layout, plan cards, checkout interaction, and checkout-return cleanup behavior from
the `web` implementation.
- Do not copy the `web` structure verbatim when it conflicts with the `src` conventions.
- Reuse existing `src/api`, `src/store`, and utility wrappers whenever they cover the needed behavior.
- Keep route-local UI, hooks, styles, and types inside `src/app/dashboard/billing`.
- Keep async actions guarded by loading states and defensive error handling.
## Acceptance Criteria
- `/dashboard/billing` exists under `src/app`.
- The page renders without TypeScript or build errors.
- Billing-related API calls use existing request helpers and stores where applicable.
- The checkout return cleanup mirrors the current behavior from `web`.

View File

@@ -0,0 +1,26 @@
{
"id": "billing-dashboard-replica",
"name": "billing-dashboard-replica",
"title": "Replicate billing dashboard page",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "tao",
"assignee": "tao",
"createdAt": "2026-05-09",
"completedAt": null,
"branch": null,
"base_branch": "master",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -15,6 +15,7 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"sonner": "^2.0.7",
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {

14
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zustand: zustand:
specifier: ^5.0.13 specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.14)(react@19.2.4) version: 5.0.13(@types/react@19.2.14)(react@19.2.4)
@@ -784,6 +787,12 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1425,6 +1434,11 @@ snapshots:
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
optional: true optional: true
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
styled-jsx@5.1.6(react@19.2.4): styled-jsx@5.1.6(react@19.2.4):

15
src/api/stripe.ts Normal file
View File

@@ -0,0 +1,15 @@
import request from "@/utils/reqeust";
/**
* 创建订单
*/
export async function stripeCheckoutApi() {
return request.post("/stripe/checkout", {agreement_version: 2})
}
/**
* 创建支付链接
*/
export async function stripePortalApi() {
return request.post("/stripe/portal")
}

View File

@@ -94,7 +94,7 @@ const NavTabs = () => {
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation"> <nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
{tabs.map(item => { {tabs.map(item => {
const Icon = item.icon; const Icon = item.icon;
const isActive = pathname.startsWith(item.href); const isActive = pathname == item.href;
return ( return (
<Link <Link

View File

@@ -0,0 +1,212 @@
"use client";
import PlanCard from "./plan-card";
import {Hourglass, Sparkles} from "lucide-react";
import React, {useEffect, useState} from "react";
import Link from "next/link";
import {useSearchParams} from "next/navigation";
import useSubscribeStore from "@/store/subscribe";
import {toast} from "sonner";
import {stripeCheckoutApi, stripePortalApi} from "@/api/stripe";
export default function BillingPage() {
const searchParams = useSearchParams();
const {status, init} = useSubscribeStore();
const [agreed, setAgreed] = useState(false)
const [isProcessing, setIsProcessing] = useState(false);
/**
* 初始化
*/
async function _init() {
const success = searchParams.get('success');
const canceled = searchParams.get('canceled');
if (success) {
toast.success("Payment successful! Syncing your account...");
startStatusPolling(); // 启动轮询同步
}
if (canceled) {
toast.info("Payment canceled.");
}
await init();
}
const startStatusPolling = () => {
let count = 0;
const maxRetries = 5;
const poll = setInterval(async () => {
count++;
await init(); // 刷新状态
const currentStatus = useSubscribeStore.getState().status;
// 如果状态变为了 active或者达到最大尝试次数停止轮询
if (currentStatus === 'active' || count >= maxRetries) {
clearInterval(poll);
if (currentStatus === 'active') toast.success("Account activated!");
}
}, 3000); // 每 3 秒请求一次
};
const handleSubmit = async () => {
// 异常拦截
if (status === "trial" || status === "") {
if (!agreed) {
toast.error("Please agree to the SaaS agreement first.");
return;
}
}
setIsProcessing(true);
try {
let res: any;
// 分支 A: 已经是付费用户或曾付过钱(管理订阅)
if (status === "active" || status === "past_due" || status === "canceled") {
try {
res = await stripePortalApi();
} catch (err: any) {
// 容错:如果 Portal 报 no_customer强制走 Checkout
if (err.response?.data?.data?.error === "no_customer") {
res = await stripeCheckoutApi();
} else {
throw err;
}
}
}
// 分支 B: 新用户或试用期(发起支付)
else {
res = await stripeCheckoutApi();
}
// 获取重定向 URL
const targetUrl = res.url || res.data?.url;
if (targetUrl) {
// 执行跳转,用户将离开当前站点进入 stripe.com
window.location.href = targetUrl;
} else {
toast.error("Failed to generate payment link.");
}
} catch (error: any) {
const msg = error.response?.data?.message || "Internal server error";
toast.error(msg);
} finally {
setIsProcessing(false);
}
};
useEffect(() => {
_init();
}, [searchParams])
return (
<main className="container mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<header className="mb-7 sm:mb-9">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Billing
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-[26px]">
Subscription &amp; invoices
</h1>
</header>
<div className={"grid gap-4 sm:grid-cols-2"}>
<PlanCard icon={<div
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border/60 bg-muted/30 text-foreground/70">
<Hourglass className="h-4 w-4"/>
</div>}
title={"Trial"}
desc={"Try the full thing — 24 hours, on us."}
price={"Free"}
priceUnit={"for 24 hours"}
tipTitle={" What&rsquo;s included"}
tip={[
"Twice-daily AI scans (morning brief + evening recap)",
"Top 3 actions every scan, with RM impact derived from real evidence",
"Telegram alerts for P1 issues — stock-outs, ad waste, ROAS dips",
"Up to 10 competitor URLs tracked daily"
]}
content={
<div className="mt-5">
<div className="text-[13px] text-muted-foreground">
Trial already ended.
</div>
<p className="mt-2 text-[12px] text-muted-foreground">
Auto-pauses when the 24h window closes no charge, no card on file.
</p>
</div>
}/>
<PlanCard
className={"border-2 border-foreground"}
icon={
<div
className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-foreground text-background">
<Sparkles className="h-4 w-4"/>
</div>
}
title={"Pro"}
desc={"Two scans a day, every day — never miss a beat."}
price={"$500"}
priceUnit={"USD / month"}
tipTitle={"What you keep when you subscribe"}
tip={[
"Twice-daily AI scans (morning brief + evening recap)",
"Top 3 actions every scan, with RM impact derived from real evidence",
"Telegram alerts for P1 issues — stock-outs, ad waste, ROAS dips",
"Up to 10 competitor URLs tracked daily"
]}
content={
<div className="mt-5">
<div className="mt-1 text-[12px] text-muted-foreground">
Billed monthly · cancel any time via Stripe&rsquo;s portal.
</div>
<div className="mt-5 flex flex-col gap-2.5">
<button
disabled={isProcessing || !agreed}
className={
!agreed && isProcessing
? 'inline-flex h-11 w-full items-center justify-center rounded-md bg-muted px-5 text-[14px] font-semibold text-muted-foreground cursor-not-allowed'
: 'inline-flex h-11 w-full items-center justify-center rounded-md bg-emerald-600 px-5 text-[14px] font-semibold text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:opacity-50'
}
onClick={handleSubmit}>
Subscribe to keep scanning
</button>
<label
className="flex cursor-pointer items-start gap-2 text-[12px] leading-snug text-muted-foreground">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-pointer rounded border-border accent-emerald-600"
aria-describedby="agreement-version-hint"
/>
<div className="min-w-0 flex-1">
I agree to the{' '}
<Link
href={"/legal/terms"}
target="_blank"
rel="noreferrer noopener"
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80">
SaaS Agreement
</Link>
&amp;
<Link
href={`/legal/terms#schedule-c`}
target="_blank"
rel="noreferrer noopener"
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80">
DPA
</Link>
</div>
</label>
</div>
</div>
}/>
</div>
</main>
)
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import {Check} from "lucide-react";
interface Props {
className?: string,
icon: React.ReactNode,
title: string,
desc: string,
price: string,
priceUnit: string,
content: React.ReactNode,
tipTitle: string,
tip: string[]
}
const PlanCard = (props: Props) => {
return (
<article className={`flex flex-col rounded-xl border border-border bg-card p-6 shadow-sm ${props.className}`}>
{props.icon}
<h3 className="mt-4 text-[20px] font-semibold tracking-tighst">{props.title}</h3>
<p className="mt-1 text-[13px] text-muted-foreground">
{props.desc}
</p>
<div className="mt-5 flex items-baseline gap-2">
<div className="text-[34px] font-semibold tracking-tight">
{props.price}
</div>
<div className="pb-1 text-[13px] text-muted-foreground">
{props.priceUnit}
</div>
</div>
{props.content}
<div className="mt-6 border-t border-border/60 pt-4">
<p className="text-[12px] font-semibold uppercase tracking-wide text-muted-foreground">
{props.tipTitle}
</p>
<ul className="mt-3 space-y-2 text-[13px] text-foreground/85">
{props.tip.map((feat) => (
<li key={feat} className="flex gap-2">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" aria-hidden/>
<span>{feat}</span>
</li>
))}
</ul>
</div>
</article>
);
};
export default PlanCard;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1055
src/app/legal/terms/page.tsx Normal file

File diff suppressed because it is too large Load Diff