1
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import useUserStore from "@/store/user";
|
||||
import Link from "next/link";
|
||||
|
||||
export function Header() {
|
||||
const userStore = useUserStore();
|
||||
return (
|
||||
<header className="sticky top-0 z-30 w-full border-b border-border/60 bg-background/80 backdrop-blur">
|
||||
<div className="container mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-6">
|
||||
@@ -17,17 +21,25 @@ export function Header() {
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="ml-1 rounded-md bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90">
|
||||
Start free
|
||||
</Link>
|
||||
{
|
||||
userStore.token ?
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="ml-1 rounded-md bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90">
|
||||
Dashboard
|
||||
</Link> : <>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="ml-1 rounded-md bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90">
|
||||
Start free
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,40 +1,128 @@
|
||||
import React from 'react';
|
||||
import {AlertTriangle, ArrowRight, Clock, Download, Zap} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Link from 'next/link';
|
||||
import {Clock, Zap, ArrowRight, AlertTriangle, Lock, Download} from 'lucide-react';
|
||||
import useExtensionStore from "@/store/extension";
|
||||
import {copyText} from "@/utils/helper";
|
||||
import useSubscribeStore from "@/store/subscribe";
|
||||
|
||||
/**
|
||||
* 订阅提示
|
||||
* @constructor
|
||||
*/
|
||||
|
||||
export const SubscriptionTip = () => {
|
||||
const {status, remainingSeconds} = useSubscribeStore();
|
||||
|
||||
// 格式化时间显示
|
||||
const formatFullTime = (seconds: number) => {
|
||||
if (seconds <= 0) return "0h 0m";
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
// 如果是活跃状态(且没取消),则不显示任何提示
|
||||
if (status === "active" || status === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据不同状态配置内容
|
||||
const getConfig = () => {
|
||||
// 1. 试用状态
|
||||
if (status === "trial") {
|
||||
if (remainingSeconds > 0) {
|
||||
return {
|
||||
title: `Trial: ${formatFullTime(remainingSeconds)} left — Scan will lock when trial ends`,
|
||||
desc: "After your free trial ends, the scan function is fully blocked until you subscribe. Subscribe now to lock in scans before time runs out.",
|
||||
btn: "Upgrade Now",
|
||||
icon: <Clock className="h-5 w-5"/>,
|
||||
theme: "border-amber-300 bg-amber-50 text-amber-900",
|
||||
iconBox: "border-amber-300 bg-amber-100 text-amber-700",
|
||||
btnClass: "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: "Trial Ended — Scanning Locked",
|
||||
desc: "Your trial period has expired. All scanning functions and AI insights are now locked. Upgrade to a Pro plan to continue monitoring your store.",
|
||||
btn: "Unlock Now",
|
||||
icon: <Lock className="h-5 w-5"/>,
|
||||
theme: "border-rose-300 bg-rose-50 text-rose-900",
|
||||
iconBox: "border-rose-300 bg-rose-100 text-rose-700",
|
||||
btnClass: "bg-rose-600 hover:bg-rose-700 text-white"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 支付失败状态
|
||||
if (status === "past_due") {
|
||||
return {
|
||||
title: "Payment Overdue — Subscription at risk",
|
||||
desc: "Your last payment was unsuccessful. Please update your payment method to maintain uninterrupted scanning and AI insights.",
|
||||
btn: "Update Billing",
|
||||
icon: <AlertTriangle className="h-5 w-5"/>,
|
||||
theme: "border-red-300 bg-red-50 text-red-900",
|
||||
iconBox: "border-red-300 bg-red-100 text-red-700",
|
||||
btnClass: "bg-red-600 hover:bg-red-700 text-white"
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 已取消订阅但还在有效期内
|
||||
if (status === "canceled") {
|
||||
if (remainingSeconds > 0) {
|
||||
return {
|
||||
title: `Subscription Ending: ${formatFullTime(remainingSeconds)} left`,
|
||||
desc: "You have canceled your subscription. Access to automatic scans and AI reports will be disabled once this period ends.",
|
||||
btn: "Renew Subscription",
|
||||
icon: <Clock className="h-5 w-5"/>,
|
||||
theme: "border-slate-300 bg-slate-50 text-slate-900",
|
||||
iconBox: "border-slate-300 bg-slate-100 text-slate-700",
|
||||
btnClass: "bg-slate-700 hover:bg-slate-800 text-white"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
title: "Subscription Expired",
|
||||
desc: "Your subscription period has ended and scans are now locked. Please subscribe again to resume service.",
|
||||
btn: "Subscribe Now",
|
||||
icon: <Lock className="h-5 w-5"/>,
|
||||
theme: "border-slate-400 bg-slate-100 text-slate-900",
|
||||
iconBox: "border-slate-400 bg-slate-200 text-slate-700",
|
||||
btnClass: "bg-slate-800 hover:bg-slate-900 text-white"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const config = getConfig();
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"mb-6 overflow-hidden rounded-xl border-2 shadow-sm border-amber-300 bg-amber-50 text-amber-900 "}>
|
||||
<div className={`mb-6 overflow-hidden rounded-xl border-2 shadow-sm transition-all ${config.theme}`}>
|
||||
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:p-5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-amber-300 bg-amber-100 text-amber-700`}>
|
||||
<Clock className="h-5 w-5"/>
|
||||
className={`mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border transition-colors ${config.iconBox}`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={`text-base font-semibold leading-tight sm:text-[17px] text-amber-900`}>
|
||||
Trial: 23h 1m left — Scan will lock when trial ends
|
||||
<p className={`text-base font-semibold leading-tight sm:text-[17px]`}>
|
||||
{config.title}
|
||||
</p>
|
||||
<p className={`mt-1 text-[13px] leading-relaxed `}>
|
||||
After your free 1-day trial ends, the scan function is fully blocked until you subscribe.
|
||||
Subscribe now to lock in scans before time runs out.
|
||||
<p className={`mt-1 text-[13px] leading-relaxed opacity-90`}>
|
||||
{config.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/billing"
|
||||
className={"group inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-lg px-5 text-sm font-semibold shadow-md transition-all hover:shadow-lg bg-amber-600 text-white hover:bg-amber-700 "}>
|
||||
<Zap className="h-4 w-4"/>
|
||||
Upgrade Now
|
||||
<ArrowRight className="h-4 w-4 "
|
||||
/>
|
||||
className={`group inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-lg px-5 text-sm font-semibold shadow-md transition-all hover:shadow-lg ${config.btnClass}`}>
|
||||
<Zap className="h-4 w-4 fill-current"/>
|
||||
{config.btn}
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1"/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import TopActions from "./_components/result/top-actions";
|
||||
import TodayMetrics from "./_components/result/today-metrics";
|
||||
|
||||
const Page = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstalledTip/>
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link";
|
||||
import React from 'react';
|
||||
import {CheckCircle2, CreditCard, LayoutGrid, LogOut, SettingsIcon} from "lucide-react";
|
||||
import React, {useEffect} from 'react';
|
||||
import {AlertCircle, CheckCircle2, Clock, CreditCard, LayoutGrid, LogOut, SettingsIcon, XCircle} from "lucide-react";
|
||||
import {usePathname} from "next/navigation";
|
||||
import useUserStore from "@/store/user";
|
||||
import useSubscribeStore from "@/store/subscribe";
|
||||
import {formatSecond} from "@/utils/format";
|
||||
|
||||
function Header() {
|
||||
const {status, remainingSeconds} = useSubscribeStore();
|
||||
|
||||
|
||||
// 2. 根据状态获取配置(颜色、图标、文本)
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case "trial":
|
||||
return {
|
||||
label: `Trial · ${formatSecond(remainingSeconds)}`,
|
||||
className: "border-blue-200 bg-blue-50 text-blue-800 hover:bg-blue-100",
|
||||
icon: <Clock className="h-3 w-3"/>
|
||||
};
|
||||
case "active":
|
||||
return {
|
||||
label: "Active",
|
||||
className: "border-emerald-200 bg-emerald-50 text-emerald-800 hover:bg-emerald-100",
|
||||
icon: <CheckCircle2 className="h-3 w-3"/>
|
||||
};
|
||||
case "past_due":
|
||||
return {
|
||||
label: "Payment Due",
|
||||
className: "border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100",
|
||||
icon: <AlertCircle className="h-3 w-3"/>
|
||||
};
|
||||
case "canceled":
|
||||
return {
|
||||
label: remainingSeconds > 0 ? `Ends in ${formatSecond(remainingSeconds)}` : "Expired",
|
||||
className: "border-slate-200 bg-slate-50 text-slate-800 hover:bg-slate-100",
|
||||
icon: <XCircle className="h-3 w-3"/>
|
||||
};
|
||||
default:
|
||||
return null; // 未加载时不显示
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/85 backdrop-blur-md">
|
||||
<div className="container mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 md:px-6">
|
||||
@@ -24,11 +64,13 @@ function Header() {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<Link href="/dashboard/billing"
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors border-emerald-200 bg-emerald-50 text-emerald-800 hover:bg-emerald-100`}>
|
||||
<CheckCircle2 className="h-3 w-3"/>
|
||||
测试
|
||||
</Link>
|
||||
{config && (
|
||||
<Link href="/dashboard/billing"
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors ${config.className}`}>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Link>
|
||||
)}
|
||||
<span className="hidden h-5 w-px bg-border/60 md:inline-block" aria-hidden="true"/>
|
||||
<UserMenu/>
|
||||
</div>
|
||||
@@ -83,14 +125,16 @@ const NavTabs = () => {
|
||||
* 用户信息
|
||||
*/
|
||||
const UserMenu = () => {
|
||||
const userStore = useUserStore();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden max-w-50 truncate text-xs text-muted-foreground md:inline">
|
||||
112@qq.com
|
||||
{userStore.user?.email}
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Sign out">
|
||||
title="Sign out"
|
||||
onClick={() => userStore.logout()}>
|
||||
<LogOut className="h-3 w-3"/>
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import useSubscribeStore from "@/store/subscribe";
|
||||
import Header from "./_components/header";
|
||||
import {useEffect} from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
@@ -6,6 +10,11 @@ interface Props {
|
||||
|
||||
export default function DashboardLayout({children}: Props) {
|
||||
|
||||
const {init} = useSubscribeStore();
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="storeai-dashboard relative min-h-screen bg-background">
|
||||
<Header/>
|
||||
|
||||
91
src/store/subscribe.ts
Normal file
91
src/store/subscribe.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {create} from "zustand";
|
||||
import {getBrandStatusApi} from "@/api/set";
|
||||
|
||||
// 订阅状态类型
|
||||
type SubscriptionStatus = "trial" | "active" | "past_due" | "canceled" | "";
|
||||
|
||||
type SubscribeState = {
|
||||
status: SubscriptionStatus;
|
||||
endTime: string; // 目标结束时间字符串
|
||||
remainingSeconds: number; // 剩余秒数(手动计时用)
|
||||
init: () => Promise<void>;
|
||||
_startTimer: () => void; // 内部启动定时器方法
|
||||
_timer: any; // 内部定时器引用
|
||||
}
|
||||
|
||||
const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
||||
status: "",
|
||||
endTime: "",
|
||||
remainingSeconds: 0,
|
||||
_timer: null,
|
||||
|
||||
/**
|
||||
* 初始化:获取数据并启动计时
|
||||
*/
|
||||
init: async () => {
|
||||
try {
|
||||
const res: any = await getBrandStatusApi();
|
||||
const brand = res.brand;
|
||||
|
||||
if (!brand) return;
|
||||
|
||||
// 逻辑优先级:试用结束时间 > 订阅取消时间
|
||||
const targetTime = brand.trial_ends_at || brand.subscription_cancel_at || "";
|
||||
|
||||
set({
|
||||
status: (brand.subscription_status as SubscriptionStatus) || "",
|
||||
endTime: targetTime,
|
||||
});
|
||||
|
||||
// 拆分逻辑:数据就绪后,启动内部计时器
|
||||
get()._startTimer();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to init subscribe store:", error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 内部方法:管理定时器生命周期
|
||||
*/
|
||||
_startTimer: () => {
|
||||
const {_timer, endTime} = get();
|
||||
|
||||
// 1. 清理旧的定时器,防止叠加
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
set({_timer: null});
|
||||
}
|
||||
|
||||
// 2. 如果没有结束时间,直接归零并返回
|
||||
if (!endTime) {
|
||||
set({remainingSeconds: 0});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 定义每秒执行的计算逻辑
|
||||
const tick = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const end = Math.floor(new Date(endTime).getTime() / 1000);
|
||||
const diff = Math.max(0, end - now);
|
||||
|
||||
set({remainingSeconds: diff});
|
||||
|
||||
// 如果倒计时结束,清理自己
|
||||
if (diff <= 0) {
|
||||
const currentTimer = get()._timer;
|
||||
if (currentTimer) clearInterval(currentTimer);
|
||||
set({_timer: null});
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 立即执行一次,避免首秒空白
|
||||
tick();
|
||||
|
||||
// 5. 启动新定时器并记录引用
|
||||
const newTimer = setInterval(tick, 1000);
|
||||
set({_timer: newTimer});
|
||||
},
|
||||
}));
|
||||
|
||||
export default useSubscribeStore;
|
||||
@@ -6,7 +6,7 @@ type UserState = {
|
||||
user: UserInfo | null,
|
||||
setToken: (token: string) => void;
|
||||
setUser: (user: UserInfo | null) => void;
|
||||
clearToken: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
type UserInfo = {
|
||||
@@ -21,8 +21,8 @@ const useUserStore = create<UserState>()(
|
||||
token: "",
|
||||
user: null,
|
||||
setToken: (token) => set({token}),
|
||||
clearToken: () => set({token: ""}),
|
||||
setUser: (user) => set({user}),
|
||||
logout: () => set({token: "", user: null}),
|
||||
}),
|
||||
{
|
||||
name: "user-storage",
|
||||
|
||||
14
src/utils/format.ts
Normal file
14
src/utils/format.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 格式化秒为hh:mm:ss
|
||||
*/
|
||||
export function formatSecond(seconds: number) {
|
||||
if (seconds <= 0) return "Expired";
|
||||
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (d > 0) return `${d}d ${h}h`; // 大于1天显示 天+小时
|
||||
if (h > 0) return `${h}h ${m}m`; // 小于1天显示 小时+分钟
|
||||
return `<1h`; // 小于1小时显示 <1h
|
||||
}
|
||||
Reference in New Issue
Block a user