1
This commit is contained in:
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