1
This commit is contained in:
@@ -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."
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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": {
|
||||
|
||||
Generated
+14
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user