This commit is contained in:
zhu
2026-05-09 14:37:56 +08:00
parent d1285b7800
commit 521eea47d2
29 changed files with 1492 additions and 279 deletions

View File

@@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */
}; };
export default nextConfig; export default nextConfig;

View File

@@ -12,6 +12,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "16.2.5", "next": "16.2.5",
"qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zustand": "^5.0.13" "zustand": "^5.0.13"

12
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
next: next:
specifier: 16.2.5 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) 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: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
@@ -746,6 +749,11 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'} 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: react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies: peerDependencies:
@@ -1359,6 +1367,10 @@ snapshots:
proxy-from-env@2.1.0: {} 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): react-dom@19.2.4(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4

43
src/api/set.ts Normal file
View 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
View 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,
})
}

View File

@@ -2,12 +2,14 @@
import {Suspense, useState} from "react"; import {Suspense, useState} from "react";
import Link from "next/link"; import Link from "next/link";
import {useSearchParams} from "next/navigation"; import {useRouter, useSearchParams} from "next/navigation";
import {AlertTriangle, Lock, Mail} from "lucide-react"; import {AlertTriangle, Lock, Mail} from "lucide-react";
import {PasswordToggle} from "../components/password-toggle"; import {PasswordToggle} from "../components/password-toggle";
import {getLoginNotice} from "./login-error"; import {getLoginNotice} from "./login-error";
import {validateSignup} from "../validate"; import {validateSignup} from "../validate";
import {loginApi} from "@/api/user";
import useUserStore from "@/store/user";
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */ /** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
@@ -26,6 +28,8 @@ function LoginFormFallback() {
/** 登录表单主体,保留 query 逻辑、校验和错误态。 */ /** 登录表单主体,保留 query 逻辑、校验和错误态。 */
function LoginForm() { function LoginForm() {
const router = useRouter();
const userStore = useUserStore();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
/*** 提示 */ /*** 提示 */
const loginNotice = getLoginNotice(searchParams.get("error_type")); const loginNotice = getLoginNotice(searchParams.get("error_type"));
@@ -51,9 +55,12 @@ function LoginForm() {
} }
setLoading(true); setLoading(true);
try { try {
let res: any = await loginApi(email, password)
} catch (err) { userStore.setToken(res.token)
userStore.setUser(res.user)
router.replace("/dashboard")
} catch (err: any) {
setError(err?.message || "Login failed.")
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -7,6 +7,8 @@ import {CheckCircle2, Lock, Mail, Sparkles} from "lucide-react";
import {PasswordToggle} from "../components/password-toggle"; import {PasswordToggle} from "../components/password-toggle";
import {validateSignup} from "../validate"; import {validateSignup} from "../validate";
import {useRouter} from "next/navigation"; 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 逻辑、密码强度和校验。 */ /** 注册表单主体,保留 query 逻辑、密码强度和校验。 */
export default function SignupForm() { export default function SignupForm() {
const router = useRouter(); const router = useRouter();
//表单
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
//错误
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const strength = useMemo(() => gradePassword(password), [password]); const strength = useMemo(() => gradePassword(password), [password]);
//显示验证码
const [showVerifyCode, setShowVerifyCode] = useState<boolean>(false)
/** 处理注册表单提交,真实接口接入前只执行前端校验和占位提交。 */ /** 处理注册表单提交,真实接口接入前只执行前端校验和占位提交。 */
async function handleSubmit(event: any) { async function handleSubmit(event: any) {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
/**
* 参数效验
*/
const validationError = validateSignup({email, password}); const validationError = validateSignup({email, password});
if (validationError) { if (validationError) {
setError(validationError); setError(validationError);
@@ -55,15 +65,19 @@ export default function SignupForm() {
setLoading(true); setLoading(true);
try { try {
router.push("/onboarding"); await registerApi(email, password,email)
// await submitSignup({email, password}); setShowVerifyCode(true)
} catch (err) { } catch (err:any) {
// setError(err instanceof Error ? err.message : "Sign-up failed."); setError(err?.message || "Sign-up failed.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
if (showVerifyCode) {
return <VerifyCode email={email}/>
}
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<div className="space-y-2"> <div className="space-y-2">

View 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>
);
}

View File

@@ -1,36 +1,35 @@
import Link from "next/link"; import Link from "next/link";
export function Header() { export function Header() {
return ( return (
<header className="sticky top-0 z-30 w-full border-b border-border/60 bg-background/80 backdrop-blur"> <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"> <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"> <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
S className="inline-flex h-6 w-6 items-center justify-center rounded bg-foreground text-[11px] font-bold text-background">
</span> S
StoreAI </div>
</Link> StoreAI
<nav className="flex items-center gap-1 text-sm"> </Link>
<Link <nav className="flex items-center gap-1 text-sm">
href="/pricing" <Link
className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground" href="/pricing"
> className="rounded-md px-3 py-1.5 text-muted-foreground transition-colors hover:text-foreground">
Pricing Pricing
</Link> </Link>
<Link <Link
href="/login" href="/login"
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"
> >
Sign in Sign in
</Link> </Link>
<Link <Link
href="/signup" 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
Start free </Link>
</Link> </nav>
</nav> </div>
</div> </header>
</header> );
);
} }

View 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&apos;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;

View File

@@ -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&rsquo;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;

View 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 &middot; 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&rsquo;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;

View 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 todays 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&rsquo;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>
);
}

View 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>
)
}

View 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;

View 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

View 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>
);
}

View File

@@ -26,7 +26,6 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
border-color: var(--border);
} }
body { body {

View File

@@ -1,134 +1,147 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import Link from "next/link"; import Link from "next/link";
import { CheckCircle2 } from "lucide-react"; import {CheckCircle2} from "lucide-react";
import { StepCard } from "../components/step-card"; 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 }> = [ const TIMEZONE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)" }, {value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)"},
{ value: "Asia/Singapore", label: "Singapore (UTC+8)" }, {value: "Asia/Singapore", label: "Singapore (UTC+8)"},
{ value: "Asia/Bangkok", label: "Bangkok (UTC+7)" }, {value: "Asia/Bangkok", label: "Bangkok (UTC+7)"},
{ value: "Asia/Jakarta", label: "Jakarta (UTC+7)" }, {value: "Asia/Jakarta", label: "Jakarta (UTC+7)"},
{ value: "Asia/Manila", label: "Manila (UTC+8)" }, {value: "Asia/Manila", label: "Manila (UTC+8)"},
{ value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (UTC+7)" }, {value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (UTC+7)"},
{ value: "Asia/Shanghai", label: "Shanghai (UTC+8)" }, {value: "Asia/Shanghai", label: "Shanghai (UTC+8)"},
{ value: "Asia/Hong_Kong", label: "Hong Kong (UTC+8)" }, {value: "Asia/Hong_Kong", label: "Hong Kong (UTC+8)"},
{ value: "Asia/Taipei", label: "Taipei (UTC+8)" }, {value: "Asia/Taipei", label: "Taipei (UTC+8)"},
{ value: "Asia/Tokyo", label: "Tokyo (UTC+9)" }, {value: "Asia/Tokyo", label: "Tokyo (UTC+9)"},
{ value: "Asia/Seoul", label: "Seoul (UTC+9)" }, {value: "Asia/Seoul", label: "Seoul (UTC+9)"},
{ value: "Australia/Sydney", label: "Sydney (UTC+10/11)" }, {value: "Australia/Sydney", label: "Sydney (UTC+10/11)"},
{ value: "Europe/London", label: "London (UTC+0/1)" }, {value: "Europe/London", label: "London (UTC+0/1)"},
{ value: "America/New_York", label: "New York (UTC-5/4)" }, {value: "America/New_York", label: "New York (UTC-5/4)"},
{ value: "America/Los_Angeles", label: "Los Angeles (UTC-8/7)" }, {value: "America/Los_Angeles", label: "Los Angeles (UTC-8/7)"},
{ value: "UTC", label: "UTC" }, {value: "UTC", label: "UTC"},
]; ];
/** onboarding 第一步:品牌名称和时区表单。 */ /** onboarding 第一步:品牌名称和时区表单。 */
export default function BrandStepPage() { export default function BrandStepPage() {
const [name, setName] = useState(""); const router = useRouter();
const [timezone, setTimezone] = useState("Asia/Kuala_Lumpur"); const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null); const [timezone, setTimezone] = useState("Asia/Kuala_Lumpur");
const [error, setError] = useState<string | null>(null);
useEffect(() => { const [loading, setLoading] = useState<boolean>(false)
try {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; useEffect(() => {
if (detected && TIMEZONE_OPTIONS.some((item) => item.value === detected)) { const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
setTimezone(detected); 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 链接负责流转。 */ /** 品牌表单提交占位,目前只做前端校验并让 Continue 链接负责流转。 */
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit() {
if (!name.trim()) { if (!name.trim()) {
event.preventDefault(); setError("Brand name is required.");
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 ( return (
<div className="space-y-5"> <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"> <div
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden /> className="flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<div> <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden/>
<div className="font-semibold">Account created</div> <div>
<div className="text-xs opacity-90"> <div className="font-semibold">Account created</div>
You&apos;re signed in. Set up your brand below to start the 24-hour trial. <div className="text-xs opacity-90">
</div> You&apos;re signed in. Set up your brand below to start the 24-hour trial.
</div>
</div>
</div>
<StepCard
eyebrow="Step 1 of 4"
title="Name your brand"
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
</label>
<input
id="brand-name"
type="text"
maxLength={80}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Victor Sports"
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
/>
<p className="text-xs text-muted-foreground">
Shows up in your reports - pick something you&apos;ll recognise on Telegram.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="brand-tz" className="text-sm font-medium">
Timezone
</label>
<select
id="brand-tz"
value={timezone}
onChange={(event) => setTimezone(event.target.value)}
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
>
{TIMEZONE_OPTIONS.map((timezoneOption) => (
<option key={timezoneOption.value} value={timezoneOption.value}>
{timezoneOption.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Pick where you&apos;ll actually read the reports. Morning brief lands at 08:00 local,
evening recap at 17:00.
</p>
</div>
{error && (
<p className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-900">
{error}
</p>
)}
<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> </div>
</div> );
<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">
<div className="space-y-1.5">
<label htmlFor="brand-name" className="text-sm font-medium">
Brand name
</label>
<input
id="brand-name"
type="text"
required
maxLength={80}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Victor Sports"
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
/>
<p className="text-xs text-muted-foreground">
Shows up in your reports - pick something you&apos;ll recognise on Telegram.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="brand-tz" className="text-sm font-medium">
Timezone
</label>
<select
id="brand-tz"
value={timezone}
onChange={(event) => setTimezone(event.target.value)}
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
>
{TIMEZONE_OPTIONS.map((timezoneOption) => (
<option key={timezoneOption.value} value={timezoneOption.value}>
{timezoneOption.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Pick where you&apos;ll actually read the reports. Morning brief lands at 08:00 local,
evening recap at 17:00.
</p>
</div>
{error && (
<p className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-900">
{error}
</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 -&gt;
</Link>
</form>
</StepCard>
</div>
);
} }

View File

@@ -1,53 +1,15 @@
import React, {useState} from 'react'; import {ArrowRight, CheckCircle2, Loader2} from "lucide-react";
import {ArrowRight, CheckCircle2, HelpCircle, Loader2} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import useExtensionStore from "@/store/extension";
import {useEffect} from "react";
import {detectExtension} from "@/utils/extension/detect_extension";
const LiveCard = () => { const LiveCard = () => {
const [installed, setInstalled] = useState<boolean | null>(null); const extensionStore = useExtensionStore();
const [showTroubleshoot, setShowTroubleshoot] = useState(false); useEffect(() => {
detectExtension()
/** }, []);
* 模拟扩展检测成功,用于先走通 onboarding 流程。 if (extensionStore.isInstalled) {
*/
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) {
return ( return (
<div <div
className="flex items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4"> 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> </div>
); );
} }
return ( 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 justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-3"> <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> <div>
<p className="text-sm font-medium">Waiting for install...</p> <p className="text-sm font-medium">Waiting for install...</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
@@ -79,15 +40,9 @@ function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
</p> </p>
</div> </div>
</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> </div>
); );
} }

View File

@@ -1,52 +1,53 @@
import Link from "next/link"; import Link from "next/link";
import { SignOutLink } from "./components/sign-out-link"; import {SignOutLink} from "./components/sign-out-link";
import { Stepper } from "./components/stepper"; import {Stepper} from "./components/stepper";
import "./index.scss"; import "./index.scss";
/** /**
* onboarding 嵌套路由的布局入参。 * onboarding 嵌套路由的布局入参。
*/ */
interface OnboardingLayoutProps { interface OnboardingLayoutProps {
/** 当前步骤页面渲染出来的中间内容。 */ /** 当前步骤页面渲染出来的中间内容。 */
children: React.ReactNode; children: React.ReactNode;
} }
/** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */ /** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */
export default function OnboardingLayout({ children }: OnboardingLayoutProps) { export default function OnboardingLayout({children}: OnboardingLayoutProps) {
return ( return (
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background"> <main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
<div <div
aria-hidden="true" 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 <div
aria-hidden="true" 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 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"> <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"> <header className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold tracking-tight"> <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
S className="inline-flex h-7 w-7 items-center justify-center rounded bg-foreground text-xs font-bold text-background">
</span> S
StoreAI </div>
</Link> StoreAI
<SignOutLink /> </Link>
</header> <SignOutLink/>
</header>
<div className="mt-10 sm:mt-14"> <div className="mt-10 sm:mt-14">
<Stepper /> <Stepper/>
</div> </div>
<div className="mt-10 flex-1 sm:mt-14">{children}</div> <div className="mt-10 flex-1 sm:mt-14">{children}</div>
<div className="mt-12 text-center text-[11px] text-muted-foreground/80"> <div className="mt-12 text-center text-[11px] text-muted-foreground/80">
You can change anything later in Settings. You can change anything later in Settings.
</div> </div>
</div> </div>
</main> </main>
); );
} }

View File

@@ -1,19 +1,39 @@
"use client"; "use client";
import {useState} from "react"; import {useEffect, useState} from "react";
import Link from "next/link"; import Link from "next/link";
import {ArrowLeft, ArrowRight, CheckCircle2} from "lucide-react"; import {ArrowLeft, ArrowRight, CheckCircle2} from "lucide-react";
import {StepCard} from "../components/step-card"; import {StepCard} from "../components/step-card";
import QrView from "./qr_view"; import QrView from "./qr_view";
import WorkList from "./work_list"; import WorkList from "./work_list";
import {getBrandStatusApi, getSettingApi} from "@/api/set";
/** onboarding 第二步Telegram 连接 UI。 */ /** onboarding 第二步Telegram 连接 UI。 */
export default function TelegramStepPage() { export default function TelegramStepPage() {
//设置
const [xb, setXb] = useState<any>(null)
const [pairingStarted, setPairingStarted] = useState(false); const [pairingStarted, setPairingStarted] = useState(false);
const [connected, setConnected] = 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 ( return (
<StepCard <StepCard
eyebrow="Step 2 of 4" eyebrow="Step 2 of 4"
@@ -24,11 +44,14 @@ export default function TelegramStepPage() {
if (connected) return <ConnectedView/>; if (connected) return <ConnectedView/>;
if (pairingStarted) return ( if (pairingStarted) return (
<QrView <QrView
uuid={xb?.brand?.id}
onCancel={() => setPairingStarted(false)} onCancel={() => setPairingStarted(false)}
onConnected={() => setConnected(true)} 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"> <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"/> <ArrowLeft className="h-3 w-3" aria-hidden="true"/>
Back Back
</Link> </Link>
<Link {
href="/onboarding/extension" connected ? <Link
className="text-right text-xs text-muted-foreground transition-colors hover:text-foreground"> href="/onboarding/extension"
Skip - reports will only show on the dashboard -&gt; 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"}>
</Link> 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 -&gt;
</Link>
}
</div> </div>
</div> </div>
</StepCard> </StepCard>

View File

@@ -1,4 +1,9 @@
import {useEffect, useRef, useState} from "react";
import {brandTelegramScan, getSettingApi} from "@/api/set";
import {QRCodeCanvas} from 'qrcode.react';
interface Props { interface Props {
uuid: string,
/** 取消当前二维码配对流程。 */ /** 取消当前二维码配对流程。 */
onCancel: () => void; onCancel: () => void;
/** 模拟 Telegram 连接成功。 */ /** 模拟 Telegram 连接成功。 */
@@ -7,35 +12,68 @@ interface Props {
/** 静态二维码配对视图,不生成真实 deeplink。 */ /** 静态二维码配对视图,不生成真实 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 ( return (
<div <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"> 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="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>
<div className="min-w-0 flex-1 space-y-3"> <div className="min-w-0 flex-1 space-y-3">
<p className="text-sm font-medium">Scan with your phone camera</p> <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"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="relative flex h-2 w-2"> <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
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"/> className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"/>
</span> <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"/>
</div>
Auto-checking - link expires in 15 minutes Auto-checking - link expires in 15 minutes
</p> </div>
<div className="flex flex-wrap items-center gap-3 pt-1"> <div className="flex flex-wrap items-center gap-3 pt-1">
<button <button
type="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" 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 -&gt; Simulate Telegram connected -&gt;
</button> </button>
<button <button
type="button" type="button"
onClick={onCancel} onClick={props.onCancel}
className="text-xs text-muted-foreground hover:text-red-600" className="text-xs text-muted-foreground hover:text-red-600">
>
Cancel Cancel
</button> </button>
</div> </div>

View File

@@ -1,21 +1,26 @@
import React, {useState} from 'react'; import React, {useState} from 'react';
import {CheckCircle2, MessagesSquare, Smartphone} from "lucide-react"; import {CheckCircle2, MessagesSquare, Smartphone} from "lucide-react";
import {useRouter} from "next/navigation"; 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(); const router = useRouter();
//显示输入id //显示输入id
const [showManual, setShowManual] = useState(false); const [showManual, setShowManual] = useState(false);
const [manualChatId, setManualChatId] = useState(""); const [manualChatId, setManualChatId] = useState("");
function onSubmit() {
router.push("/onboarding/extension")
}
/** 保存手动 chat_id 的占位方法。 */ /** 保存手动 chat_id 的占位方法。 */
function handleManualSave() { async function handleManualSave() {
if (!manualChatId.trim()) return; if (!manualChatId.trim()) return;
await brandTelegram(props.brandId, manualChatId)
props.onSuccess();
} }
return ( return (
@@ -37,7 +42,7 @@ function WorkList() {
<button <button
type="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"> 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 Connect with Telegram
</button> </button>

35
src/store/extension.ts Normal file
View 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

View File

@@ -1,18 +1,28 @@
import {create} from "zustand"; import {create} from "zustand";
import {persist, createJSONStorage} from "zustand/middleware"; import {persist, createJSONStorage} from "zustand/middleware";
type UserStore = { type UserState = {
token: string; token: string;
user: UserInfo | null,
setToken: (token: string) => void; setToken: (token: string) => void;
setUser: (user: UserInfo | null) => void;
clearToken: () => void; clearToken: () => void;
} }
const useUserStore = create<UserStore>()( type UserInfo = {
id: string,
email: string,
name: string | null,
}
const useUserStore = create<UserState>()(
persist( persist(
(set) => ({ (set) => ({
token: "", token: "",
user: null,
setToken: (token) => set({token}), setToken: (token) => set({token}),
clearToken: () => set({token: ""}), clearToken: () => set({token: ""}),
setUser: (user) => set({user}),
}), }),
{ {
name: "user-storage", name: "user-storage",

View 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);
}

View 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
View 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