1
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
134
src/app/onboarding/brand/page.tsx
Normal file
134
src/app/onboarding/brand/page.tsx
Normal 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'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'll recognise on Telegram.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="brand-tz" className="text-sm font-medium">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
id="brand-tz"
|
||||
value={timezone}
|
||||
onChange={(event) => setTimezone(event.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
|
||||
>
|
||||
{TIMEZONE_OPTIONS.map((timezoneOption) => (
|
||||
<option key={timezoneOption.value} value={timezoneOption.value}>
|
||||
{timezoneOption.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick where you'll actually read the reports. Morning brief lands at 08:00 local,
|
||||
evening recap at 17:00.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-900">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/onboarding/telegram"
|
||||
onClick={(event) => {
|
||||
if (!name.trim()) {
|
||||
event.preventDefault();
|
||||
setError("Brand name is required.");
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-6 text-sm font-medium text-background transition-opacity hover:opacity-90"
|
||||
>
|
||||
Continue ->
|
||||
</Link>
|
||||
</form>
|
||||
</StepCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/onboarding/components/sign-out-link/index.tsx
Normal file
38
src/app/onboarding/components/sign-out-link/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/onboarding/components/step-card/index.tsx
Normal file
40
src/app/onboarding/components/step-card/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/app/onboarding/components/stepper/index.tsx
Normal file
81
src/app/onboarding/components/stepper/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/onboarding/extension/chromium-check.tsx
Normal file
43
src/app/onboarding/extension/chromium-check.tsx
Normal 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;
|
||||
53
src/app/onboarding/extension/icon/dev-mode.tsx
Normal file
53
src/app/onboarding/extension/icon/dev-mode.tsx
Normal 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;
|
||||
58
src/app/onboarding/extension/icon/load-unpacked.tsx
Normal file
58
src/app/onboarding/extension/icon/load-unpacked.tsx
Normal 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;
|
||||
65
src/app/onboarding/extension/icon/pin-icon.tsx
Normal file
65
src/app/onboarding/extension/icon/pin-icon.tsx
Normal 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;
|
||||
33
src/app/onboarding/extension/install-card.tsx
Normal file
33
src/app/onboarding/extension/install-card.tsx
Normal 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;
|
||||
130
src/app/onboarding/extension/instruction-list.tsx
Normal file
130
src/app/onboarding/extension/instruction-list.tsx
Normal 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;
|
||||
94
src/app/onboarding/extension/live-card.tsx
Normal file
94
src/app/onboarding/extension/live-card.tsx
Normal 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;
|
||||
82
src/app/onboarding/extension/page.tsx
Normal file
82
src/app/onboarding/extension/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
18
src/app/onboarding/index.scss
Normal file
18
src/app/onboarding/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/app/onboarding/layout.tsx
Normal file
52
src/app/onboarding/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/onboarding/page.tsx
Normal file
6
src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** onboarding 根路径默认进入第一步。 */
|
||||
export default function OnboardingIndexPage() {
|
||||
redirect("/onboarding/brand");
|
||||
}
|
||||
24
src/app/onboarding/scan/components/action-row/index.tsx
Normal file
24
src/app/onboarding/scan/components/action-row/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/app/onboarding/scan/components/phase-status/index.tsx
Normal file
206
src/app/onboarding/scan/components/phase-status/index.tsx
Normal 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>"{brandName}"</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>
|
||||
);
|
||||
}
|
||||
39
src/app/onboarding/scan/components/scanned-view/index.tsx
Normal file
39
src/app/onboarding/scan/components/scanned-view/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/onboarding/scan/config.ts
Normal file
19
src/app/onboarding/scan/config.ts
Normal 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";
|
||||
22
src/app/onboarding/scan/page.tsx
Normal file
22
src/app/onboarding/scan/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/app/onboarding/scan/types.ts
Normal file
116
src/app/onboarding/scan/types.ts
Normal 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;
|
||||
11
src/app/onboarding/scan/utils.ts
Normal file
11
src/app/onboarding/scan/utils.ts
Normal 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)}`;
|
||||
}
|
||||
67
src/app/onboarding/telegram/page.tsx
Normal file
67
src/app/onboarding/telegram/page.tsx
Normal 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 ->
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/app/onboarding/telegram/qr_view.tsx
Normal file
48
src/app/onboarding/telegram/qr_view.tsx
Normal 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 ->
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-muted-foreground hover:text-red-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default QrView;
|
||||
99
src/app/onboarding/telegram/work_list.tsx
Normal file
99
src/app/onboarding/telegram/work_list.tsx
Normal 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
6
src/utils/helper.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 一键复制文本
|
||||
*/
|
||||
export async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
Reference in New Issue
Block a user