diff --git a/next.config.ts b/next.config.ts index e9ffa30..db0a372 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ }; export default nextConfig; diff --git a/package.json b/package.json index ace9c61..27bb8da 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "classnames": "^2.5.1", "lucide-react": "^1.14.0", "next": "16.2.5", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1ccaea..2f4a709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: next: specifier: 16.2.5 version: 16.2.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -746,6 +749,11 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -1359,6 +1367,10 @@ snapshots: proxy-from-env@2.1.0: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/api/set.ts b/src/api/set.ts new file mode 100644 index 0000000..d6f3888 --- /dev/null +++ b/src/api/set.ts @@ -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") +} \ No newline at end of file diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..cc85b7a --- /dev/null +++ b/src/api/user.ts @@ -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, + }) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 450a668..8c01f7d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -2,12 +2,14 @@ import {Suspense, useState} from "react"; import Link from "next/link"; -import {useSearchParams} from "next/navigation"; +import {useRouter, useSearchParams} from "next/navigation"; import {AlertTriangle, Lock, Mail} from "lucide-react"; import {PasswordToggle} from "../components/password-toggle"; import {getLoginNotice} from "./login-error"; import {validateSignup} from "../validate"; +import {loginApi} from "@/api/user"; +import useUserStore from "@/store/user"; /** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */ @@ -26,6 +28,8 @@ function LoginFormFallback() { /** 登录表单主体,保留 query 逻辑、校验和错误态。 */ function LoginForm() { + const router = useRouter(); + const userStore = useUserStore(); const searchParams = useSearchParams(); /*** 提示 */ const loginNotice = getLoginNotice(searchParams.get("error_type")); @@ -51,9 +55,12 @@ function LoginForm() { } setLoading(true); try { - - } catch (err) { - + let res: any = await loginApi(email, password) + userStore.setToken(res.token) + userStore.setUser(res.user) + router.replace("/dashboard") + } catch (err: any) { + setError(err?.message || "Login failed.") } finally { setLoading(false); } diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 79c72ae..50fccc3 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -7,6 +7,8 @@ import {CheckCircle2, Lock, Mail, Sparkles} from "lucide-react"; import {PasswordToggle} from "../components/password-toggle"; import {validateSignup} from "../validate"; import {useRouter} from "next/navigation"; +import {registerApi, sendVerifyCodeApi} from "@/api/user"; +import VerifyCode from "./verify-code"; /** 密码强度的分级结果。 */ @@ -35,18 +37,26 @@ const STRENGTH_LABEL_COLOR: Record = { /** 注册表单主体,保留 query 逻辑、密码强度和校验。 */ export default function SignupForm() { const router = useRouter(); + //表单 const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + //错误 const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const strength = useMemo(() => gradePassword(password), [password]); + //显示验证码 + const [showVerifyCode, setShowVerifyCode] = useState(false) + /** 处理注册表单提交,真实接口接入前只执行前端校验和占位提交。 */ async function handleSubmit(event: any) { event.preventDefault(); setError(null); + /** + * 参数效验 + */ const validationError = validateSignup({email, password}); if (validationError) { setError(validationError); @@ -55,15 +65,19 @@ export default function SignupForm() { setLoading(true); try { - router.push("/onboarding"); - // await submitSignup({email, password}); - } catch (err) { - // setError(err instanceof Error ? err.message : "Sign-up failed."); + await registerApi(email, password,email) + setShowVerifyCode(true) + } catch (err:any) { + setError(err?.message || "Sign-up failed."); } finally { setLoading(false); } } + if (showVerifyCode) { + return + } + return (
diff --git a/src/app/(auth)/signup/verify-code.tsx b/src/app/(auth)/signup/verify-code.tsx new file mode 100644 index 0000000..eee48a1 --- /dev/null +++ b/src/app/(auth)/signup/verify-code.tsx @@ -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 ( +
+ {/* 头部 UI */} +
+
+ +
+

Check your inbox

+
+ We sent a 6-digit code and a link to +
{email}
+
+
+ + {/* 验证码输入区域 */} +
+ {code.map((digit, i) => ( + { + 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" + /> + ))} +
+ +
+ Reading email on your phone? + Tap the link in the email — it works on any device. Or come back here and type the 6-digit code. +
+ + {/* 按钮 */} + + + {/* 底部按钮 */} +
+ + + Back to sign in + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(home)/components/header/index.tsx b/src/app/(home)/components/header/index.tsx index a40da56..6605450 100644 --- a/src/app/(home)/components/header/index.tsx +++ b/src/app/(home)/components/header/index.tsx @@ -1,36 +1,35 @@ import Link from "next/link"; export function Header() { - return ( -
-
- - - S - - StoreAI - - -
-
- ); + return ( +
+
+ +
+ S +
+ StoreAI + + +
+
+ ); } diff --git a/src/app/dashboard/(home)/_components/result/today-metrics.tsx b/src/app/dashboard/(home)/_components/result/today-metrics.tsx new file mode 100644 index 0000000..e85e27e --- /dev/null +++ b/src/app/dashboard/(home)/_components/result/today-metrics.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {AlertCircle, ArrowDownRight, ArrowUpRight, Clock} from "lucide-react"; + +const TodayMetrics = () => { + return ( +
+ {/*头部*/} +
+

+ Numbers +

+ + Today's metrics, trends and SKU mix. + +
+
+ < TodayHero/> +
+
+ ); +}; + +/** + * 今日指标面板 + * @constructor + */ +const TodayHero = () => { + return ( +
+ {/*头部*/} +
+
+ + Scanned 测测 +
+ + + 6 thing to handle + +
+ + {/*rm值*/} +
+
+ GMV today +
+
+
+ RM 100.00 +
+ +
+
+ +
+
+
+ 测测 +
+
+ 111 +
+ {/*{hint && (*/} + {/*
{hint}
*/} + {/*)}*/} +
+
+
+ ); +}; + + +/** + * 趋势标记 + * @param fraction 百分比 + */ +function DeltaChip({fraction}: { fraction: number }) { + if (Math.abs(fraction) < 0.005) { + return ( +
+ flat +
+ ); + } + 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 ( +
+
+ ); +} + + +export default TodayMetrics; \ No newline at end of file diff --git a/src/app/dashboard/(home)/_components/result/today-verdict.tsx b/src/app/dashboard/(home)/_components/result/today-verdict.tsx new file mode 100644 index 0000000..1bac615 --- /dev/null +++ b/src/app/dashboard/(home)/_components/result/today-verdict.tsx @@ -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 ( +
+
+
+ +
+
+ {/*标题*/} +
+
+ Today’s verdict +
+
+ {visual.label} +
+
+ {/*内容*/} +

+ No sales yet this afternoon — RM 0 with 0 orders, and 3 urgent issues need attention. +

+ {/* 因素列表 */} + { +
    + {[1, 2].map((factor, i) => ( +
  • + Zero GMV and zero orders — no baselinesdaaaaaaaaa to compare, but the day hasn't + started. +
  • + ))} +
+ } + + {/* 建议 */} +

+ → + 将两个缺货的VICTOR球拍袋下架,并立即暂停“球拍-团体广告”活动 +

+

+ Updated 4 min ago · + low confidence — limited history +

+
+
+
+ ); +}; + +export default TodayVerdict; \ No newline at end of file diff --git a/src/app/dashboard/(home)/_components/result/top-actions.tsx b/src/app/dashboard/(home)/_components/result/top-actions.tsx new file mode 100644 index 0000000..5783e35 --- /dev/null +++ b/src/app/dashboard/(home)/_components/result/top-actions.tsx @@ -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 ( +
+ {/*头部*/} +
+
+ +
+
+

+ Top 3 actions today +

+

+ Sorted by GMV impact · mark each done as you handle it. +

+
+
+ {/*步骤*/} + {(() => { + if (loading) { + return ; + } + // 当数据为空时 + if (actions == 0) { + return
+ Nothing pressing right now — your store is on autopilot +
+ } + //列表 + return
+ {[1, 2, 3].map((item) => )} +
+ })()} +
+ ); +}; + + +/** + * 加载中 + */ +const Loading = () => { + return ( +
+ + + AI is drafting today’s top actions — should land in a few + seconds. This panel refreshes automatically. + +
+ ); +}; + + +/** + * 步骤卡片 + * @constructor + */ +const TopActionsCard = () => { + return ( +
+ {/*头部*/} +
+
+ 1 +
+
+ + Out of stock +
+
+ {/*标题*/} +

+ Unlist VICTOR Capsule Collection Racket Bag BR9615CPS — stock 0 +

+ {/*标记*/} +
+
+ + RM 50 wasted today +
+ + Not enough history yet + +
+ + {/*原因*/} +

+ 0 stock but still listed — would cause cancellations on next order +

+ {/*尾部*/} +
+ + Open ads centre + + + +
+
+ ); +}; + + +export default TopActions; \ No newline at end of file diff --git a/src/app/dashboard/(home)/_components/start-scan.tsx b/src/app/dashboard/(home)/_components/start-scan.tsx new file mode 100644 index 0000000..9f7fe90 --- /dev/null +++ b/src/app/dashboard/(home)/_components/start-scan.tsx @@ -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(() => { + //如果插件未下载 + 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: + Install extension + + + } + } + + //如果正在爬取中 + 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: + } + } + 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: ( + + ), + } + }, [extension.isFetching]) + + /** + * 开始爬取 + */ + function handStart() { + + } + + + return ( +
+
+
+
+ +
+
+

+ Start scanning +

+

+ {configFor.title} +

+

+ {configFor.body} +

+
+
+
+ {configFor.action} +
+
+
+ ); +}; + + +/** + * 链接卡片 + * @constructor + */ +export function ConnectTelegramCard() { + return ( +
+
+
+
+
+
+

+ Connect Telegram for push delivery +

+

+ Reports will land on your phone within seconds of each scan. + Without it, you’ll only see them on this dashboard. +

+
+
+ + Connect +
+
+ ); +} + + diff --git a/src/app/dashboard/(home)/_components/tip.tsx b/src/app/dashboard/(home)/_components/tip.tsx new file mode 100644 index 0000000..c6b1b7f --- /dev/null +++ b/src/app/dashboard/(home)/_components/tip.tsx @@ -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 ( +
+
+
+
+ +
+
+

+ Trial: 23h 1m left — Scan will lock when trial ends +

+

+ 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. +

+
+
+ + + Upgrade Now + + +
+
+ ); +}; + +/** + * 扩展未安装提示 + */ +export const InstalledTip = () => { + const extension = useExtensionStore(); + if (extension.isInstalled) { + return <> + } + return ( +
+
+
+
+
+ + + +
+
+
+ Install steps: 1) Download the .zip and unzip it. 2) Open + chrome://extensions + and turn on Developer mode. 3) Click Load unpacked and pick + the unzipped folder. The extension will sign in automatically with this account. +
+
+ ) +} diff --git a/src/app/dashboard/(home)/page.tsx b/src/app/dashboard/(home)/page.tsx new file mode 100644 index 0000000..562299f --- /dev/null +++ b/src/app/dashboard/(home)/page.tsx @@ -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 ( + <> + +
+ + {/*订阅通知*/} + + +
+ {/*爬取*/} + + {/*检查Telegram连接*/} + + + {/*统计面板*/} + + + +
+
+ + ); +}; + +export default Page; \ No newline at end of file diff --git a/src/app/dashboard/_components/header.tsx b/src/app/dashboard/_components/header.tsx new file mode 100644 index 0000000..0177910 --- /dev/null +++ b/src/app/dashboard/_components/header.tsx @@ -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 ( +
+
+
+ +
+ S +
+ StoreAI + +
+ +
+ + + 测试 + +
+
+
+ ); +} + + +/** + * 导航栏 + */ +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 ( + + ); +}; + + +/** + * 用户信息 + */ +const UserMenu = () => { + return ( +
+
+ 112@qq.com +
+
+ + Sign out +
+
+ ); +}; + + +export default Header diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..c895ed1 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import Header from "./_components/header"; + +interface Props { + children: React.ReactNode; +} + +export default function DashboardLayout({children}: Props) { + + return ( +
+
+
{children}
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 3b3e23e..86219af 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -26,7 +26,6 @@ * { box-sizing: border-box; - border-color: var(--border); } body { diff --git a/src/app/onboarding/brand/page.tsx b/src/app/onboarding/brand/page.tsx index 7173f3e..f74ac60 100644 --- a/src/app/onboarding/brand/page.tsx +++ b/src/app/onboarding/brand/page.tsx @@ -1,134 +1,147 @@ "use client"; -import { useEffect, useState } from "react"; +import {useEffect, useState} from "react"; 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 }> = [ - { value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)" }, - { value: "Asia/Singapore", label: "Singapore (UTC+8)" }, - { value: "Asia/Bangkok", label: "Bangkok (UTC+7)" }, - { value: "Asia/Jakarta", label: "Jakarta (UTC+7)" }, - { value: "Asia/Manila", label: "Manila (UTC+8)" }, - { value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (UTC+7)" }, - { value: "Asia/Shanghai", label: "Shanghai (UTC+8)" }, - { value: "Asia/Hong_Kong", label: "Hong Kong (UTC+8)" }, - { value: "Asia/Taipei", label: "Taipei (UTC+8)" }, - { value: "Asia/Tokyo", label: "Tokyo (UTC+9)" }, - { value: "Asia/Seoul", label: "Seoul (UTC+9)" }, - { value: "Australia/Sydney", label: "Sydney (UTC+10/11)" }, - { value: "Europe/London", label: "London (UTC+0/1)" }, - { value: "America/New_York", label: "New York (UTC-5/4)" }, - { value: "America/Los_Angeles", label: "Los Angeles (UTC-8/7)" }, - { value: "UTC", label: "UTC" }, + {value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)"}, + {value: "Asia/Singapore", label: "Singapore (UTC+8)"}, + {value: "Asia/Bangkok", label: "Bangkok (UTC+7)"}, + {value: "Asia/Jakarta", label: "Jakarta (UTC+7)"}, + {value: "Asia/Manila", label: "Manila (UTC+8)"}, + {value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (UTC+7)"}, + {value: "Asia/Shanghai", label: "Shanghai (UTC+8)"}, + {value: "Asia/Hong_Kong", label: "Hong Kong (UTC+8)"}, + {value: "Asia/Taipei", label: "Taipei (UTC+8)"}, + {value: "Asia/Tokyo", label: "Tokyo (UTC+9)"}, + {value: "Asia/Seoul", label: "Seoul (UTC+9)"}, + {value: "Australia/Sydney", label: "Sydney (UTC+10/11)"}, + {value: "Europe/London", label: "London (UTC+0/1)"}, + {value: "America/New_York", label: "New York (UTC-5/4)"}, + {value: "America/Los_Angeles", label: "Los Angeles (UTC-8/7)"}, + {value: "UTC", label: "UTC"}, ]; /** onboarding 第一步:品牌名称和时区表单。 */ export default function BrandStepPage() { - const [name, setName] = useState(""); - const [timezone, setTimezone] = useState("Asia/Kuala_Lumpur"); - const [error, setError] = useState(null); + const router = useRouter(); + const [name, setName] = useState(""); + const [timezone, setTimezone] = useState("Asia/Kuala_Lumpur"); + const [error, setError] = useState(null); - useEffect(() => { - try { - const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (detected && TIMEZONE_OPTIONS.some((item) => item.value === detected)) { - setTimezone(detected); - } - } catch { - // 保持默认时区。 + const [loading, setLoading] = useState(false) + + useEffect(() => { + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (detected && TIMEZONE_OPTIONS.some((item) => item.value === detected)) { + setTimezone(detected); + } + init() + }, []); + + async function init() { + let res: any = await getBrandStatusApi() + if (res.brand?.name) { + router.replace("/onboarding/telegram") + } } - }, []); - /** 品牌表单提交占位,目前只做前端校验并让 Continue 链接负责流转。 */ - function handleSubmit(event: React.FormEvent) { - if (!name.trim()) { - event.preventDefault(); - setError("Brand name is required."); + /** 品牌表单提交占位,目前只做前端校验并让 Continue 链接负责流转。 */ + async function handleSubmit() { + if (!name.trim()) { + 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 ( -
-
- -
-
Account created
-
- You're signed in. Set up your brand below to start the 24-hour trial. -
+ return ( +
+
+ +
+
Account created
+
+ You're signed in. Set up your brand below to start the 24-hour trial. +
+
+
+ + +
+
+ + 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" + /> +

+ Shows up in your reports - pick something you'll recognise on Telegram. +

+
+ +
+ + +

+ Pick where you'll actually read the reports. Morning brief lands at 08:00 local, + evening recap at 17:00. +

+
+ + {error && ( +

+ {error} +

+ )} + + +
+
-
- - -
-
- - 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" - /> -

- Shows up in your reports - pick something you'll recognise on Telegram. -

-
- -
- - -

- Pick where you'll actually read the reports. Morning brief lands at 08:00 local, - evening recap at 17:00. -

-
- - {error && ( -

- {error} -

- )} - - { - 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 -> - -
-
-
- ); + ); } diff --git a/src/app/onboarding/extension/live-card.tsx b/src/app/onboarding/extension/live-card.tsx index b129a7f..2708c95 100644 --- a/src/app/onboarding/extension/live-card.tsx +++ b/src/app/onboarding/extension/live-card.tsx @@ -1,53 +1,15 @@ -import React, {useState} from 'react'; -import {ArrowRight, CheckCircle2, HelpCircle, Loader2} from "lucide-react"; +import {ArrowRight, CheckCircle2, Loader2} from "lucide-react"; import Link from "next/link"; +import useExtensionStore from "@/store/extension"; +import {useEffect} from "react"; +import {detectExtension} from "@/utils/extension/detect_extension"; const LiveCard = () => { - const [installed, setInstalled] = useState(null); - const [showTroubleshoot, setShowTroubleshoot] = useState(false); - - /** - * 模拟扩展检测成功,用于先走通 onboarding 流程。 - */ - function handleSimulateDetected() { - setInstalled(true); - } - - return ( - <> - {/*检测状态*/} - - - {showTroubleshoot && installed !== true && ( -
-
- -
-

Still not detected?

-
    -
  • Did you flip Developer mode ON?
  • -
  • Did you select the unzipped folder, not the .zip itself?
  • -
  • Does StoreAI show up on chrome://extensions with no red error banner?
  • -
-
-
-
- )} - - ); -}; - - -/** 扩展安装状态面板,当前通过按钮模拟检测成功。 */ -interface LiveStatusProps { - /** 当前是否已经检测到扩展。 */ - installed: boolean | null; - /** 模拟检测成功的点击回调。 */ - onSimulateDetected: () => void; -} - -function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) { - if (installed === true) { + const extensionStore = useExtensionStore(); + useEffect(() => { + detectExtension() + }, []); + if (extensionStore.isInstalled) { return (
@@ -67,11 +29,10 @@ function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
); } - return (
- +

Waiting for install...

@@ -79,15 +40,9 @@ function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {

-
); + } diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index 900fd8d..09a3048 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -1,52 +1,53 @@ import Link from "next/link"; -import { SignOutLink } from "./components/sign-out-link"; -import { Stepper } from "./components/stepper"; +import {SignOutLink} from "./components/sign-out-link"; +import {Stepper} from "./components/stepper"; import "./index.scss"; /** * onboarding 嵌套路由的布局入参。 */ interface OnboardingLayoutProps { - /** 当前步骤页面渲染出来的中间内容。 */ - children: React.ReactNode; + /** 当前步骤页面渲染出来的中间内容。 */ + children: React.ReactNode; } /** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */ -export default function OnboardingLayout({ children }: OnboardingLayoutProps) { - return ( -
-
+ ); } diff --git a/src/app/onboarding/telegram/page.tsx b/src/app/onboarding/telegram/page.tsx index 2a468df..e632fe2 100644 --- a/src/app/onboarding/telegram/page.tsx +++ b/src/app/onboarding/telegram/page.tsx @@ -1,19 +1,39 @@ "use client"; -import {useState} from "react"; +import {useEffect, useState} from "react"; import Link from "next/link"; import {ArrowLeft, ArrowRight, CheckCircle2} from "lucide-react"; import {StepCard} from "../components/step-card"; import QrView from "./qr_view"; import WorkList from "./work_list"; +import {getBrandStatusApi, getSettingApi} from "@/api/set"; /** onboarding 第二步:Telegram 连接 UI。 */ export default function TelegramStepPage() { + //设置 + const [xb, setXb] = useState(null) + const [pairingStarted, setPairingStarted] = useState(false); const [connected, setConnected] = useState(false); + async function init() { + let res: any = await getSettingApi() + setXb(res) + if (res.brand.telegram_chat_id) { + setConnected(true); + } + } + + //设置成功 + const handlePairingSuccess = () => { + setConnected(true); + }; + useEffect(() => { + init() + }, []); + return ( ; if (pairingStarted) return ( setPairingStarted(false)} onConnected={() => setConnected(true)} /> ); - return ; + return setPairingStarted(true)}/>; })()}
@@ -38,11 +61,17 @@ export default function TelegramStepPage() {
diff --git a/src/app/onboarding/telegram/qr_view.tsx b/src/app/onboarding/telegram/qr_view.tsx index 77f6b92..90c2806 100644 --- a/src/app/onboarding/telegram/qr_view.tsx +++ b/src/app/onboarding/telegram/qr_view.tsx @@ -1,4 +1,9 @@ +import {useEffect, useRef, useState} from "react"; +import {brandTelegramScan, getSettingApi} from "@/api/set"; +import {QRCodeCanvas} from 'qrcode.react'; + interface Props { + uuid: string, /** 取消当前二维码配对流程。 */ onCancel: () => void; /** 模拟 Telegram 连接成功。 */ @@ -7,35 +12,68 @@ interface Props { /** 静态二维码配对视图,不生成真实 deeplink。 */ -function QrView({onCancel, onConnected}: Props) { +function QrView(props: Props) { + const pollingRef = useRef(null); + const [info, setInfo] = useState(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 (
-
+

Scan with your phone camera

-

- - - - +

+
+ + +
Auto-checking - link expires in 15 minutes -

+
diff --git a/src/app/onboarding/telegram/work_list.tsx b/src/app/onboarding/telegram/work_list.tsx index 82107ae..8ddf750 100644 --- a/src/app/onboarding/telegram/work_list.tsx +++ b/src/app/onboarding/telegram/work_list.tsx @@ -1,21 +1,26 @@ import React, {useState} from 'react'; import {CheckCircle2, MessagesSquare, Smartphone} from "lucide-react"; import {useRouter} from "next/navigation"; +import {brandTelegram} from "@/api/set"; -function WorkList() { +interface Props { + brandId: string; + onSuccess: () => void; + onConnect: () => void; +} + +function WorkList(props: Props) { const router = useRouter(); //显示输入id const [showManual, setShowManual] = useState(false); const [manualChatId, setManualChatId] = useState(""); - function onSubmit() { - router.push("/onboarding/extension") - } - /** 保存手动 chat_id 的占位方法。 */ - function handleManualSave() { + async function handleManualSave() { if (!manualChatId.trim()) return; + await brandTelegram(props.brandId, manualChatId) + props.onSuccess(); } return ( @@ -37,7 +42,7 @@ function WorkList() { diff --git a/src/store/extension.ts b/src/store/extension.ts new file mode 100644 index 0000000..e3f776f --- /dev/null +++ b/src/store/extension.ts @@ -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((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 \ No newline at end of file diff --git a/src/store/user.ts b/src/store/user.ts index 1b5026e..2b62ec2 100644 --- a/src/store/user.ts +++ b/src/store/user.ts @@ -1,18 +1,28 @@ import {create} from "zustand"; import {persist, createJSONStorage} from "zustand/middleware"; -type UserStore = { +type UserState = { token: string; + user: UserInfo | null, setToken: (token: string) => void; + setUser: (user: UserInfo | null) => void; clearToken: () => void; } -const useUserStore = create()( +type UserInfo = { + id: string, + email: string, + name: string | null, +} + +const useUserStore = create()( persist( (set) => ({ token: "", + user: null, setToken: (token) => set({token}), clearToken: () => set({token: ""}), + setUser: (user) => set({user}), }), { name: "user-storage", diff --git a/src/utils/extension/detect_extension.ts b/src/utils/extension/detect_extension.ts new file mode 100644 index 0000000..6a78509 --- /dev/null +++ b/src/utils/extension/detect_extension.ts @@ -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); +} \ No newline at end of file diff --git a/src/utils/extension/type.ts b/src/utils/extension/type.ts new file mode 100644 index 0000000..f21dda4 --- /dev/null +++ b/src/utils/extension/type.ts @@ -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", +}; diff --git a/src/utils/reqeust.ts b/src/utils/reqeust.ts new file mode 100644 index 0000000..34e0b90 --- /dev/null +++ b/src/utils/reqeust.ts @@ -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 \ No newline at end of file