1
This commit is contained in:
24
.trellis/tasks/05-09-billing-dashboard-replica/check.jsonl
Normal file
24
.trellis/tasks/05-09-billing-dashboard-replica/check.jsonl
Normal 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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
22
.trellis/tasks/05-09-billing-dashboard-replica/prd.md
Normal file
22
.trellis/tasks/05-09-billing-dashboard-replica/prd.md
Normal 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`.
|
||||
26
.trellis/tasks/05-09-billing-dashboard-replica/task.json
Normal file
26
.trellis/tasks/05-09-billing-dashboard-replica/task.json
Normal 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": {}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 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:
|
||||
specifier: ^5.0.13
|
||||
version: 5.0.13(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -784,6 +787,12 @@ packages:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1425,6 +1434,11 @@ snapshots:
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
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: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.4):
|
||||
|
||||
15
src/api/stripe.ts
Normal file
15
src/api/stripe.ts
Normal 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")
|
||||
}
|
||||
@@ -94,7 +94,7 @@ const NavTabs = () => {
|
||||
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
|
||||
{tabs.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const isActive = pathname == item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
212
src/app/dashboard/billing/page.tsx
Normal file
212
src/app/dashboard/billing/page.tsx
Normal 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 & 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’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’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>
|
||||
&
|
||||
<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>
|
||||
)
|
||||
}
|
||||
50
src/app/dashboard/billing/plan-card.tsx
Normal file
50
src/app/dashboard/billing/plan-card.tsx
Normal 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;
|
||||
1432
src/app/legal/saas-agreement/page.tsx
Normal file
1432
src/app/legal/saas-agreement/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1075
src/app/legal/terms-preview/page.tsx
Normal file
1075
src/app/legal/terms-preview/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1055
src/app/legal/terms/page.tsx
Normal file
1055
src/app/legal/terms/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user