diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index c467733..79c72ae 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -6,6 +6,7 @@ import {CheckCircle2, Lock, Mail, Sparkles} from "lucide-react"; import {PasswordToggle} from "../components/password-toggle"; import {validateSignup} from "../validate"; +import {useRouter} from "next/navigation"; /** 密码强度的分级结果。 */ @@ -33,7 +34,7 @@ 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); @@ -54,6 +55,7 @@ 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."); diff --git a/src/app/globals.css b/src/app/globals.css index e7b93a9..3b3e23e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -33,7 +33,7 @@ body { margin: 0; background: var(--background); color: var(--foreground); - font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + font-family: Arial, Helvetica, sans-serif; font-feature-settings: "rlig" 1, "calt" 1; } @@ -41,9 +41,6 @@ a { text-decoration: none; } -button, -input, -textarea, -select { - font: inherit; -} +button { + cursor: pointer; +} \ No newline at end of file diff --git a/src/app/onboarding/brand/page.tsx b/src/app/onboarding/brand/page.tsx new file mode 100644 index 0000000..7173f3e --- /dev/null +++ b/src/app/onboarding/brand/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { CheckCircle2 } from "lucide-react"; + +import { StepCard } from "../components/step-card"; + +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" }, +]; + +/** onboarding 第一步:品牌名称和时区表单。 */ +export default function BrandStepPage() { + 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 { + // 保持默认时区。 + } + }, []); + + /** 品牌表单提交占位,目前只做前端校验并让 Continue 链接负责流转。 */ + function handleSubmit(event: React.FormEvent) { + if (!name.trim()) { + event.preventDefault(); + setError("Brand name is required."); + } + } + + 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} +

+ )} + + { + 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/components/sign-out-link/index.tsx b/src/app/onboarding/components/sign-out-link/index.tsx new file mode 100644 index 0000000..13d143c --- /dev/null +++ b/src/app/onboarding/components/sign-out-link/index.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { LogOut } from "lucide-react"; + +/** + * 退出入口的展示参数。 + */ +interface SignOutLinkProps { + /** 顶部展示的当前账号邮箱。 */ + email?: string; +} + +/** onboarding 顶部的退出入口,当前只保留 UI 和点击状态,不接真实退出接口。 */ +export function SignOutLink({ email = "you@brand.com" }: SignOutLinkProps) { + const [busy, setBusy] = useState(false); + + /** 退出点击占位,后续接入真实认证时在这里调用退出接口。 */ + function handleSignOut() { + setBusy(true); + setTimeout(() => setBusy(false), 300); + } + + return ( +
+ {email} + +
+ ); +} diff --git a/src/app/onboarding/components/step-card/index.tsx b/src/app/onboarding/components/step-card/index.tsx new file mode 100644 index 0000000..c94dce4 --- /dev/null +++ b/src/app/onboarding/components/step-card/index.tsx @@ -0,0 +1,40 @@ +/** + * onboarding 步骤卡片的展示参数。 + */ +interface StepCardProps { + /** 顶部小号步骤文案,例如 Step 1 of 4。 */ + eyebrow?: string; + /** 卡片主标题。 */ + title: string; + /** 标题下方的辅助说明。 */ + subtitle?: string; + /** 卡片主体内容。 */ + children: React.ReactNode; + /** 可选底部操作区,适合统一放按钮组。 */ + footer?: React.ReactNode; +} + +/** onboarding 每一步共用的卡片容器,统一标题区、内容区和底部操作区。 */ +export function StepCard({ eyebrow, title, subtitle, children, footer }: StepCardProps) { + return ( +
+
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/src/app/onboarding/components/stepper/index.tsx b/src/app/onboarding/components/stepper/index.tsx new file mode 100644 index 0000000..67803cb --- /dev/null +++ b/src/app/onboarding/components/stepper/index.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Check } from "lucide-react"; + +const STEPS = [ + { path: "/onboarding/brand", label: "Brand" }, + { path: "/onboarding/telegram", label: "Telegram" }, + { path: "/onboarding/extension", label: "Extension" }, + { path: "/onboarding/scan", label: "First scan" }, +] as const; + +/** onboarding 顶部进度条,根据当前路径计算已完成、当前和未开始状态。 */ +export function Stepper() { + const pathname = usePathname(); + const currentIndex = Math.max( + STEPS.findIndex((step) => step.path === pathname), + 0, + ); + + return ( + + ); +} diff --git a/src/app/onboarding/extension/chromium-check.tsx b/src/app/onboarding/extension/chromium-check.tsx new file mode 100644 index 0000000..4ebf036 --- /dev/null +++ b/src/app/onboarding/extension/chromium-check.tsx @@ -0,0 +1,43 @@ +import React, {useEffect, useState} from 'react'; +import {AlertCircle} from "lucide-react"; + + +/** 浏览器类型检测结果。 */ +type BrowserKind = "chromium" | "safari" | "firefox" | "other"; + +const ChromiumCheck = () => { + const [browserKind, setBrowserKind] = useState(null); + + useEffect(() => { + const userAgent = navigator.userAgent; + if (/Firefox\//.test(userAgent)) setBrowserKind("firefox"); + else if (/Safari\//.test(userAgent) && !/Chrome|Chromium|CriOS|EdgA?\//.test(userAgent)) { + setBrowserKind("safari"); + } else if (/Chrome|Chromium|CriOS|Edg\//.test(userAgent)) setBrowserKind("chromium"); + else setBrowserKind("other"); + }, []); + + + if (browserKind && browserKind !== "chromium") { + return ( +
+ +
+

+ {browserKind === "safari" && "Safari is not supported"} + {browserKind === "firefox" && "Firefox is not supported"} + {browserKind === "other" && "This browser may not be supported"} +

+

+ The StoreAI extension only loads in Chromium-based browsers. Switch to Chrome, + Edge, Brave, Arc or Opera and re-open this page to install. +

+
+
+ ); + } + return <> + +}; + +export default ChromiumCheck; \ No newline at end of file diff --git a/src/app/onboarding/extension/icon/dev-mode.tsx b/src/app/onboarding/extension/icon/dev-mode.tsx new file mode 100644 index 0000000..6133b3f --- /dev/null +++ b/src/app/onboarding/extension/icon/dev-mode.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +const common = ( + + + + + +); + +const DevMode = () => { + return ( +
+ + {common} + {/* Window chrome */} + + + + + + + chrome://extensions + + {/* Page header */} + Extensions + {/* Search box */} + + Search extensions + {/* Developer mode toggle (target) */} + Developer mode + + + + + {/* Highlight ring + arrow */} + + + + Flip this toggle ON + +
+ ); +}; + +export default DevMode; \ No newline at end of file diff --git a/src/app/onboarding/extension/icon/load-unpacked.tsx b/src/app/onboarding/extension/icon/load-unpacked.tsx new file mode 100644 index 0000000..82e9d47 --- /dev/null +++ b/src/app/onboarding/extension/icon/load-unpacked.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +const common = ( + + + + + +); + +const LoadUnpacked = () => { + return ( +
+ + {common} + {/* Window chrome */} + + + + + + + chrome://extensions + + {/* Action toolbar revealed by Developer mode */} + + {/* Load unpacked button (target) */} + + + Load unpacked + + + Pack extension + + Update + {/* Dev mode toggle (already on) — show as confirmation */} + Developer mode + + {/* Highlight ring + arrow */} + + + + Click here, then pick the unzipped + folder + + +
+ ); +}; + +export default LoadUnpacked; \ No newline at end of file diff --git a/src/app/onboarding/extension/icon/pin-icon.tsx b/src/app/onboarding/extension/icon/pin-icon.tsx new file mode 100644 index 0000000..4381406 --- /dev/null +++ b/src/app/onboarding/extension/icon/pin-icon.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +const common = ( + + + + + +); + +const PinIcon = () => { + return ( +
+ + {common} + {/* Toolbar strip */} + + + store.bbiz.ai + {/* Puzzle-piece icon (target 1) */} + + + + + {/* Profile / menu placeholders */} + + + + + {/* Dropdown panel */} + + + Extensions + {/* StoreAI row */} + + S + StoreAI + {/* Pushpin (target 2) */} + + + + + {/* Greyed-out other extensions */} + + Other extension + + Other extension + + {/* Arrows */} + + + 1. Click puzzle-piece + + 2. Click pushpin → + +
+ ); +}; + +export default PinIcon; \ No newline at end of file diff --git a/src/app/onboarding/extension/install-card.tsx b/src/app/onboarding/extension/install-card.tsx new file mode 100644 index 0000000..1610625 --- /dev/null +++ b/src/app/onboarding/extension/install-card.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {Download,} from "lucide-react"; + +const InstallCard = () => { + return ( +
+
+
+ +
+
+
+ Download installer +
+
+ StoreAI v1.0.0 / 55KB` +
+ +
+ ); +}; + +export default InstallCard; \ No newline at end of file diff --git a/src/app/onboarding/extension/instruction-list.tsx b/src/app/onboarding/extension/instruction-list.tsx new file mode 100644 index 0000000..b00d778 --- /dev/null +++ b/src/app/onboarding/extension/instruction-list.tsx @@ -0,0 +1,130 @@ +import React, {useState} from 'react'; +import {Check, ChevronDown, ChevronRight, Copy, Pin} from "lucide-react"; +import {copyText} from "@/utils/helper"; +import DevMode from "./icon/dev-mode"; +import LoadUnpacked from "./icon/load-unpacked"; +import PinIcon from "./icon/pin-icon"; + +const InstructionList = () => { + let chrome_extensions_url = "chrome://extensions" + const [showInstructions, setShowInstructions] = useState(true); + //是否已复制 + const [isCopy, setIsCopy] = useState(false) + + async function copyExtension() { + await copyText(chrome_extensions_url) + setIsCopy(true) + setTimeout(() => { + setIsCopy(false) + }, 2000) + } + + return ( +
+ {/*头*/} +
setShowInstructions((value) => !value)} + className="cursor-pointer flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-muted/40" + aria-expanded={showInstructions}> +
+ { + showInstructions ? : + } + How to install the .zip (60 seconds) +
+ {!showInstructions && ( + 6 steps + )} +
+ + {/*步骤*/} + { + showInstructions && +
    + + Click the Download .zip button above. Your browser saves{" "} + storeai-extension-v0.1.0.zip. + + + + macOS double-click the downloaded zip in Finder.{" "} + Windows right-click the .zip and choose Extract All. + + + + Chrome blocks links to chrome:// URLs from web + pages, so copy this and paste into your address bar: +
    + + +
    +
    + + + Top-right of the extensions page, flip the Developer mode toggle + to ON. + + + + + Click Load unpacked, then select the unzipped{" "} + storeai-extension-v1.0.0 folder. + + + + Click the puzzle-piece icon{" "} + +
+ } +
+ ); +}; + +interface InstructionProps { + /** 步骤序号。 */ + step: number; + /** 步骤标题。 */ + title: string; + /** 步骤说明内容。 */ + children: React.ReactNode; +} + +/** 安装步骤说明中的单条步骤。 */ +function Instruction({step, title, children}: InstructionProps) { + return ( +
  • + + {step} + +
    +

    {title}

    +
    {children}
    +
    +
  • + ); +} + +export default InstructionList; \ No newline at end of file diff --git a/src/app/onboarding/extension/live-card.tsx b/src/app/onboarding/extension/live-card.tsx new file mode 100644 index 0000000..b129a7f --- /dev/null +++ b/src/app/onboarding/extension/live-card.tsx @@ -0,0 +1,94 @@ +import React, {useState} from 'react'; +import {ArrowRight, CheckCircle2, HelpCircle, Loader2} from "lucide-react"; +import Link from "next/link"; + +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) { + return ( +
    +
    + +
    +

    Extension detected

    +

    Off to your first scan.

    +
    +
    + + Continue now + + +
    + ); + } + + return ( +
    +
    + +
    +

    Waiting for install...

    +

    + This page will auto-detect StoreAI once the extension is installed. +

    +
    +
    + +
    + ); +} + + +export default LiveCard; \ No newline at end of file diff --git a/src/app/onboarding/extension/page.tsx b/src/app/onboarding/extension/page.tsx new file mode 100644 index 0000000..ac5d3d6 --- /dev/null +++ b/src/app/onboarding/extension/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import {useState} from "react"; +import Link from "next/link"; +import {ArrowLeft, ArrowRight, Clock, ShieldCheck,} from "lucide-react"; + +import {StepCard} from "../components/step-card"; +import InstructionList from "./instruction-list"; +import ChromiumCheck from "./chromium-check"; +import InstallCard from "./install-card"; +import LiveCard from "./live-card"; + + +/** onboarding 第三步:Chrome 扩展安装说明和检测面板。 */ +export default function ExtensionStepPage() { + const [installed, setInstalled] = useState(null); + + + return ( + +
    + + + + +

    + + Chrome Web Store version with auto-updates is on the way for v1.0 - for now, the + developer build above is identical and signed by us. +

    + + {/*提示*/} + + + {/*检测*/} + + +
    + +
    + The extension only runs on seller.shopee.com.my{" "} + and shopee.com.my. It never reads cookies + or pages from any other site. +
    +
    + +
    + + + Back + +
    + + Skip - install from dashboard later → + + + Continue + + +
    +
    +
    +
    + ); +} + + diff --git a/src/app/onboarding/index.scss b/src/app/onboarding/index.scss new file mode 100644 index 0000000..9512a80 --- /dev/null +++ b/src/app/onboarding/index.scss @@ -0,0 +1,18 @@ +.storeai-onboarding { + /* onboarding 专用背景网格,避免把装饰样式泄漏到全局。 */ + .onboarding-grid { + background-image: linear-gradient(to right, rgb(226 232 240 / 0.45) 1px, transparent 1px), + linear-gradient(to bottom, rgb(226 232 240 / 0.45) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%); + } + + /* 静态二维码占位图,用来还原原型的视觉位置,不接入真实二维码生成逻辑。 */ + .telegram-qr-placeholder { + background-image: + linear-gradient(90deg, #111827 10px, transparent 10px), + linear-gradient(#111827 10px, transparent 10px); + background-size: 28px 28px; + background-position: 8px 8px; + } +} diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx new file mode 100644 index 0000000..900fd8d --- /dev/null +++ b/src/app/onboarding/layout.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; + +import { SignOutLink } from "./components/sign-out-link"; +import { Stepper } from "./components/stepper"; +import "./index.scss"; + +/** + * onboarding 嵌套路由的布局入参。 + */ +interface OnboardingLayoutProps { + /** 当前步骤页面渲染出来的中间内容。 */ + children: React.ReactNode; +} + +/** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */ +export default function OnboardingLayout({ children }: OnboardingLayoutProps) { + return ( +
    +
    + ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 0000000..0a60243 --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** onboarding 根路径默认进入第一步。 */ +export default function OnboardingIndexPage() { + redirect("/onboarding/brand"); +} diff --git a/src/app/onboarding/scan/components/action-row/index.tsx b/src/app/onboarding/scan/components/action-row/index.tsx new file mode 100644 index 0000000..d3f9a75 --- /dev/null +++ b/src/app/onboarding/scan/components/action-row/index.tsx @@ -0,0 +1,24 @@ +/** + * 扫描步骤操作行的展示参数。 + */ +interface ActionRowProps { + /** 操作行标题。 */ + title: string; + /** 操作行说明文案。 */ + body: string; + /** 右侧或下方的操作按钮区域。 */ + cta: React.ReactNode; +} + +/** 扫描步骤中标题、说明和 CTA 的通用横向布局。 */ +export function ActionRow({ title, body, cta }: ActionRowProps) { + return ( +
    +
    +

    {title}

    +

    {body}

    +
    +
    {cta}
    +
    + ); +} diff --git a/src/app/onboarding/scan/components/phase-status/index.tsx b/src/app/onboarding/scan/components/phase-status/index.tsx new file mode 100644 index 0000000..8a9c0b5 --- /dev/null +++ b/src/app/onboarding/scan/components/phase-status/index.tsx @@ -0,0 +1,206 @@ +import { AlertTriangle, Loader2, Radar, Sparkles, Store } from "lucide-react"; + +import type { ScanPhase, ScanPreflightVerdict, ScanState } from "../../types"; +import { maskShopId } from "../../utils"; + +/** + * 扫描阶段提示卡的展示参数。 + */ +interface PhaseStatusProps { + /** 当前页面阶段。 */ + phase: ScanPhase; + /** 扩展侧扫描状态快照。 */ + scanState: ScanState | null; + /** 扫描前检查结果。 */ + preflight: ScanPreflightVerdict | null; + /** 当前 onboarding 品牌名。 */ + brandName: string; +} + +/** 根据扫描流程阶段渲染对应的状态提示卡。 */ +export function PhaseStatus({ phase, scanState, preflight, brandName }: PhaseStatusProps) { + if (phase === "install_missing") { + return ( +
    + Extension not detected. Go back one step and finish the install. +
    + ); + } + + if (phase === "sign_in_pending") { + return ( +
    +

    + One last step - connect the extension to your StoreAI account +

    +

    + The extension was just installed but does not yet know which StoreAI account it belongs + to. The button below pairs them in one click - you do not need to open the extension + popup or sign in again. +

    +
    + + Prefer to do it from the popup instead? + +
      +
    1. + 1. Click the StoreAI{" "} + + S + {" "} + icon in your toolbar. +
    2. +
    3. + 2. Click Sign in in the popup. +
    4. +
    5. 3. We auto-detect it, then this card turns green.
    6. +
    +
    +
    + ); + } + + if (phase === "ready") { + return ( +
    +
    +
    +
    + ); + } + + if (phase === "preflight_no_shopee") { + return ( +
    +
    +
    +
    + ); + } + + if (phase === "preflight_wrong_shop" && preflight?.kind === "wrong_shop") { + return ( +
    +
    +
    +
    + ); + } + + if (phase === "preflight_first_bind" && preflight?.kind === "first_bind_confirm") { + return ( +
    +
    +
    +
    + ); + } + + if (phase === "scanning") { + return ( +
    +
    +
    +
    + ); + } + + if (phase === "scan_paused") { + const isShopeeAuth = scanState?.pause?.reason === "reauth"; + + return ( +
    +

    + {isShopeeAuth ? "The scan needs you to sign into Shopee" : "The scan paused"} +

    +

    + {scanState?.pause?.message ?? "The scan tab is asking for your input."} +

    +
    + ); + } + + if (phase === "scan_failed") { + const wasCancelled = scanState?.phase === "cancelled"; + + return ( +
    +

    + {wasCancelled ? "Scan cancelled" : "The scan did not finish"} +

    +

    + {scanState?.result?.error ?? + (wasCancelled + ? "You cancelled the scan from the extension popup." + : "The scan window closed before the scan could complete.")} +

    +
    + ); + } + + return ( +
    +
    +
    +
    + ); +} diff --git a/src/app/onboarding/scan/components/scanned-view/index.tsx b/src/app/onboarding/scan/components/scanned-view/index.tsx new file mode 100644 index 0000000..ab2ea8e --- /dev/null +++ b/src/app/onboarding/scan/components/scanned-view/index.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { ArrowRight, CheckCircle2 } from "lucide-react"; + +/** + * 首次扫描完成态的展示参数。 + */ +interface ScannedViewProps { + /** 当前 onboarding 品牌名。 */ + brandName: string; + /** 完成后进入的页面地址。 */ + dashboardHref: string; +} + +/** 首次扫描完成后的成功视图。 */ +export function ScannedView({ brandName, dashboardHref }: ScannedViewProps) { + return ( +
    +
    +
    +
    +
    +

    First scan complete

    +

    + {brandName} is live on StoreAI. Taking you to your dashboard... +

    +
    +
    + + + Open dashboard now +
    + ); +} diff --git a/src/app/onboarding/scan/config.ts b/src/app/onboarding/scan/config.ts new file mode 100644 index 0000000..6e3c0dc --- /dev/null +++ b/src/app/onboarding/scan/config.ts @@ -0,0 +1,19 @@ +/** + * UI 占位品牌名,后续接入真实 onboarding 状态后从接口读取。 + */ +export const DEFAULT_BRAND_NAME = "Victor Sports"; + +/** + * onboarding 完成后的默认落点。 + */ +export const DEFAULT_DASHBOARD_HREF = "/dashboard"; + +/** + * Shopee Seller 后台地址,用于提示用户补登录或切换店铺。 + */ +export const SHOPEE_SELLER_URL = "https://seller.shopee.com.my"; + +/** + * 首次绑定确认里展示的模拟店铺 ID。 + */ +export const MOCK_CURRENT_SHOP_ID = "293847561"; diff --git a/src/app/onboarding/scan/page.tsx b/src/app/onboarding/scan/page.tsx new file mode 100644 index 0000000..6788965 --- /dev/null +++ b/src/app/onboarding/scan/page.tsx @@ -0,0 +1,22 @@ +"use client"; +import {Loader2} from "lucide-react"; + +import {StepCard} from "../components/step-card"; + + +/** onboarding 第四步页面入口,负责给查询参数读取提供 Suspense 边界。 */ +export default function ScanStepPage() { + return ( + +
    +
    +
    +
    +
    + ); +} diff --git a/src/app/onboarding/scan/types.ts b/src/app/onboarding/scan/types.ts new file mode 100644 index 0000000..4b0b86b --- /dev/null +++ b/src/app/onboarding/scan/types.ts @@ -0,0 +1,116 @@ +/** + * 首次扫描步骤的页面状态。 + */ +export type ScanPhase = + | "detecting" + | "install_missing" + | "sign_in_pending" + | "ready" + | "preflight_no_shopee" + | "preflight_wrong_shop" + | "preflight_first_bind" + | "scanning" + | "scan_paused" + | "scan_failed" + | "scanned"; + +/** + * 扩展扫描任务在浏览器内的模拟运行状态。 + */ +export type ScanRuntimePhase = + | "scanning" + | "paused" + | "failed" + | "cancelled" + | "done"; + +/** + * 扫描暂停时需要用户处理的原因。 + */ +export type ScanPauseReason = "reauth" | "captcha" | "unknown"; + +/** + * 扫描暂停提示。 + */ +export interface ScanPause { + /** 暂停原因,用来展示更准确的处理文案。 */ + reason: ScanPauseReason; + /** 展示给用户的暂停说明。 */ + message: string; +} + +/** + * 扫描结束结果。 + */ +export interface ScanResult { + /** 失败或取消时展示的错误说明。 */ + error?: string; +} + +/** + * 当前扫描任务的轻量状态快照。 + */ +export interface ScanState { + /** 扩展侧扫描任务的当前阶段。 */ + phase: ScanRuntimePhase; + /** 暂停阶段附带的用户处理提示。 */ + pause?: ScanPause; + /** 结束阶段附带的结果信息。 */ + result?: ScanResult; +} + +/** + * 扫描前检查发现没有安装扩展。 + */ +export interface NoExtensionVerdict { + /** 检查结果类型。 */ + kind: "no_extension"; +} + +/** + * 扫描前检查发现浏览器没有登录 Shopee Seller。 + */ +export interface NoShopeeVerdict { + /** 检查结果类型。 */ + kind: "no_shopee"; +} + +/** + * 扫描前检查发现当前 Shopee 店铺和已绑定店铺不一致。 + */ +export interface WrongShopVerdict { + /** 检查结果类型。 */ + kind: "wrong_shop"; + /** 当前品牌已经绑定的 Shopee 店铺 ID。 */ + bound: string; + /** 当前浏览器登录的 Shopee 店铺 ID。 */ + current: string; +} + +/** + * 首次扫描前需要用户确认要绑定的店铺。 + */ +export interface FirstBindConfirmVerdict { + /** 检查结果类型。 */ + kind: "first_bind_confirm"; + /** 当前浏览器登录的 Shopee 店铺 ID。 */ + current: string; +} + +/** + * 扫描前检查通过。 + */ +export interface OkVerdict { + /** 检查结果类型。 */ + kind: "ok"; +} + +/** + * 扫描前检查的所有可能结果。 + */ +export type ScanPreflightVerdict = + | NoExtensionVerdict + | NoShopeeVerdict + | WrongShopVerdict + | FirstBindConfirmVerdict + | OkVerdict; diff --git a/src/app/onboarding/scan/utils.ts b/src/app/onboarding/scan/utils.ts new file mode 100644 index 0000000..6e9a4bc --- /dev/null +++ b/src/app/onboarding/scan/utils.ts @@ -0,0 +1,11 @@ +/** + * 将店铺 ID 做局部打码,避免在 UI 上完整暴露长 ID。 + * + * @param id 需要展示的店铺 ID。 + * @returns 打码后的店铺 ID。 + */ +export function maskShopId(id: string): string { + if (id.length <= 6) return id; + + return `${id.slice(0, 3)}...${id.slice(-3)}`; +} diff --git a/src/app/onboarding/telegram/page.tsx b/src/app/onboarding/telegram/page.tsx new file mode 100644 index 0000000..2a468df --- /dev/null +++ b/src/app/onboarding/telegram/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import {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"; + + +/** onboarding 第二步:Telegram 连接 UI。 */ +export default function TelegramStepPage() { + const [pairingStarted, setPairingStarted] = useState(false); + const [connected, setConnected] = useState(false); + + return ( + +
    + {(() => { + if (connected) return ; + if (pairingStarted) return ( + setPairingStarted(false)} + onConnected={() => setConnected(true)} + /> + ); + return ; + })()} + +
    + +
    +
    +
    + ); +} + +/** Telegram 已连接状态卡。 */ +function ConnectedView() { + return ( +
    +
    + ); +} + diff --git a/src/app/onboarding/telegram/qr_view.tsx b/src/app/onboarding/telegram/qr_view.tsx new file mode 100644 index 0000000..77f6b92 --- /dev/null +++ b/src/app/onboarding/telegram/qr_view.tsx @@ -0,0 +1,48 @@ +interface Props { + /** 取消当前二维码配对流程。 */ + onCancel: () => void; + /** 模拟 Telegram 连接成功。 */ + onConnected: () => void; +} + + +/** 静态二维码配对视图,不生成真实 deeplink。 */ +function QrView({onCancel, onConnected}: Props) { + return ( +
    +
    +
    +
    +
    +

    Scan with your phone camera

    +

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

    +
    + + +
    +
    +
    + ); +} + + +export default QrView; \ No newline at end of file diff --git a/src/app/onboarding/telegram/work_list.tsx b/src/app/onboarding/telegram/work_list.tsx new file mode 100644 index 0000000..82107ae --- /dev/null +++ b/src/app/onboarding/telegram/work_list.tsx @@ -0,0 +1,99 @@ +import React, {useState} from 'react'; +import {CheckCircle2, MessagesSquare, Smartphone} from "lucide-react"; +import {useRouter} from "next/navigation"; + + +function WorkList() { + const router = useRouter(); + //显示输入id + const [showManual, setShowManual] = useState(false); + const [manualChatId, setManualChatId] = useState(""); + + function onSubmit() { + router.push("/onboarding/extension") + } + + /** 保存手动 chat_id 的占位方法。 */ + function handleManualSave() { + if (!manualChatId.trim()) return; + } + + return ( +
    +
    + + + +
    + + + +
    + +
    + + {showManual && ( +
    + +

    + Direct chats are positive numbers; group chats start with{" "} + -100. +

    +
    + setManualChatId(event.target.value)} + placeholder="1234567890 or -1009876543210" + className="min-w-[200px] flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-foreground" + /> + +
    +
    + )} +
    + ); +} + +/** Telegram 配对说明中的单个小步骤。 */ +function HowItWorksStep({icon: Icon, title, body}: { icon: any; title: string; body: string }) { + return ( +
    +
    + ); +} + + +export default WorkList; \ No newline at end of file diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..56bc66d --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,6 @@ +/** + * 一键复制文本 + */ +export async function copyText(text: string) { + await navigator.clipboard.writeText(text); +}