1
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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
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 {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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
box-sizing: border-box;
|
||||||
border-color: var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -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're signed in. Set up your brand below to start the 24-hour trial.
|
<div className="text-xs opacity-90">
|
||||||
</div>
|
You'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'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'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'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'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 ->
|
|
||||||
</Link>
|
|
||||||
</form>
|
|
||||||
</StepCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
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 ->
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
|
|||||||
@@ -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 ->
|
Simulate Telegram connected ->
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
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 {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",
|
||||||
|
|||||||
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