1
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "^5.0.13"
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.2.5
|
||||
version: 16.2.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.4)
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -746,6 +749,11 @@ packages:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
qrcode.react@4.2.0:
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
@@ -1359,6 +1367,10 @@ snapshots:
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
43
src/api/set.ts
Normal file
43
src/api/set.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import request from "@/utils/reqeust";
|
||||
|
||||
/**
|
||||
* 获取用户品牌状态
|
||||
*/
|
||||
export async function getBrandStatusApi() {
|
||||
return await request.get("/dashboard/shell")
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置品牌信息
|
||||
*/
|
||||
export async function setBrandApi(name: string, timezone: string) {
|
||||
return await request.post("/me/brand", {
|
||||
name,
|
||||
timezone,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定telegram的id
|
||||
* @param brandId
|
||||
* @param chatId
|
||||
*/
|
||||
export async function brandTelegram(brandId: string, chatId: string | null) {
|
||||
return await request.patch(`/brands/${brandId}/telegram`, {
|
||||
chatId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码绑定telegram
|
||||
*/
|
||||
export async function brandTelegramScan(brandId: string) {
|
||||
return await request.post(`/brands/${brandId}/telegram/pair`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设置信息
|
||||
*/
|
||||
export async function getSettingApi() {
|
||||
return await request.get("/dashboard/settings")
|
||||
}
|
||||
83
src/api/user.ts
Normal file
83
src/api/user.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import request from "@/utils/reqeust";
|
||||
|
||||
/**
|
||||
* 注册接口
|
||||
* @param email 邮箱
|
||||
* @param password 密码
|
||||
* @param name 用户名
|
||||
*/
|
||||
export async function registerApi(email: string, password: string, name: string) {
|
||||
return await request.post("/auth/register", {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录接口
|
||||
* @param email 邮箱
|
||||
* @param password 密码
|
||||
*/
|
||||
export async function loginApi(email: string, password: string) {
|
||||
return await request.post("/auth/login", {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出接口
|
||||
*/
|
||||
export async function logoutApi() {
|
||||
return await request.post("/auth/logout")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 修改密码,登录情况下修改
|
||||
*/
|
||||
export async function setPasswordApi(password: string) {
|
||||
return await request.patch("/auth/setPassword", {
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param email 邮箱
|
||||
* @returns expiresInSeconds 验证码剩余有效秒数
|
||||
*/
|
||||
export async function sendVerifyCodeApi(email: string) {
|
||||
return await request.post("/auth/email/verification-code", {
|
||||
email,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 效验code
|
||||
* @param email 邮箱
|
||||
* @param code 验证码
|
||||
*/
|
||||
export async function verifyCodeApi(email: string, code: string) {
|
||||
return await request.post("/auth/email/verify-code", {
|
||||
email,
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 重置邮箱密码
|
||||
* @param email 邮箱
|
||||
* @param code 验证码
|
||||
* @param password 新密码
|
||||
*/
|
||||
export async function resetEmailPasswordApi(email: string, code: string, password: string) {
|
||||
return await request.patch("/auth//password/forgot", {
|
||||
email,
|
||||
code,
|
||||
password,
|
||||
})
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import {Suspense, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {useSearchParams} from "next/navigation";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {AlertTriangle, Lock, Mail} from "lucide-react";
|
||||
|
||||
import {PasswordToggle} from "../components/password-toggle";
|
||||
import {getLoginNotice} from "./login-error";
|
||||
import {validateSignup} from "../validate";
|
||||
import {loginApi} from "@/api/user";
|
||||
import useUserStore from "@/store/user";
|
||||
|
||||
|
||||
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
|
||||
@@ -26,6 +28,8 @@ function LoginFormFallback() {
|
||||
|
||||
/** 登录表单主体,保留 query 逻辑、校验和错误态。 */
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const searchParams = useSearchParams();
|
||||
/*** 提示 */
|
||||
const loginNotice = getLoginNotice(searchParams.get("error_type"));
|
||||
@@ -51,9 +55,12 @@ function LoginForm() {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
|
||||
} catch (err) {
|
||||
|
||||
let res: any = await loginApi(email, password)
|
||||
userStore.setToken(res.token)
|
||||
userStore.setUser(res.user)
|
||||
router.replace("/dashboard")
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Login failed.")
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {CheckCircle2, Lock, Mail, Sparkles} from "lucide-react";
|
||||
import {PasswordToggle} from "../components/password-toggle";
|
||||
import {validateSignup} from "../validate";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {registerApi, sendVerifyCodeApi} from "@/api/user";
|
||||
import VerifyCode from "./verify-code";
|
||||
|
||||
|
||||
/** 密码强度的分级结果。 */
|
||||
@@ -35,18 +37,26 @@ const STRENGTH_LABEL_COLOR: Record<PasswordStrength["score"], string> = {
|
||||
/** 注册表单主体,保留 query 逻辑、密码强度和校验。 */
|
||||
export default function SignupForm() {
|
||||
const router = useRouter();
|
||||
//表单
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
//错误
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const strength = useMemo(() => gradePassword(password), [password]);
|
||||
|
||||
//显示验证码
|
||||
const [showVerifyCode, setShowVerifyCode] = useState<boolean>(false)
|
||||
|
||||
/** 处理注册表单提交,真实接口接入前只执行前端校验和占位提交。 */
|
||||
async function handleSubmit(event: any) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
/**
|
||||
* 参数效验
|
||||
*/
|
||||
const validationError = validateSignup({email, password});
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
@@ -55,15 +65,19 @@ export default function SignupForm() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
router.push("/onboarding");
|
||||
// await submitSignup({email, password});
|
||||
} catch (err) {
|
||||
// setError(err instanceof Error ? err.message : "Sign-up failed.");
|
||||
await registerApi(email, password,email)
|
||||
setShowVerifyCode(true)
|
||||
} catch (err:any) {
|
||||
setError(err?.message || "Sign-up failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (showVerifyCode) {
|
||||
return <VerifyCode email={email}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="space-y-2">
|
||||
|
||||
155
src/app/(auth)/signup/verify-code.tsx
Normal file
155
src/app/(auth)/signup/verify-code.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, {useState, useRef} from 'react';
|
||||
import {MailCheck, Loader2} from 'lucide-react';
|
||||
import Link from "next/link";
|
||||
import {sendVerifyCodeApi, verifyCodeApi} from "@/api/user";
|
||||
import useUserStore from "@/store/user";
|
||||
import {useRouter} from "next/navigation";
|
||||
// 假设这是你的接口路径
|
||||
// import { sendVerifyCodeApi } from "@/api/user";
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function VerifyCode({email}: Props) {
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
// 1. 初始状态改为 6 位
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const isComplete = code.every(digit => digit !== '');
|
||||
|
||||
// 处理输入
|
||||
const handleChange = (index: number, value: string) => {
|
||||
// 只允许数字,且只取最后一位
|
||||
const val = value.replace(/\D/g, '').slice(-1);
|
||||
const newCode = [...code];
|
||||
newCode[index] = val;
|
||||
setCode(newCode);
|
||||
|
||||
// 【修正】:如果是输入内容,且当前不是最后一位(index < 5),则跳到下一个
|
||||
if (val && index < code.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理退格键
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理粘贴
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
// 【修正】:截取前 6 位数字
|
||||
const pasteData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, code.length);
|
||||
if (!pasteData) return;
|
||||
|
||||
const newCode = [...code];
|
||||
pasteData.split('').forEach((char, i) => {
|
||||
if (i < code.length) newCode[i] = char;
|
||||
});
|
||||
setCode(newCode);
|
||||
|
||||
// 【修正】:聚焦到最后一个输入的下一格,或者最后一格
|
||||
const nextIndex = Math.min(pasteData.length, code.length - 1);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
|
||||
e.preventDefault(); // 阻止默认粘贴行为
|
||||
};
|
||||
|
||||
const resendCode = async () => {
|
||||
await sendVerifyCodeApi(email)
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isComplete) return;
|
||||
setLoading(true);
|
||||
const finalCode = code.join('');
|
||||
|
||||
try {
|
||||
let res = await verifyCodeApi(email, finalCode) as any
|
||||
if(res.verified){
|
||||
userStore.setToken(res.token)
|
||||
userStore.setUser(res.user)
|
||||
router.replace("/onboarding")
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm space-y-8 py-10">
|
||||
{/* 头部 UI */}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-50 ring-8 ring-emerald-50/50">
|
||||
<MailCheck className="h-6 w-6 text-emerald-600"/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Check your inbox</h1>
|
||||
<div className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
We sent a 6-digit code and a link to
|
||||
<div className="font-medium text-foreground">{email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码输入区域 */}
|
||||
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
||||
{code.map((digit, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
inputRefs.current[i] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
className="h-12 w-10 sm:h-14 sm:w-12 rounded-md border border-input bg-background text-center text-xl sm:text-2xl font-bold ring-offset-background focus:border-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md border border-border/60 bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<strong className="block font-medium text-foreground">Reading email on your phone?</strong>
|
||||
Tap the link in the email — it works on any device. Or come back here and type the 6-digit code.
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isComplete || loading}
|
||||
className="flex h-11 w-full items-center justify-center rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
|
||||
Verifying...
|
||||
</>
|
||||
) : 'Verify and continue'}
|
||||
</button>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendCode}
|
||||
className="font-medium text-foreground underline underline-offset-4 transition-opacity hover:opacity-70 disabled:opacity-50">
|
||||
Resend email
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,16 @@ export function Header() {
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-foreground text-[11px] font-bold text-background">
|
||||
<div
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded bg-foreground text-[11px] font-bold text-background">
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
StoreAI
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
@@ -25,8 +25,7 @@ export function Header() {
|
||||
</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"
|
||||
>
|
||||
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>
|
||||
|
||||
108
src/app/dashboard/(home)/_components/result/today-metrics.tsx
Normal file
108
src/app/dashboard/(home)/_components/result/today-metrics.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import {AlertCircle, ArrowDownRight, ArrowUpRight, Clock} from "lucide-react";
|
||||
|
||||
const TodayMetrics = () => {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{/*头部*/}
|
||||
<header className="flex items-baseline gap-2">
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Numbers
|
||||
</h2>
|
||||
<span className="text-[11px] text-muted-foreground/80">
|
||||
Today's metrics, trends and SKU mix.
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
< TodayHero/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 今日指标面板
|
||||
* @constructor
|
||||
*/
|
||||
const TodayHero = () => {
|
||||
return (
|
||||
<section
|
||||
className="overflow-hidden rounded-2xl border border-border/60 bg-linear-to-br from-card via-card to-muted/30">
|
||||
{/*头部*/}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-5 pt-5 sm:px-7 sm:pt-6">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<Clock className="h-3 w-3"/>
|
||||
Scanned 测测
|
||||
</div>
|
||||
<a
|
||||
href="#actions"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11px] font-medium text-rose-800 transition-colors hover:bg-rose-100">
|
||||
<AlertCircle className="h-3 w-3"/>
|
||||
6 thing to handle
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/*rm值*/}
|
||||
<div className="px-5 pt-4 sm:px-7">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
GMV today
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-3">
|
||||
<div
|
||||
className="text-[40px] font-semibold leading-none tracking-tight tabular-nums sm:text-5xl">
|
||||
RM 100.00
|
||||
</div>
|
||||
<DeltaChip fraction={0.04}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 px-5 pb-5 pt-4 sm:grid-cols-4 sm:gap-6 sm:px-7 sm:pb-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
测测
|
||||
</div>
|
||||
<div className={`mt-0.5 text-lg font-semibold tabular-nums `}>
|
||||
111
|
||||
</div>
|
||||
{/*{hint && (*/}
|
||||
{/* <div className="text-[10px] text-muted-foreground/80">{hint}</div>*/}
|
||||
{/*)}*/}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 趋势标记
|
||||
* @param fraction 百分比
|
||||
*/
|
||||
function DeltaChip({fraction}: { fraction: number }) {
|
||||
if (Math.abs(fraction) < 0.005) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
flat
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const up = fraction > 0;
|
||||
const Icon = up ? ArrowUpRight : ArrowDownRight;
|
||||
const tone = up
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-rose-200 bg-rose-50 text-rose-800';
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-[12px] font-medium tabular-nums ${tone}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden="true"/>
|
||||
{Math.abs(fraction) * 100}%
|
||||
<span className="ml-0.5">vs yesterday</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default TodayMetrics;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import {Sparkles} from "lucide-react";
|
||||
import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields";
|
||||
|
||||
/**
|
||||
* 等级
|
||||
*/
|
||||
export type VerdictTier = 'strong' | 'normal' | 'weak' | 'critical';
|
||||
|
||||
/**
|
||||
* 等级对应下的样式
|
||||
*/
|
||||
const tierVisual = {
|
||||
strong: {
|
||||
label: 'Strong',
|
||||
badgeClass: 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-600/20',
|
||||
gradientClass: 'from-card via-card to-emerald-50/50',
|
||||
iconClass: 'text-emerald-600',
|
||||
},
|
||||
normal: {
|
||||
label: 'Normal',
|
||||
badgeClass: 'bg-slate-500/15 text-slate-700 ring-1 ring-slate-600/20',
|
||||
gradientClass: 'from-card via-card to-slate-50/50',
|
||||
iconClass: 'text-slate-600',
|
||||
},
|
||||
weak: {
|
||||
label: 'Weak',
|
||||
badgeClass: 'bg-amber-500/15 text-amber-700 ring-1 ring-amber-600/20',
|
||||
gradientClass: 'from-card via-card to-amber-50/40',
|
||||
iconClass: 'text-amber-600',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
badgeClass: 'bg-rose-500/15 text-rose-700 ring-1 ring-rose-600/30',
|
||||
gradientClass: 'from-card via-card to-rose-50/40',
|
||||
iconClass: 'text-rose-600',
|
||||
},
|
||||
}
|
||||
|
||||
const TodayVerdict = () => {
|
||||
const visual = tierVisual.weak;
|
||||
return (
|
||||
<section
|
||||
className={`rounded-2xl border border-border/60 bg-linear-to-br from-card via-card to-amber-50/40 p-6 sm:p-7`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-card`}>
|
||||
<Sparkles className="h-5 w-5"/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/*标题*/}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Today’s verdict
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${visual.badgeClass}`}>
|
||||
{visual.label}
|
||||
</div>
|
||||
</div>
|
||||
{/*内容*/}
|
||||
<p className="text-pretty text-[17px] font-medium leading-snug text-foreground sm:text-[19px]">
|
||||
No sales yet this afternoon — RM 0 with 0 orders, and 3 urgent issues need attention.
|
||||
</p>
|
||||
{/* 因素列表 */}
|
||||
{
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{[1, 2].map((factor, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="inline-flex items-start rounded-md border border-border/40 bg-card/60 px-2.5 py-1 text-[13px] leading-snug text-foreground/80">
|
||||
Zero GMV and zero orders — no baselinesdaaaaaaaaa to compare, but the day hasn't
|
||||
started.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
|
||||
{/* 建议 */}
|
||||
<p className="text-pretty text-[14px] leading-relaxed text-foreground/85">
|
||||
→
|
||||
将两个缺货的VICTOR球拍袋下架,并立即暂停“球拍-团体广告”活动
|
||||
</p>
|
||||
<p className="pt-1 text-[11px] text-muted-foreground">
|
||||
Updated 4 min ago ·
|
||||
<span className="text-amber-700">low confidence — limited history</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayVerdict;
|
||||
124
src/app/dashboard/(home)/_components/result/top-actions.tsx
Normal file
124
src/app/dashboard/(home)/_components/result/top-actions.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Check, ExternalLink, Info, ListChecks, Loader2} from "lucide-react";
|
||||
import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields";
|
||||
|
||||
const TopActions = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
let actions = 3
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{/*头部*/}
|
||||
<header className="flex items-baseline gap-3">
|
||||
<div
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-card text-emerald-700">
|
||||
<ListChecks className="h-4 w-4"/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight text-foreground">
|
||||
Top 3 actions today
|
||||
</h2>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Sorted by GMV impact · mark each done as you handle it.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{/*步骤*/}
|
||||
{(() => {
|
||||
if (loading) {
|
||||
return <Loading/>;
|
||||
}
|
||||
// 当数据为空时
|
||||
if (actions == 0) {
|
||||
return <div
|
||||
className="rounded-xl border border-dashed border-border/50 bg-card/40 p-6 text-center text-[13px] text-muted-foreground">
|
||||
Nothing pressing right now — your store is on autopilot
|
||||
</div>
|
||||
}
|
||||
//列表
|
||||
return <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{[1, 2, 3].map((item) => <TopActionsCard key={item}/>)}
|
||||
</div>
|
||||
})()}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 加载中
|
||||
*/
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl border border-dashed border-emerald-200/60 bg-emerald-50/30 p-6 text-[13px] text-foreground/80">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-emerald-700"/>
|
||||
<span>
|
||||
AI is drafting today’s top actions — should land in a few
|
||||
seconds. This panel refreshes automatically.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 步骤卡片
|
||||
* @constructor
|
||||
*/
|
||||
const TopActionsCard = () => {
|
||||
return (
|
||||
<article
|
||||
className={`flex h-full flex-col gap-3 rounded-xl border border-border/40 bg-card p-5 transition-shadow hover:shadow-sm`}>
|
||||
{/*头部*/}
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-[15px] font-semibold bg-slate-500/15 text-slate-700`}>
|
||||
1
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1 rounded-md border border-border/40 bg-card px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide `}>
|
||||
<Info className="h-3 w-3"/>
|
||||
Out of stock
|
||||
</div>
|
||||
</div>
|
||||
{/*标题*/}
|
||||
<h3 className="text-pretty text-[15px] font-semibold leading-snug text-foreground">
|
||||
Unlist VICTOR Capsule Collection Racket Bag BR9615CPS — stock 0
|
||||
</h3>
|
||||
{/*标记*/}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div
|
||||
className={`inline-flex w-fit items-center gap-1 rounded-md px-2 py-0.5 text-[12px] font-semibold bg-sky-500/15 text-sky-700`}>
|
||||
<span aria-hidden="true">💰</span>
|
||||
<span>RM 50 wasted today</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Not enough history yet
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*原因*/}
|
||||
<p className="flex-1 text-[13px] leading-relaxed text-foreground/75">
|
||||
0 stock but still listed — would cause cancellations on next order
|
||||
</p>
|
||||
{/*尾部*/}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<a href="https://seller.shopee.com.my/portal/marketing/pas/index"
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium text-foreground/80 hover:bg-muted hover:text-foreground">
|
||||
Open ads centre
|
||||
<ExternalLink className={"h-3 w-3"}/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50">
|
||||
<Check className="h-3.5 w-3.5"/>
|
||||
Mark done
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default TopActions;
|
||||
138
src/app/dashboard/(home)/_components/start-scan.tsx
Normal file
138
src/app/dashboard/(home)/_components/start-scan.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import useExtensionStore from "@/store/extension";
|
||||
import React, {ReactNode, useMemo} from "react";
|
||||
|
||||
|
||||
/**
|
||||
* 开始链接卡片
|
||||
* @constructor
|
||||
*/
|
||||
type ConfigFor = {
|
||||
title: string,
|
||||
body: string,
|
||||
className?: string,
|
||||
action: ReactNode,
|
||||
}
|
||||
export const StartScanningCard = () => {
|
||||
const extension = useExtensionStore();
|
||||
|
||||
/*** 配置*/
|
||||
const configFor = useMemo<ConfigFor>(() => {
|
||||
//如果插件未下载
|
||||
if (!extension.isInstalled) {
|
||||
return {
|
||||
title: "Install the Chrome extension",
|
||||
body: "StoreAI scans your store from inside your own logged-in Chrome. Install the extension, then come back here to run your first scan.",
|
||||
className: "border-amber-200 bg-amber-50 text-amber-600",
|
||||
action: <Link
|
||||
href={"/onboarding/extension"}
|
||||
className="cursor-pointer inline-flex h-10 items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background opacity-70">
|
||||
Install extension
|
||||
<ArrowRight className="h-4 w-4"/>
|
||||
</Link>
|
||||
}
|
||||
}
|
||||
|
||||
//如果正在爬取中
|
||||
if (extension.isFetching) {
|
||||
return {
|
||||
title: "Scanning your store…",
|
||||
body: "The extension is stepping through your store dashboard. This page will refresh automatically when the scan lands.",
|
||||
action: <button
|
||||
type="button"
|
||||
disabled
|
||||
className="inline-flex h-10 cursor-progress items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background opacity-70">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true"/>
|
||||
Scanning
|
||||
</button>
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: "Run your first scan",
|
||||
body: 'Everything is wired up. One click to pull today’s GMV, orders, ads and reviews from your store dashboard — about 30 seconds.',
|
||||
action: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handStart}
|
||||
className="inline-flex h-10 items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background transition-opacity hover:opacity-90">
|
||||
<Radar className="h-3.5 w-3.5"/>
|
||||
Run my first scan
|
||||
</button>
|
||||
),
|
||||
}
|
||||
}, [extension.isFetching])
|
||||
|
||||
/**
|
||||
* 开始爬取
|
||||
*/
|
||||
function handStart() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<section
|
||||
className="overflow-hidden rounded-2xl border border-border/60 bg-linear-to-br from-card via-card to-emerald-50/30 p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border ${configFor.className ? configFor.className : 'border-emerald-200 bg-emerald-50 text-emerald-600'} `}>
|
||||
<Radar/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Start scanning
|
||||
</p>
|
||||
<h2 className={`mt-1 text-balance text-xl font-semibold tracking-tight sm:text-[22px]`}>
|
||||
{configFor.title}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
{configFor.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{configFor.action}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 链接卡片
|
||||
* @constructor
|
||||
*/
|
||||
export function ConnectTelegramCard() {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border/60 bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/30 text-foreground">
|
||||
<MessagesSquare className="h-4 w-4" aria-hidden="true"/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Connect Telegram for push delivery
|
||||
</p>
|
||||
<p className="mt-0.5 max-w-xl text-xs leading-relaxed text-muted-foreground">
|
||||
Reports will land on your phone within seconds of each scan.
|
||||
Without it, you’ll only see them on this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/onboarding/telegram"
|
||||
className="inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-md border border-border bg-background px-4 text-xs font-medium transition-colors hover:bg-accent">
|
||||
Connect
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true"/>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
89
src/app/dashboard/(home)/_components/tip.tsx
Normal file
89
src/app/dashboard/(home)/_components/tip.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import {AlertTriangle, ArrowRight, Clock, Download, Zap} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import useExtensionStore from "@/store/extension";
|
||||
import {copyText} from "@/utils/helper";
|
||||
|
||||
/**
|
||||
* 订阅提示
|
||||
* @constructor
|
||||
*/
|
||||
export const SubscriptionTip = () => {
|
||||
return (
|
||||
<div
|
||||
className={"mb-6 overflow-hidden rounded-xl border-2 shadow-sm border-amber-300 bg-amber-50 text-amber-900 "}>
|
||||
<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"/>
|
||||
</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>
|
||||
<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>
|
||||
</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 "
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 扩展未安装提示
|
||||
*/
|
||||
export const InstalledTip = () => {
|
||||
const extension = useExtensionStore();
|
||||
if (extension.isInstalled) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50">
|
||||
<div
|
||||
className="container mx-auto flex max-w-6xl flex-col gap-2 px-4 py-3 text-xs sm:flex-row sm:items-center">
|
||||
<div className="flex items-start gap-2 sm:flex-1">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" aria-hidden="true"/>
|
||||
<div className="leading-relaxed text-amber-900">
|
||||
<strong className="block font-medium">StoreAI extension not detected.</strong>
|
||||
Your scans are paused. Reinstall the extension to resume morning + evening monitoring.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:shrink-0">
|
||||
<a
|
||||
href={extension.extensionInfo.downloadUrl}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-white px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true"/>
|
||||
Download extension
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(extension.extensionInfo.chromeUrl)}
|
||||
className="rounded-md border border-amber-200 px-3 py-1.5 text-amber-800 transition hover:bg-amber-100"
|
||||
title="Copy chrome://extensions to clipboard"
|
||||
>
|
||||
Copy chrome://extensions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto max-w-6xl px-4 pb-3 text-[11px] leading-relaxed text-amber-800">
|
||||
Install steps: 1) Download the .zip and unzip it. 2) Open
|
||||
<code className="mx-1 rounded bg-amber-100 px-1.5 py-0.5 font-mono">chrome://extensions</code>
|
||||
and turn on <strong>Developer mode</strong>. 3) Click <strong>Load unpacked</strong> and pick
|
||||
the unzipped folder. The extension will sign in automatically with this account.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/app/dashboard/(home)/page.tsx
Normal file
34
src/app/dashboard/(home)/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
import React from 'react';
|
||||
import {ConnectTelegramCard, StartScanningCard} from "./_components/start-scan";
|
||||
import {InstalledTip, SubscriptionTip} from "./_components/tip";
|
||||
import TodayVerdict from "./_components/result/today-verdict";
|
||||
import TopActions from "./_components/result/top-actions";
|
||||
import TodayMetrics from "./_components/result/today-metrics";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<InstalledTip/>
|
||||
<main className="container mx-auto max-w-6xl px-4 py-8 md:px-6 md:py-10">
|
||||
|
||||
{/*订阅通知*/}
|
||||
<SubscriptionTip/>
|
||||
|
||||
<div className={'space-y-4'}>
|
||||
{/*爬取*/}
|
||||
<StartScanningCard/>
|
||||
{/*检查Telegram连接*/}
|
||||
<ConnectTelegramCard/>
|
||||
|
||||
{/*统计面板*/}
|
||||
<TodayVerdict/>
|
||||
<TopActions />
|
||||
<TodayMetrics />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
102
src/app/dashboard/_components/header.tsx
Normal file
102
src/app/dashboard/_components/header.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link";
|
||||
import React from 'react';
|
||||
import {CheckCircle2, CreditCard, LayoutGrid, LogOut, SettingsIcon} from "lucide-react";
|
||||
import {usePathname} from "next/navigation";
|
||||
|
||||
function Header() {
|
||||
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">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-5">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex shrink-0 items-center gap-2 text-sm font-semibold tracking-tight">
|
||||
<div
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded bg-foreground text-[11px] font-bold text-background">
|
||||
S
|
||||
</div>
|
||||
<span className="hidden sm:inline">StoreAI</span>
|
||||
</Link>
|
||||
<span className="hidden h-5 w-px bg-border/60 md:inline-block" aria-hidden="true"/>
|
||||
<NavTabs/>
|
||||
</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>
|
||||
<span className="hidden h-5 w-px bg-border/60 md:inline-block" aria-hidden="true"/>
|
||||
<UserMenu/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 导航栏
|
||||
*/
|
||||
const NavTabs = () => {
|
||||
const pathname = usePathname();
|
||||
const tabs = [
|
||||
{href: "/dashboard", label: "Dashboard", icon: LayoutGrid},
|
||||
{href: "/dashboard/settings", label: "Settings", icon: SettingsIcon},
|
||||
{href: "/dashboard/billing", label: "Billing", icon: CreditCard},
|
||||
];
|
||||
return (
|
||||
<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);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors sm:px-3 sm:text-sm ${
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
}`}>
|
||||
<Icon className="h-3.5 w-3.5"/>
|
||||
<span className="hidden sm:inline">{item.label}</span>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-3.25 left-2 right-2 h-0.5 rounded-t-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 用户信息
|
||||
*/
|
||||
const UserMenu = () => {
|
||||
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
|
||||
</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">
|
||||
<LogOut className="h-3 w-3"/>
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Header
|
||||
15
src/app/dashboard/layout.tsx
Normal file
15
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Header from "./_components/header";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({children}: Props) {
|
||||
|
||||
return (
|
||||
<div className="storeai-dashboard relative min-h-screen bg-background">
|
||||
<Header/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import {CheckCircle2} from "lucide-react";
|
||||
|
||||
import {StepCard} from "../components/step-card";
|
||||
import {getBrandStatusApi, setBrandApi} from "@/api/set";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
const TIMEZONE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)"},
|
||||
@@ -27,33 +29,52 @@ const TIMEZONE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
|
||||
/** onboarding 第一步:品牌名称和时区表单。 */
|
||||
export default function BrandStepPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState("Asia/Kuala_Lumpur");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (detected && TIMEZONE_OPTIONS.some((item) => item.value === detected)) {
|
||||
setTimezone(detected);
|
||||
}
|
||||
} catch {
|
||||
// 保持默认时区。
|
||||
}
|
||||
init()
|
||||
}, []);
|
||||
|
||||
async function init() {
|
||||
let res: any = await getBrandStatusApi()
|
||||
if (res.brand?.name) {
|
||||
router.replace("/onboarding/telegram")
|
||||
}
|
||||
}
|
||||
|
||||
/** 品牌表单提交占位,目前只做前端校验并让 Continue 链接负责流转。 */
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit() {
|
||||
if (!name.trim()) {
|
||||
event.preventDefault();
|
||||
setError("Brand name is required.");
|
||||
return
|
||||
}
|
||||
//开始提交
|
||||
try {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
await setBrandApi(name, timezone);
|
||||
router.push("/onboarding/telegram")
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Something went wrong. Please try again later.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden />
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden/>
|
||||
<div>
|
||||
<div className="font-semibold">Account created</div>
|
||||
<div className="text-xs opacity-90">
|
||||
@@ -65,9 +86,8 @@ export default function BrandStepPage() {
|
||||
<StepCard
|
||||
eyebrow="Step 1 of 4"
|
||||
title="Name your brand"
|
||||
subtitle="One brand = one store on your subscription."
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-5">
|
||||
subtitle="One brand = one store on your subscription.">
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="brand-name" className="text-sm font-medium">
|
||||
Brand name
|
||||
@@ -75,7 +95,6 @@ export default function BrandStepPage() {
|
||||
<input
|
||||
id="brand-name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
@@ -115,19 +134,13 @@ export default function BrandStepPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/onboarding/telegram"
|
||||
onClick={(event) => {
|
||||
if (!name.trim()) {
|
||||
event.preventDefault();
|
||||
setError("Brand name is required.");
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-6 text-sm font-medium text-background transition-opacity hover:opacity-90"
|
||||
>
|
||||
Continue ->
|
||||
</Link>
|
||||
</form>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-6 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</StepCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,15 @@
|
||||
import React, {useState} from 'react';
|
||||
import {ArrowRight, CheckCircle2, HelpCircle, Loader2} from "lucide-react";
|
||||
import {ArrowRight, CheckCircle2, Loader2} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import useExtensionStore from "@/store/extension";
|
||||
import {useEffect} from "react";
|
||||
import {detectExtension} from "@/utils/extension/detect_extension";
|
||||
|
||||
const LiveCard = () => {
|
||||
const [installed, setInstalled] = useState<boolean | null>(null);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
|
||||
/**
|
||||
* 模拟扩展检测成功,用于先走通 onboarding 流程。
|
||||
*/
|
||||
function handleSimulateDetected() {
|
||||
setInstalled(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*检测状态*/}
|
||||
<LiveStatus installed={installed} onSimulateDetected={handleSimulateDetected}/>
|
||||
|
||||
{showTroubleshoot && installed !== true && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700"/>
|
||||
<div className="space-y-2 text-[13px] text-amber-900">
|
||||
<p className="font-semibold">Still not detected?</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-amber-900/85">
|
||||
<li>Did you flip Developer mode ON?</li>
|
||||
<li>Did you select the unzipped folder, not the .zip itself?</li>
|
||||
<li>Does StoreAI show up on chrome://extensions with no red error banner?</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/** 扩展安装状态面板,当前通过按钮模拟检测成功。 */
|
||||
interface LiveStatusProps {
|
||||
/** 当前是否已经检测到扩展。 */
|
||||
installed: boolean | null;
|
||||
/** 模拟检测成功的点击回调。 */
|
||||
onSimulateDetected: () => void;
|
||||
}
|
||||
|
||||
function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
|
||||
if (installed === true) {
|
||||
const extensionStore = useExtensionStore();
|
||||
useEffect(() => {
|
||||
detectExtension()
|
||||
}, []);
|
||||
if (extensionStore.isInstalled) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4">
|
||||
@@ -67,11 +29,10 @@ function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 flex-shrink-0 animate-spin text-muted-foreground"/>
|
||||
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-muted-foreground"/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Waiting for install...</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -79,15 +40,9 @@ function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSimulateDetected}
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Simulate detected
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,20 +18,21 @@ export default function OnboardingLayout({ children }: OnboardingLayoutProps) {
|
||||
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-32 -top-32 h-[28rem] w-[28rem] rounded-full bg-emerald-500/10 blur-3xl"
|
||||
className="pointer-events-none absolute -right-32 -top-32 h-112 w-md rounded-full bg-emerald-500/10 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-32 -left-32 h-[28rem] w-[28rem] rounded-full bg-indigo-500/10 blur-3xl"
|
||||
className="pointer-events-none absolute -bottom-32 -left-32 h-112 w-md rounded-full bg-indigo-500/10 blur-3xl"
|
||||
/>
|
||||
<div aria-hidden="true" className="onboarding-grid pointer-events-none absolute inset-0"/>
|
||||
|
||||
<div className="relative mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 py-6 sm:px-6 sm:py-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold tracking-tight">
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded bg-foreground text-xs font-bold text-background">
|
||||
<div
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded bg-foreground text-xs font-bold text-background">
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
StoreAI
|
||||
</Link>
|
||||
<SignOutLink/>
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {ArrowLeft, ArrowRight, CheckCircle2} from "lucide-react";
|
||||
|
||||
import {StepCard} from "../components/step-card";
|
||||
import QrView from "./qr_view";
|
||||
import WorkList from "./work_list";
|
||||
import {getBrandStatusApi, getSettingApi} from "@/api/set";
|
||||
|
||||
|
||||
/** onboarding 第二步:Telegram 连接 UI。 */
|
||||
export default function TelegramStepPage() {
|
||||
//设置
|
||||
const [xb, setXb] = useState<any>(null)
|
||||
|
||||
const [pairingStarted, setPairingStarted] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
async function init() {
|
||||
let res: any = await getSettingApi()
|
||||
setXb(res)
|
||||
if (res.brand.telegram_chat_id) {
|
||||
setConnected(true);
|
||||
}
|
||||
}
|
||||
|
||||
//设置成功
|
||||
const handlePairingSuccess = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StepCard
|
||||
eyebrow="Step 2 of 4"
|
||||
@@ -24,11 +44,14 @@ export default function TelegramStepPage() {
|
||||
if (connected) return <ConnectedView/>;
|
||||
if (pairingStarted) return (
|
||||
<QrView
|
||||
uuid={xb?.brand?.id}
|
||||
onCancel={() => setPairingStarted(false)}
|
||||
onConnected={() => setConnected(true)}
|
||||
/>
|
||||
);
|
||||
return <WorkList/>;
|
||||
return <WorkList brandId={xb?.brand?.id}
|
||||
onSuccess={handlePairingSuccess}
|
||||
onConnect={() => setPairingStarted(true)}/>;
|
||||
})()}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-5">
|
||||
@@ -38,11 +61,17 @@ export default function TelegramStepPage() {
|
||||
<ArrowLeft className="h-3 w-3" aria-hidden="true"/>
|
||||
Back
|
||||
</Link>
|
||||
<Link
|
||||
{
|
||||
connected ? <Link
|
||||
href="/onboarding/extension"
|
||||
className={"inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background transition-opacity hover:opacity-90"}>
|
||||
Continue <ArrowRight className="h-3.5 w-3.5"/>
|
||||
</Link> : <Link
|
||||
href="/onboarding/extension"
|
||||
className="text-right text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||
Skip - reports will only show on the dashboard ->
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</StepCard>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {brandTelegramScan, getSettingApi} from "@/api/set";
|
||||
import {QRCodeCanvas} from 'qrcode.react';
|
||||
|
||||
interface Props {
|
||||
uuid: string,
|
||||
/** 取消当前二维码配对流程。 */
|
||||
onCancel: () => void;
|
||||
/** 模拟 Telegram 连接成功。 */
|
||||
@@ -7,35 +12,68 @@ interface Props {
|
||||
|
||||
|
||||
/** 静态二维码配对视图,不生成真实 deeplink。 */
|
||||
function QrView({onCancel, onConnected}: Props) {
|
||||
function QrView(props: Props) {
|
||||
const pollingRef = useRef<any>(null);
|
||||
const [info, setInfo] = useState<any>(null)
|
||||
//初始化,生成链接
|
||||
const init = async () => {
|
||||
let res = await brandTelegramScan(props.uuid)
|
||||
setInfo(res)
|
||||
startPolling()
|
||||
}
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollingRef.current = setInterval(async () => {
|
||||
const res: any = await getSettingApi();
|
||||
if (res.brand?.telegram_chat_id) {
|
||||
stopPolling()
|
||||
props.onConnected()
|
||||
}
|
||||
|
||||
}, 3000); // 每 3 秒查一次
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
return () => stopPolling();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-5 rounded-lg border border-border/60 bg-muted/30 p-5 sm:flex-row sm:items-start sm:gap-7">
|
||||
<div className="flex shrink-0 justify-center rounded-lg border border-border bg-white p-3">
|
||||
<div className="telegram-qr-placeholder h-[180px] w-[180px] rounded bg-white"/>
|
||||
<QRCodeCanvas
|
||||
value={info?.deeplink}
|
||||
size={200}
|
||||
level={"H"}/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<p className="text-sm font-medium">Scan with your phone camera</p>
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"/>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"/>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"/>
|
||||
</span>
|
||||
</div>
|
||||
Auto-checking - link expires in 15 minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnected}
|
||||
onClick={props.onConnected}
|
||||
className="text-xs font-medium text-muted-foreground underline decoration-muted-foreground/60 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||
>
|
||||
Simulate Telegram connected ->
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-muted-foreground hover:text-red-600"
|
||||
>
|
||||
onClick={props.onCancel}
|
||||
className="text-xs text-muted-foreground hover:text-red-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import React, {useState} from 'react';
|
||||
import {CheckCircle2, MessagesSquare, Smartphone} from "lucide-react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {brandTelegram} from "@/api/set";
|
||||
|
||||
|
||||
function WorkList() {
|
||||
interface Props {
|
||||
brandId: string;
|
||||
onSuccess: () => void;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
function WorkList(props: Props) {
|
||||
const router = useRouter();
|
||||
//显示输入id
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [manualChatId, setManualChatId] = useState("");
|
||||
|
||||
function onSubmit() {
|
||||
router.push("/onboarding/extension")
|
||||
}
|
||||
|
||||
/** 保存手动 chat_id 的占位方法。 */
|
||||
function handleManualSave() {
|
||||
async function handleManualSave() {
|
||||
if (!manualChatId.trim()) return;
|
||||
await brandTelegram(props.brandId, manualChatId)
|
||||
props.onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -37,7 +42,7 @@ function WorkList() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
onClick={props.onConnect}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-[#229ED9] px-5 text-sm font-medium text-white transition-opacity hover:opacity-90">
|
||||
Connect with Telegram
|
||||
</button>
|
||||
|
||||
35
src/store/extension.ts
Normal file
35
src/store/extension.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {create} from "zustand";
|
||||
|
||||
type ExtensionState = {
|
||||
/*** 是否安装了扩展*/
|
||||
isInstalled: boolean,
|
||||
/*** 是否第一次*/
|
||||
isFirst: boolean,
|
||||
/*** 是否抓取中*/
|
||||
isFetching: boolean,
|
||||
extensionInfo: ExtensionInfo,
|
||||
setInstalled: (status: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展信息
|
||||
*/
|
||||
type ExtensionInfo = {
|
||||
//下载地址
|
||||
downloadUrl: string,
|
||||
//扩展商店
|
||||
chromeUrl: string,
|
||||
}
|
||||
|
||||
|
||||
const useExtensionStore = create<ExtensionState>((set) => ({
|
||||
isInstalled: false,
|
||||
isFirst: true,
|
||||
isFetching: false,
|
||||
extensionInfo: {
|
||||
downloadUrl: "/extensions/storeai-extension-v0.1.4.zip",
|
||||
chromeUrl:"chrome://extensions"
|
||||
},
|
||||
setInstalled: (value) => set({isInstalled: value}),
|
||||
}))
|
||||
export default useExtensionStore
|
||||
@@ -1,18 +1,28 @@
|
||||
import {create} from "zustand";
|
||||
import {persist, createJSONStorage} from "zustand/middleware";
|
||||
|
||||
type UserStore = {
|
||||
type UserState = {
|
||||
token: string;
|
||||
user: UserInfo | null,
|
||||
setToken: (token: string) => void;
|
||||
setUser: (user: UserInfo | null) => void;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
const useUserStore = create<UserStore>()(
|
||||
type UserInfo = {
|
||||
id: string,
|
||||
email: string,
|
||||
name: string | null,
|
||||
}
|
||||
|
||||
const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: "",
|
||||
user: null,
|
||||
setToken: (token) => set({token}),
|
||||
clearToken: () => set({token: ""}),
|
||||
setUser: (user) => set({user}),
|
||||
}),
|
||||
{
|
||||
name: "user-storage",
|
||||
|
||||
25
src/utils/extension/detect_extension.ts
Normal file
25
src/utils/extension/detect_extension.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {STORE_REPLY_EVENTS, STORE_SEND_EVENTS} from "./type";
|
||||
import useExtensionStore from "@/store/extension";
|
||||
|
||||
/**
|
||||
* 检擦扩展是否安装
|
||||
*/
|
||||
export const detectExtension = () => {
|
||||
let timer: any = null;
|
||||
|
||||
// 定义响应处理器
|
||||
const handleResponse = (event: any) => {
|
||||
// 移除监听,释放内存
|
||||
window.removeEventListener(STORE_SEND_EVENTS.PING, handleResponse);
|
||||
clearTimeout(timer);
|
||||
useExtensionStore.getState().setInstalled(true)
|
||||
};
|
||||
|
||||
// 1. 开始监听扩展的回传信号
|
||||
window.addEventListener(STORE_REPLY_EVENTS.PONG, handleResponse);
|
||||
|
||||
timer = setInterval(() => {
|
||||
// 持续发送 PING 指令
|
||||
window.dispatchEvent(new CustomEvent(STORE_SEND_EVENTS.PING));
|
||||
}, 2000);
|
||||
}
|
||||
15
src/utils/extension/type.ts
Normal file
15
src/utils/extension/type.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 发送给扩展的信息 (Web -> Extension)
|
||||
*/
|
||||
export const STORE_SEND_EVENTS = {
|
||||
// 查询扩展是否安装
|
||||
PING: "STORE_AI_EVT_WEB_PING",
|
||||
};
|
||||
|
||||
/**
|
||||
* 扩展返回的信息 (Extension -> Web)
|
||||
*/
|
||||
export const STORE_REPLY_EVENTS = {
|
||||
// 确认已安装
|
||||
PONG: "STORE_AI_EVT_EXT_PONG",
|
||||
};
|
||||
71
src/utils/reqeust.ts
Normal file
71
src/utils/reqeust.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios"
|
||||
import useUserStore from "@/store/user";
|
||||
|
||||
const baseURl = process.env.NEXT_PUBLIC_API_URL as string
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: baseURl,
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
//请求拦截器
|
||||
service.interceptors.request.use((config) => {
|
||||
//
|
||||
//当数据为formData,自动修改请求头
|
||||
if ((config.data instanceof FormData)) {
|
||||
(config as any).headers = {"Content-Type": "multipart/form-data"}
|
||||
}
|
||||
let token = useUserStore.getState().token
|
||||
if (token) {
|
||||
(config.headers).Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
}, (error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
//响应拦截器
|
||||
|
||||
service.interceptors.response.use((config: AxiosResponse) => {
|
||||
const {code, data} = config.data
|
||||
|
||||
if ([1, '200'].includes(code)) {
|
||||
return data
|
||||
//当为文件流时
|
||||
} else if (config.headers['content-types'] == 'application/octet-stream') {
|
||||
return config.data
|
||||
} else {
|
||||
// Message.error(message);
|
||||
return Promise.reject(config.data)
|
||||
}
|
||||
}, error => {
|
||||
if (error.message == 'Network Error') {
|
||||
// Toast.error("网络异常")
|
||||
}
|
||||
return Promise.reject()
|
||||
})
|
||||
|
||||
|
||||
function requestPost(url: string, data = {}, config: AxiosRequestConfig = {}) {
|
||||
return service.post(url, data, config)
|
||||
}
|
||||
function requestPatch(url: string, data = {}, config: AxiosRequestConfig = {}) {
|
||||
return service.patch(url, data, config)
|
||||
}
|
||||
|
||||
|
||||
function requestGet(url: string, params: any = {}) {
|
||||
return service.get(url, {params})
|
||||
}
|
||||
|
||||
function requestDelete(url: string, data = {}) {
|
||||
return service.delete(url, {data: data})
|
||||
}
|
||||
|
||||
let request = {
|
||||
get: requestGet,
|
||||
post: requestPost,
|
||||
delete: requestDelete,
|
||||
patch: requestPatch,
|
||||
}
|
||||
export default request
|
||||
Reference in New Issue
Block a user