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

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">
{tabs.map(item => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const isActive = pathname == item.href;
return (
<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