This commit is contained in:
zhu
2026-05-08 13:56:09 +08:00
parent c3d550513c
commit d1285b7800
28 changed files with 1591 additions and 8 deletions

View File

@@ -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<PasswordStrength["score"], string> = {
/** 注册表单主体,保留 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.");

View File

@@ -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;
}

View File

@@ -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<string | null>(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<HTMLFormElement>) {
if (!name.trim()) {
event.preventDefault();
setError("Brand name is required.");
}
}
return (
<div className="space-y-5">
<div className="flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden />
<div>
<div className="font-semibold">Account created</div>
<div className="text-xs opacity-90">
You&apos;re signed in. Set up your brand below to start the 24-hour trial.
</div>
</div>
</div>
<StepCard
eyebrow="Step 1 of 4"
title="Name your brand"
subtitle="One brand = one store on your subscription."
>
<form onSubmit={handleSubmit} className="mt-6 space-y-5">
<div className="space-y-1.5">
<label htmlFor="brand-name" className="text-sm font-medium">
Brand name
</label>
<input
id="brand-name"
type="text"
required
maxLength={80}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Victor Sports"
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
/>
<p className="text-xs text-muted-foreground">
Shows up in your reports - pick something you&apos;ll recognise on Telegram.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="brand-tz" className="text-sm font-medium">
Timezone
</label>
<select
id="brand-tz"
value={timezone}
onChange={(event) => setTimezone(event.target.value)}
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
>
{TIMEZONE_OPTIONS.map((timezoneOption) => (
<option key={timezoneOption.value} value={timezoneOption.value}>
{timezoneOption.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Pick where you&apos;ll actually read the reports. Morning brief lands at 08:00 local,
evening recap at 17:00.
</p>
</div>
{error && (
<p className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-900">
{error}
</p>
)}
<Link
href="/onboarding/telegram"
onClick={(event) => {
if (!name.trim()) {
event.preventDefault();
setError("Brand name is required.");
}
}}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-6 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Continue -&gt;
</Link>
</form>
</StepCard>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">{email}</span>
<Link
href="/login"
onClick={handleSignOut}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-muted hover:text-foreground"
>
<LogOut className="h-3 w-3" aria-hidden="true" />
{busy ? "Signing out..." : "Sign out"}
</Link>
</div>
);
}

View File

@@ -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 (
<div className="rounded-2xl border border-border/60 bg-card/95 shadow-xl shadow-foreground/[0.03] backdrop-blur-sm">
<div className="px-6 pt-7 sm:px-10 sm:pt-9">
{eyebrow && (
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</p>
)}
<h1 className="mt-2 text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
{title}
</h1>
{subtitle && (
<p className="mt-2 text-pretty text-sm leading-relaxed text-muted-foreground">
{subtitle}
</p>
)}
</div>
<div className="px-6 pb-7 pt-6 sm:px-10 sm:pb-9">{children}</div>
{footer && <div className="border-t border-border/60 px-6 py-4 sm:px-10">{footer}</div>}
</div>
);
}

View File

@@ -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 (
<nav aria-label="Onboarding progress" className="w-full">
<ol className="flex items-center justify-between gap-2 sm:gap-4">
{STEPS.map((step, index) => {
const isLast = index === STEPS.length - 1;
const isCurrent = index === currentIndex;
const isDone = index < currentIndex;
const circle = (
<span
className={`relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium transition-all sm:h-9 sm:w-9 ${
isDone
? "border-emerald-500 bg-emerald-500 text-white"
: isCurrent
? "border-foreground bg-foreground text-background ring-4 ring-foreground/10"
: "border-border bg-card text-muted-foreground"
}`}
aria-hidden="true"
>
{isDone ? <Check className="h-4 w-4" strokeWidth={3} /> : index + 1}
</span>
);
return (
<li key={step.path} className="flex flex-1 items-center gap-2 sm:gap-4">
<div className="flex flex-col items-center gap-1.5 sm:flex-row sm:gap-3">
{isDone ? (
<Link href={step.path} className="rounded-full" aria-label={`Back to ${step.label}`}>
{circle}
</Link>
) : (
circle
)}
<span
className={`whitespace-nowrap text-[11px] font-medium sm:text-xs ${
isCurrent
? "text-foreground"
: isDone
? "text-foreground/70"
: "text-muted-foreground"
}`}
>
{step.label}
</span>
</div>
{!isLast && (
<span
className={`h-px flex-1 transition-colors ${
isDone ? "bg-emerald-500" : "bg-border"
}`}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -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<BrowserKind | null>(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 (
<div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50/80 p-4">
<AlertCircle className="h-5 w-5 shrink-0 text-amber-600"/>
<div className="space-y-1 text-[13px]">
<p className="font-semibold text-amber-900">
{browserKind === "safari" && "Safari is not supported"}
{browserKind === "firefox" && "Firefox is not supported"}
{browserKind === "other" && "This browser may not be supported"}
</p>
<p className="text-amber-900/85">
The StoreAI extension only loads in Chromium-based browsers. Switch to Chrome,
Edge, Brave, Arc or Opera and re-open this page to install.
</p>
</div>
</div>
);
}
return <></>
};
export default ChromiumCheck;

View File

@@ -0,0 +1,53 @@
import React from 'react';
const common = (
<defs>
<filter id="storeai-fig-shadow" x="-2%" y="-2%" width="104%" height="110%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodOpacity="0.08"/>
</filter>
</defs>
);
const DevMode = () => {
return (
<figure className="mt-3 max-w-[420px]">
<svg
viewBox="0 0 420 130"
className="w-full rounded-md border border-border/60 bg-card"
aria-label="Diagram: chrome://extensions header showing the Developer mode toggle in the top-right"
role="img"
>
{common}
{/* Window chrome */}
<rect x="0" y="0" width="420" height="22" fill="#f4f4f5"/>
<circle cx="11" cy="11" r="4" fill="#fb7185"/>
<circle cx="25" cy="11" r="4" fill="#fbbf24"/>
<circle cx="39" cy="11" r="4" fill="#34d399"/>
<rect x="60" y="6" width="180" height="11" rx="3" fill="#e4e4e7"/>
<text x="68" y="14" fontSize="8" fill="#6b7280" fontFamily="ui-monospace, monospace">
chrome://extensions
</text>
{/* Page header */}
<text x="20" y="48" fontSize="13" fill="#18181b" fontWeight="600">Extensions</text>
{/* Search box */}
<rect x="20" y="60" width="140" height="22" rx="4" fill="#fafafa" stroke="#e4e4e7"/>
<text x="28" y="74" fontSize="9" fill="#a1a1aa">Search extensions</text>
{/* Developer mode toggle (target) */}
<text x="288" y="55" fontSize="9" fill="#3f3f46">Developer mode</text>
<g filter="url(#storeai-fig-shadow)">
<rect x="370" y="48" width="28" height="14" rx="7" fill="#10b981"/>
<circle cx="391" cy="55" r="5" fill="#ffffff"/>
</g>
{/* Highlight ring + arrow */}
<rect x="282" y="42" width="124" height="26" rx="6" fill="none" stroke="#10b981" strokeWidth="2"
strokeDasharray="3 2"/>
<path d="M 240 90 Q 280 100 340 78" stroke="#10b981" strokeWidth="1.5" fill="none"
strokeLinecap="round"/>
<path d="M 336 73 L 344 78 L 339 84 Z" fill="#10b981"/>
<text x="86" y="108" fontSize="9.5" fill="#047857" fontWeight="600">Flip this toggle ON</text>
</svg>
</figure>
);
};
export default DevMode;

View File

@@ -0,0 +1,58 @@
import React from 'react';
const common = (
<defs>
<filter id="storeai-fig-shadow" x="-2%" y="-2%" width="104%" height="110%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodOpacity="0.08"/>
</filter>
</defs>
);
const LoadUnpacked = () => {
return (
<figure className="mt-3 max-w-[420px]">
<svg
viewBox="0 0 420 130"
className="w-full rounded-md border border-border/60 bg-card"
aria-label="Diagram: Load unpacked button appearing in the top-left of chrome://extensions after Developer mode is on"
role="img"
>
{common}
{/* Window chrome */}
<rect x="0" y="0" width="420" height="22" fill="#f4f4f5"/>
<circle cx="11" cy="11" r="4" fill="#fb7185"/>
<circle cx="25" cy="11" r="4" fill="#fbbf24"/>
<circle cx="39" cy="11" r="4" fill="#34d399"/>
<rect x="60" y="6" width="180" height="11" rx="3" fill="#e4e4e7"/>
<text x="68" y="14" fontSize="8" fill="#6b7280" fontFamily="ui-monospace, monospace">
chrome://extensions
</text>
{/* Action toolbar revealed by Developer mode */}
<rect x="20" y="38" width="380" height="40" rx="4" fill="#fafafa" stroke="#e4e4e7"/>
{/* Load unpacked button (target) */}
<g filter="url(#storeai-fig-shadow)">
<rect x="32" y="50" width="100" height="18" rx="3" fill="#ffffff" stroke="#3f3f46"/>
<text x="40" y="62" fontSize="9" fill="#18181b" fontWeight="600">Load unpacked</text>
</g>
<rect x="142" y="50" width="84" height="18" rx="3" fill="#fafafa" stroke="#d4d4d8"/>
<text x="150" y="62" fontSize="9" fill="#a1a1aa">Pack extension</text>
<rect x="236" y="50" width="60" height="18" rx="3" fill="#fafafa" stroke="#d4d4d8"/>
<text x="244" y="62" fontSize="9" fill="#a1a1aa">Update</text>
{/* Dev mode toggle (already on) — show as confirmation */}
<text x="318" y="56" fontSize="8" fill="#16a34a">Developer mode</text>
<rect x="378" y="58" width="14" height="8" rx="4" fill="#10b981"/>
{/* Highlight ring + arrow */}
<rect x="26" y="44" width="112" height="30" rx="6" fill="none" stroke="#10b981" strokeWidth="2"
strokeDasharray="3 2"/>
<path d="M 220 105 Q 160 105 110 80" stroke="#10b981" strokeWidth="1.5" fill="none"
strokeLinecap="round"/>
<path d="M 113 75 L 105 79 L 110 86 Z" fill="#10b981"/>
<text x="222" y="110" fontSize="9.5" fill="#047857" fontWeight="600">Click here, then pick the unzipped
folder
</text>
</svg>
</figure>
);
};
export default LoadUnpacked;

View File

@@ -0,0 +1,65 @@
import React from 'react';
const common = (
<defs>
<filter id="storeai-fig-shadow" x="-2%" y="-2%" width="104%" height="110%">
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodOpacity="0.08"/>
</filter>
</defs>
);
const PinIcon = () => {
return (
<figure className="mt-3 max-w-[420px]">
<svg
viewBox="0 0 420 130"
className="w-full rounded-md border border-border/60 bg-card"
aria-label="Diagram: Chrome toolbar showing the puzzle-piece icon with StoreAI inside, and the pushpin to keep it visible"
role="img"
>
{common}
{/* Toolbar strip */}
<rect x="0" y="0" width="420" height="40" fill="#f4f4f5" />
<rect x="80" y="10" width="180" height="20" rx="10" fill="#ffffff" stroke="#e4e4e7" />
<text x="92" y="24" fontSize="9" fill="#71717a" fontFamily="ui-monospace, monospace">store.bbiz.ai</text>
{/* Puzzle-piece icon (target 1) */}
<g transform="translate(330, 12)">
<rect width="20" height="16" rx="3" fill="#ffffff" stroke="#3f3f46" />
<path d="M 6 4 h 2 a 2 2 0 1 1 0 4 h -2 v 4 h 4 a 2 2 0 1 0 0 -2 v 0 h 4 v -6 h -8 z" fill="#3f3f46" />
</g>
{/* Profile / menu placeholders */}
<circle cx="375" cy="20" r="7" fill="#e4e4e7" />
<circle cx="395" cy="20" r="2" fill="#a1a1aa" />
<circle cx="395" cy="14" r="2" fill="#a1a1aa" />
<circle cx="395" cy="26" r="2" fill="#a1a1aa" />
{/* Dropdown panel */}
<g filter="url(#storeai-fig-shadow)">
<rect x="220" y="44" width="180" height="76" rx="6" fill="#ffffff" stroke="#e4e4e7" />
<text x="232" y="60" fontSize="9" fill="#71717a" fontWeight="600">Extensions</text>
{/* StoreAI row */}
<rect x="228" y="68" width="22" height="14" rx="3" fill="#0f172a" />
<text x="234" y="78" fontSize="9" fill="#ffffff" fontWeight="700">S</text>
<text x="258" y="78" fontSize="9" fill="#18181b" fontWeight="600">StoreAI</text>
{/* Pushpin (target 2) */}
<g transform="translate(372, 70)">
<rect width="14" height="12" rx="2" fill="#ecfdf5" stroke="#10b981" />
<path d="M 7 2 v 4 l 2 2 v 1 h -4 v -1 l 2 -2 v -4 z M 7 9 v 2" stroke="#10b981" strokeWidth="1.2" strokeLinecap="round" fill="none" />
</g>
{/* Greyed-out other extensions */}
<rect x="228" y="88" width="14" height="10" rx="2" fill="#e4e4e7" />
<text x="248" y="97" fontSize="8" fill="#a1a1aa">Other extension</text>
<rect x="228" y="104" width="14" height="10" rx="2" fill="#e4e4e7" />
<text x="248" y="113" fontSize="8" fill="#a1a1aa">Other extension</text>
</g>
{/* Arrows */}
<path d="M 318 36 Q 332 38 336 32" stroke="#10b981" strokeWidth="1.5" fill="none" strokeLinecap="round" />
<path d="M 333 28 L 339 31 L 336 36 Z" fill="#10b981" />
<text x="180" y="34" fontSize="9" fill="#047857" fontWeight="600">1. Click puzzle-piece</text>
<path d="M 405 75 Q 412 78 392 78" stroke="#10b981" strokeWidth="1.5" fill="none" strokeLinecap="round" />
<text x="290" y="62" fontSize="9" fill="#047857" fontWeight="600">2. Click pushpin </text>
</svg>
</figure>
);
};
export default PinIcon;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import {Download,} from "lucide-react";
const InstallCard = () => {
return (
<div className={`flex flex-col rounded-lg border p-5`}>
<div className="flex items-start justify-between gap-2">
<div
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background text-foreground">
<Download className="h-4 w-4"/>
</div>
</div>
<div className="mt-4 text-sm font-semibold tracking-tight">
Download installer
</div>
<div className="mt-1 text-xs text-muted-foreground">
StoreAI v1.0.0 / 55KB`
</div>
<div className="mt-5">
<a
href={"/extensions/storeai-extension-v0.1.4.zip"}
download
className={`inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-md text-xs font-medium bg-foreground text-background hover:opacity-90`}
>
<Download className="h-3.5 w-3.5" />
Download .zip
</a>
</div>
</div>
);
};
export default InstallCard;

View File

@@ -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 (
<div className="rounded-lg border border-border/60 bg-muted/20">
{/*头*/}
<div
onClick={() => 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}>
<div className="flex items-center gap-2">
{
showInstructions ? <ChevronDown className="h-4 w-4"/> : <ChevronRight className="h-4 w-4"/>
}
How to install the .zip (60 seconds)
</div>
{!showInstructions && (
<span className="text-[11px] font-normal text-muted-foreground">6 steps</span>
)}
</div>
{/*步骤*/}
{
showInstructions &&
<ol className="space-y-4 border-t border-border/60 px-5 py-5 text-sm">
<Instruction step={1} title="Download the installer">
Click the <strong>Download .zip</strong> button above. Your browser saves{" "}
<code className="text-xs">storeai-extension-v0.1.0.zip</code>.
</Instruction>
<Instruction step={2} title="Unzip it">
<strong>macOS</strong> double-click the downloaded zip in Finder.{" "}
<strong>Windows</strong> right-click the .zip and choose Extract All.
</Instruction>
<Instruction step={3} title="Open Chrome's extensions page">
Chrome blocks links to <code className="text-xs">chrome://</code> URLs from web
pages, so copy this and paste into your address bar:
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={copyExtension}
className="flex flex-1 cursor-pointer items-center rounded-md border border-border bg-card px-3 py-2 text-left text-xs font-mono transition-colors hover:bg-accent hover:text-foreground"
aria-label="Copy chrome://extensions to clipboard">
{chrome_extensions_url}
</button>
<button
type="button"
onClick={copyExtension}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-2 text-xs transition-colors hover:bg-accent hover:text-foreground">
{isCopy ?
<>
<Check className="h-3 w-3 text-emerald-600"/>Copied
</>
:
<>
<Copy className="h-3 w-3"/> Copy
</>
}
</button>
</div>
</Instruction>
<Instruction step={4} title="Turn on Developer mode">
Top-right of the extensions page, flip the <strong>Developer mode</strong> toggle
to ON.
<DevMode/>
</Instruction>
<Instruction step={5} title="Load the unpacked extension">
Click <strong>Load unpacked</strong>, then select the unzipped{" "}
<code className="text-xs">storeai-extension-v1.0.0</code> folder.
<LoadUnpacked/>
</Instruction>
<Instruction step={6} title="PinIcon StoreAI to the toolbar">
Click the puzzle-piece icon{" "}
<Pin className="inline h-3 w-3 align-text-bottom" aria-hidden="true"/> in your
Chrome toolbar, find StoreAI, then click the pushpin.
<PinIcon/>
</Instruction>
</ol>
}
</div>
);
};
interface InstructionProps {
/** 步骤序号。 */
step: number;
/** 步骤标题。 */
title: string;
/** 步骤说明内容。 */
children: React.ReactNode;
}
/** 安装步骤说明中的单条步骤。 */
function Instruction({step, title, children}: InstructionProps) {
return (
<li className="flex gap-3">
<span
className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background">
{step}
</span>
<div className="min-w-0 flex-1 space-y-0.5 leading-relaxed">
<p className="font-medium">{title}</p>
<div className="text-xs text-muted-foreground">{children}</div>
</div>
</li>
);
}
export default InstructionList;

View File

@@ -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<boolean | null>(null);
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
/**
* 模拟扩展检测成功,用于先走通 onboarding 流程。
*/
function handleSimulateDetected() {
setInstalled(true);
}
return (
<>
{/*检测状态*/}
<LiveStatus installed={installed} onSimulateDetected={handleSimulateDetected}/>
{showTroubleshoot && installed !== true && (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
<div className="flex items-start gap-3">
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700"/>
<div className="space-y-2 text-[13px] text-amber-900">
<p className="font-semibold">Still not detected?</p>
<ul className="list-inside list-disc space-y-1 text-amber-900/85">
<li>Did you flip Developer mode ON?</li>
<li>Did you select the unzipped folder, not the .zip itself?</li>
<li>Does StoreAI show up on chrome://extensions with no red error banner?</li>
</ul>
</div>
</div>
</div>
)}
</>
);
};
/** 扩展安装状态面板,当前通过按钮模拟检测成功。 */
interface LiveStatusProps {
/** 当前是否已经检测到扩展。 */
installed: boolean | null;
/** 模拟检测成功的点击回调。 */
onSimulateDetected: () => void;
}
function LiveStatus({installed, onSimulateDetected,}: LiveStatusProps) {
if (installed === true) {
return (
<div
className="flex items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600"/>
<div>
<p className="text-sm font-semibold text-emerald-900">Extension detected</p>
<p className="mt-0.5 text-xs text-emerald-800/90">Off to your first scan.</p>
</div>
</div>
<Link
href="/onboarding/scan"
className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-emerald-600 px-4 text-xs font-medium text-white transition-opacity hover:opacity-90">
Continue now
<ArrowRight className="h-3 w-3"/>
</Link>
</div>
);
}
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 flex-shrink-0 animate-spin text-muted-foreground"/>
<div>
<p className="text-sm font-medium">Waiting for install...</p>
<p className="mt-0.5 text-xs text-muted-foreground">
This page will auto-detect StoreAI once the extension is installed.
</p>
</div>
</div>
<button
type="button"
onClick={onSimulateDetected}
className="rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-accent"
>
Simulate detected
</button>
</div>
);
}
export default LiveCard;

View File

@@ -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<boolean | null>(null);
return (
<StepCard
eyebrow="Step 3 of 4"
title="Install the Chrome extension"
subtitle="The extension scans your store dashboard inside your already-authenticated browser. Read-only - your store password never touches our servers."
>
<div className="space-y-7">
<ChromiumCheck/>
<InstallCard/>
<p className="-mt-2 flex items-center gap-2 text-[11px] text-muted-foreground/90">
<Clock className="h-3 w-3 shrink-0"/>
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.
</p>
{/*提示*/}
<InstructionList/>
{/*检测*/}
<LiveCard/>
<div className="flex items-start gap-2 text-[11px] text-muted-foreground">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600"/>
<div>
The extension only runs on <code className="text-foreground/80">seller.shopee.com.my</code>{" "}
and <code className="text-foreground/80">shopee.com.my</code>. It never reads cookies
or pages from any other site.
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-5">
<Link
href="/onboarding/telegram"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
<ArrowLeft className="h-3 w-3"/>
Back
</Link>
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
title="You can install from the dashboard later - your scans wait until then.">
Skip - install from dashboard later
</Link>
<Link
href="/onboarding/scan"
className={`inline-flex h-10 items-center justify-center gap-1.5 rounded-md px-5 text-sm font-medium transition-all ${
installed
? "bg-foreground text-background hover:opacity-90"
: "bg-muted text-muted-foreground hover:bg-muted"
}`}>
Continue
<ArrowRight className="h-3.5 w-3.5"/>
</Link>
</div>
</div>
</div>
</StepCard>
);
}

View File

@@ -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;
}
}

View File

@@ -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 (
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 -top-32 h-[28rem] w-[28rem] rounded-full bg-emerald-500/10 blur-3xl"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-32 -left-32 h-[28rem] w-[28rem] rounded-full bg-indigo-500/10 blur-3xl"
/>
<div aria-hidden="true" className="onboarding-grid pointer-events-none absolute inset-0" />
<div className="relative mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 py-6 sm:px-6 sm:py-10">
<header className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span className="inline-flex h-7 w-7 items-center justify-center rounded bg-foreground text-xs font-bold text-background">
S
</span>
StoreAI
</Link>
<SignOutLink />
</header>
<div className="mt-10 sm:mt-14">
<Stepper />
</div>
<div className="mt-10 flex-1 sm:mt-14">{children}</div>
<div className="mt-12 text-center text-[11px] text-muted-foreground/80">
You can change anything later in Settings.
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/** onboarding 根路径默认进入第一步。 */
export default function OnboardingIndexPage() {
redirect("/onboarding/brand");
}

View File

@@ -0,0 +1,24 @@
/**
* 扫描步骤操作行的展示参数。
*/
interface ActionRowProps {
/** 操作行标题。 */
title: string;
/** 操作行说明文案。 */
body: string;
/** 右侧或下方的操作按钮区域。 */
cta: React.ReactNode;
}
/** 扫描步骤中标题、说明和 CTA 的通用横向布局。 */
export function ActionRow({ title, body, cta }: ActionRowProps) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold">{title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{body}</p>
</div>
<div className="shrink-0">{cta}</div>
</div>
);
}

View File

@@ -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 (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4 text-sm text-amber-900">
<strong>Extension not detected.</strong> Go back one step and finish the install.
</div>
);
}
if (phase === "sign_in_pending") {
return (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4">
<p className="text-sm font-semibold">
One last step - connect the extension to your StoreAI account
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
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.
</p>
<details className="mt-3 text-[11px] text-muted-foreground">
<summary className="cursor-pointer underline-offset-4 hover:underline">
Prefer to do it from the popup instead?
</summary>
<ol className="mt-2 space-y-1.5 pl-1">
<li>
1. Click the StoreAI{" "}
<span className="inline-flex h-3.5 w-3.5 items-center justify-center rounded bg-foreground text-[8px] font-bold text-background">
S
</span>{" "}
icon in your toolbar.
</li>
<li>
2. Click <strong>Sign in</strong> in the popup.
</li>
<li>3. We auto-detect it, then this card turns green.</li>
</ol>
</details>
</div>
);
}
if (phase === "ready") {
return (
<div className="rounded-lg border border-emerald-200 bg-emerald-50/60 p-4">
<div className="flex items-start gap-3">
<Sparkles className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-600" aria-hidden="true" />
<div>
<p className="text-sm font-semibold text-emerald-900">Paired and ready</p>
<p className="mt-1 text-xs leading-relaxed text-emerald-800/90">
The extension is signed in. Click the button below - we will kick off the scan from
here, no need to open the popup.
</p>
</div>
</div>
</div>
);
}
if (phase === "preflight_no_shopee") {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
<div className="flex items-start gap-3">
<Store className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-700" aria-hidden="true" />
<div>
<p className="text-sm font-semibold text-amber-900">
Sign into your Shopee Seller account in this Chrome
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-900/85">
StoreAI scans by reading what your already-logged-in Chrome can see. Right now Chrome
does not have a Shopee Seller session.
</p>
<ol className="mt-3 space-y-1.5 text-xs text-amber-900/90">
<li>1. Click the button below to open Shopee Seller in a new tab.</li>
<li>2. Sign in there with the seller account that owns this brand.</li>
<li>
3. Come back here and click <strong>Run my first scan</strong> again.
</li>
</ol>
</div>
</div>
</div>
);
}
if (phase === "preflight_wrong_shop" && preflight?.kind === "wrong_shop") {
return (
<div className="rounded-lg border border-rose-200 bg-rose-50/60 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-rose-700" aria-hidden="true" />
<div>
<p className="text-sm font-semibold text-rose-900">Wrong Shopee account</p>
<p className="mt-1 text-xs leading-relaxed text-rose-900/85">
This brand was bound to Shopee shop <strong>{maskShopId(preflight.bound)}</strong>,
but Chrome is currently signed into shop{" "}
<strong>{maskShopId(preflight.current)}</strong>.
</p>
<p className="mt-2 text-xs text-rose-900/85">
Sign back into shop {maskShopId(preflight.bound)} on seller.shopee.com.my, then click{" "}
<strong>Re-check</strong>.
</p>
</div>
</div>
</div>
);
}
if (phase === "preflight_first_bind" && preflight?.kind === "first_bind_confirm") {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
<div className="flex items-start gap-3">
<Store className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-700" aria-hidden="true" />
<div>
<p className="text-sm font-semibold text-amber-900">
About to bind this brand to a Shopee shop
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-900/85">
Your Chrome is signed into Shopee shop{" "}
<strong>{maskShopId(preflight.current)}</strong>. After this scan, brand{" "}
<strong>&quot;{brandName}&quot;</strong> is permanently bound to that shop.
</p>
</div>
</div>
</div>
);
}
if (phase === "scanning") {
return (
<div className="rounded-lg border border-border/60 bg-card p-4">
<div className="flex items-start gap-3">
<Radar className="mt-0.5 h-5 w-5 flex-shrink-0 animate-pulse text-foreground" aria-hidden="true" />
<div>
<p className="text-sm font-semibold">Scanning your store</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
You will see a quick scan tab open and close. We will redirect to your dashboard
automatically when it lands.
</p>
</div>
</div>
</div>
);
}
if (phase === "scan_paused") {
const isShopeeAuth = scanState?.pause?.reason === "reauth";
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
<p className="text-sm font-semibold text-amber-900">
{isShopeeAuth ? "The scan needs you to sign into Shopee" : "The scan paused"}
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-800/90">
{scanState?.pause?.message ?? "The scan tab is asking for your input."}
</p>
</div>
);
}
if (phase === "scan_failed") {
const wasCancelled = scanState?.phase === "cancelled";
return (
<div className="rounded-lg border border-rose-200 bg-rose-50/60 p-4">
<p className="text-sm font-semibold text-rose-900">
{wasCancelled ? "Scan cancelled" : "The scan did not finish"}
</p>
<p className="mt-1 text-xs leading-relaxed text-rose-900/85">
{scanState?.result?.error ??
(wasCancelled
? "You cancelled the scan from the extension popup."
: "The scan window closed before the scan could complete.")}
</p>
</div>
);
}
return (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium">Looking for the extension...</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10 ring-8 ring-emerald-500/5">
<CheckCircle2 className="h-7 w-7 text-emerald-600" aria-hidden="true" />
</div>
<div>
<p className="text-base font-semibold text-emerald-900">First scan complete</p>
<p className="mt-1 max-w-md text-sm leading-relaxed text-emerald-800/90">
{brandName} is live on StoreAI. Taking you to your dashboard...
</p>
</div>
</div>
<Link
href={dashboardHref}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Open dashboard now
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,22 @@
"use client";
import {Loader2} from "lucide-react";
import {StepCard} from "../components/step-card";
/** onboarding 第四步页面入口,负责给查询参数读取提供 Suspense 边界。 */
export default function ScanStepPage() {
return (
<StepCard
eyebrow="Step 4 of 4"
title="Pair and run your first scan"
subtitle="Open the StoreAI extension popup, sign in to pair it with this brand, then click Scan now. The first run takes about 30 seconds.">
<div className="rounded-lg border border-border/60 bg-muted/30 p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden="true"/>
<p className="text-sm font-medium">Preparing scan setup...</p>
</div>
</div>
</StepCard>
);
}

View File

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

View File

@@ -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)}`;
}

View File

@@ -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 (
<StepCard
eyebrow="Step 2 of 4"
title="Connect your Telegram"
subtitle="Reports land on Telegram twice a day - morning brief at 08:00, evening recap at 17:00 in your brand timezone. Push notifications get you to a critical issue in under a minute.">
<div className="space-y-6">
{(() => {
if (connected) return <ConnectedView/>;
if (pairingStarted) return (
<QrView
onCancel={() => setPairingStarted(false)}
onConnected={() => setConnected(true)}
/>
);
return <WorkList/>;
})()}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-5">
<Link
href="/onboarding/brand"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
<ArrowLeft className="h-3 w-3" aria-hidden="true"/>
Back
</Link>
<Link
href="/onboarding/extension"
className="text-right text-xs text-muted-foreground transition-colors hover:text-foreground">
Skip - reports will only show on the dashboard -&gt;
</Link>
</div>
</div>
</StepCard>
);
}
/** Telegram 已连接状态卡。 */
function ConnectedView() {
return (
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50/60 p-4">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-600" aria-hidden="true"/>
<div>
<p className="text-sm font-semibold text-emerald-900">Telegram connected</p>
<p className="mt-1 text-xs leading-relaxed text-emerald-800/90">
Your morning brief and evening recap will arrive on Telegram. You can disconnect or
change the chat any time from Settings.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
interface Props {
/** 取消当前二维码配对流程。 */
onCancel: () => void;
/** 模拟 Telegram 连接成功。 */
onConnected: () => void;
}
/** 静态二维码配对视图,不生成真实 deeplink。 */
function QrView({onCancel, onConnected}: Props) {
return (
<div
className="flex flex-col gap-5 rounded-lg border border-border/60 bg-muted/30 p-5 sm:flex-row sm:items-start sm:gap-7">
<div className="flex shrink-0 justify-center rounded-lg border border-border bg-white p-3">
<div className="telegram-qr-placeholder h-[180px] w-[180px] rounded bg-white"/>
</div>
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm font-medium">Scan with your phone camera</p>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"/>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"/>
</span>
Auto-checking - link expires in 15 minutes
</p>
<div className="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
onClick={onConnected}
className="text-xs font-medium text-muted-foreground underline decoration-muted-foreground/60 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
Simulate Telegram connected -&gt;
</button>
<button
type="button"
onClick={onCancel}
className="text-xs text-muted-foreground hover:text-red-600"
>
Cancel
</button>
</div>
</div>
</div>
);
}
export default QrView;

View File

@@ -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 (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-3">
<HowItWorksStep
icon={Smartphone}
title="Scan the QR"
body="Use your phone camera or Telegram scanner."/>
<HowItWorksStep
icon={MessagesSquare}
title="Tap Start in our bot"
body="Telegram opens our bot with a Start button pre-filled."/>
<HowItWorksStep
icon={CheckCircle2}
title="Done - auto-paired"
body="This page flips to connected within a few seconds."/>
</div>
<button
type="button"
onClick={onSubmit}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-[#229ED9] px-5 text-sm font-medium text-white transition-opacity hover:opacity-90">
Connect with Telegram
</button>
<div className="text-center">
<button
type="button"
onClick={() => setShowManual((value) => !value)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground ">
{showManual ? "Hide advanced" : "Already paired before? Paste your Telegram chat ID"}
</button>
</div>
{showManual && (
<div className="rounded-md border border-border/60 bg-muted/30 p-4">
<label className="text-xs font-medium" htmlFor="manual-chat-id">
Paste Telegram chat_id
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
Direct chats are positive numbers; group chats start with{" "}
<code className="text-foreground/80">-100</code>.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
id="manual-chat-id"
type="text"
inputMode="numeric"
value={manualChatId}
onChange={(event) => 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"
/>
<button
type="button"
onClick={handleManualSave}
disabled={manualChatId.trim() === ""}
className="rounded-md border border-border bg-background px-3 py-2 text-xs font-medium transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50">
Save chat_id
</button>
</div>
</div>
)}
</div>
);
}
/** Telegram 配对说明中的单个小步骤。 */
function HowItWorksStep({icon: Icon, title, body}: { icon: any; title: string; body: string }) {
return (
<div className="rounded-md border border-border/60 bg-muted/20 p-3">
<Icon className="h-4 w-4 text-foreground" aria-hidden="true"/>
<p className="mt-2 text-xs font-medium">{title}</p>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{body}</p>
</div>
);
}
export default WorkList;

6
src/utils/helper.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* 一键复制文本
*/
export async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}