This commit is contained in:
zhu
2026-05-07 17:36:42 +08:00
parent c661f4a063
commit 6348090dfe
9 changed files with 604 additions and 0 deletions

View File

@@ -71,6 +71,7 @@ If a component requires a large amount of static data (e.g., "dead" data for ren
**Every method, property, interface, and complex logic block MUST be documented.**
- **Language Requirement**: All comments inside the code (JSDoc and internal) **MUST be written in Chinese**.
- **Mandatory Positive Constraint**: Every new **interface**, **type**, **exported or module-level constant**, **function component**, and **business logic function** MUST have a JSDoc `/** */` comment in **Chinese**. The only exception is for local variables within a function that are immediately obvious and self-explanatory.
- **Public API/Props/Interfaces**: Use JSDoc style `/** ... */` **mandatory** for every interface definition and **every single property** within that interface.
- **Methods & Functions**: Every function (exported or internal) **must** have a `/** ... */` comment explaining its purpose, parameters, and return value.
- **Internal Logic**: Use double-slash `//` for step-by-step explanations inside function bodies.

View File

@@ -124,6 +124,9 @@ export const SearchBar = ({ placeholder, onSearch }: SearchBarProps) => {
Define the store state and actions with an interface.
```typescript
/**
* 权益状态
*/
interface AuthState {
token: string | null;
setToken: (token: string) => void;

View File

@@ -0,0 +1,25 @@
import { Eye, EyeOff } from "lucide-react";
/** 密码显示/隐藏切换按钮的参数。 */
interface PasswordToggleProps {
isShown: boolean;
onToggle: () => void;
}
/** 密码显示/隐藏切换按钮,复用在登录和注册密码输入框中。 */
export function PasswordToggle({ isShown, onToggle }: PasswordToggleProps) {
return (
<button
type="button"
onClick={onToggle}
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={isShown ? "Hide password" : "Show password"}
>
{isShown ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
);
}

View File

@@ -0,0 +1,9 @@
.storeai-auth {
/* 认证壳层的细网格背景,只在该路由组内生效。 */
.auth-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%);
}
}

137
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,137 @@
import Link from "next/link";
import { BellRing, Clock3, MessagesSquare, ShieldCheck } from "lucide-react";
import "./index.scss";
/** 认证壳层左侧卖点行的入参。 */
interface FeatureRowProps {
icon: React.ComponentType<{ className?: string; "aria-hidden"?: true }>;
title: string;
body: string;
}
/**
* 登录和注册共享的认证壳层。
* 桌面端展示左侧品牌说明,移动端保留紧凑品牌头,表单内容由子路由提供。
*/
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className="storeai-auth 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="auth-grid pointer-events-none absolute inset-0" />
<div className="relative grid min-h-screen lg:grid-cols-[1.1fr_1fr]">
<aside className="hidden border-r border-border/40 bg-muted/20 lg:flex lg:flex-col lg:justify-between lg:p-14">
<Link href="/" className="flex items-center gap-2 text-base 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>
<div className="space-y-8">
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
AI store monitor
</p>
<h2 className="text-balance text-3xl font-semibold tracking-tight md:text-4xl">
Two reports a day.
<br />
Anomalies first.
</h2>
<p className="text-pretty text-sm leading-relaxed text-muted-foreground">
StoreAI scans your store twice a day, runs a 13-rule anomaly engine over your
sales, ads, stock, reviews and competitors, and sends a tight summary to your
Telegram so you find out before the day is lost.
</p>
</div>
<ul className="space-y-4">
<FeatureRow
icon={Clock3}
title="Morning 08:00 / evening 17:00"
body="Two scheduled scans on your timezone - automatic, hands-free."
/>
<FeatureRow
icon={BellRing}
title="Stockouts / ad waste / price gaps / rating dips"
body="Issues ranked by lost-revenue impact - high signal, no noise."
/>
<FeatureRow
icon={MessagesSquare}
title="Delivered to Telegram + dashboard"
body="A tight diagnosis you can act on in under 60 seconds."
/>
<FeatureRow
icon={ShieldCheck}
title="Read-only / password stays in Chrome"
body="The extension reads your already-authenticated session; we never see your store password."
/>
</ul>
</div>
<p className="text-xs text-muted-foreground/80">
Built with founding sellers running brands from RM 50K to RM 5M / month GMV.
</p>
</aside>
<section className="flex items-center justify-center px-4 py-12 sm:px-8 lg:px-14">
<div className="w-full max-w-md">
<div className="mb-10 flex flex-col items-center text-center lg:hidden">
<Link href="/" className="flex items-center gap-2 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>
<p className="mt-2 text-xs text-muted-foreground">
Anomaly-first store monitor / two reports a day
</p>
</div>
<div className="rounded-2xl border border-border/60 bg-card/95 p-8 shadow-xl shadow-foreground/[0.03] backdrop-blur-sm sm:p-10">
{children}
</div>
<p className="mt-6 text-center text-[11px] text-muted-foreground/80">
By continuing you agree to our{" "}
<Link href="/terms" className="underline underline-offset-2 hover:text-foreground">
Terms
</Link>{" "}
and{" "}
<Link
href="/privacy-policy"
className="underline underline-offset-2 hover:text-foreground"
>
Privacy Policy
</Link>
.
</p>
</div>
</section>
</div>
</main>
);
}
/** 认证壳层左侧的单条产品卖点。 */
function FeatureRow({ icon: Icon, title, body }: FeatureRowProps) {
return (
<li className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-card">
<Icon className="h-4 w-4 text-foreground" aria-hidden />
</span>
<div className="space-y-0.5">
<p className="text-sm font-medium leading-tight">{title}</p>
<p className="text-xs leading-relaxed text-muted-foreground">{body}</p>
</div>
</li>
);
}

View File

@@ -0,0 +1,37 @@
export type LoginErrorType = "1" | "2" | "3";
export interface LoginNotice {
tone: "warning" | "success";
title: string;
body: string;
}
const login_error_text: Record<LoginErrorType, LoginNotice> = {
"1": {
tone: "warning",
title: "Signed out from the extension.",
body: "Log back in to continue scanning.",
},
"2": {
tone: "warning",
title: "Signed in on another device.",
body:
"StoreAI allows one active session per account, so this device was signed out. Log back in to continue here - the other device will then be signed out.",
},
"3": {
tone: "success",
title: "An account already exists with this email.",
body: "Sign in below to continue.",
},
};
/**
* 根据 query 中的 error_type 获取登录页提示配置。
*/
export function getLoginNotice(errorType: string | null): LoginNotice | null {
if (!errorType) return null;
if (errorType in login_error_text) {
return login_error_text[errorType as LoginErrorType];
}
return null;
}

View File

@@ -0,0 +1,174 @@
"use client";
import {Suspense, useState} from "react";
import Link from "next/link";
import {useSearchParams} from "next/navigation";
import {AlertTriangle, Lock, Mail} from "lucide-react";
import {PasswordToggle} from "../components/password-toggle";
import {getLoginNotice} from "./login-error";
import {validateSignup} from "../validate";
/** 登录表单提交参数。 */
interface LoginPayload {
email: string;
password: string;
}
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
export default function LoginPage() {
return (
<Suspense fallback={<LoginFormFallback/>}>
<LoginForm/>
</Suspense>
);
}
/** 登录表单加载占位,保持认证壳层布局稳定。 */
function LoginFormFallback() {
return <div className="h-72 animate-pulse rounded-md bg-muted/60"/>;
}
/** 登录表单主体,保留 query 逻辑、校验和错误态。 */
function LoginForm() {
const searchParams = useSearchParams();
/*** 提示 */
const loginNotice = getLoginNotice(searchParams.get("error_type"));
const prefillEmail = searchParams.get("email") ?? "";
const [email, setEmail] = useState(prefillEmail);
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
/*** 错误*/
const [error, setError] = useState<string | null>("");
/*** 提交中*/
const [loading, setLoading] = useState(false);
/** 处理登录表单提交,真实接口接入前只执行前端校验和占位提交。 */
async function handleSubmit(event: any) {
event.preventDefault();
setError(null);
//效验
const validationError = validateSignup({email, password});
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
} catch (err) {
} finally {
setLoading(false);
}
}
return (
<div className="space-y-7">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
Sign in to your StoreAI dashboard.
</p>
</div>
{/*提示*/}
{loginNotice && (
<Notice tone={loginNotice.tone} title={loginNotice.title}>
{loginNotice.body}
</Notice>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<div className="relative">
<Mail
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@brand.com"
className="flex h-11 w-full rounded-md border border-border bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2"
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<div className="relative">
<Lock
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="flex h-11 w-full rounded-md border border-border bg-background px-3 py-2 pl-10 pr-10 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2"
/>
<PasswordToggle
isShown={showPassword}
onToggle={() => setShowPassword((current) => !current)}
/>
</div>
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-[13px] leading-relaxed">
<p className="font-medium text-red-700">{error}</p>
</div>
)}
<button
disabled={loading}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60">
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
New to StoreAI?{" "}
<Link href={"/signup"} className="font-medium text-foreground underline underline-offset-4">
Start your free trial
</Link>
</p>
</div>
);
}
/** 登录页中用来解释 query 状态的提示条。 */
function Notice({title, children, tone,}: {
title: string;
children: React.ReactNode;
tone: "warning" | "success";
}) {
const className =
tone === "success"
? "flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs leading-relaxed text-emerald-900"
: "flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900";
return (
<div className={className}>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true"/>
<div>
<strong className="block font-medium">{title}</strong>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import {useMemo, useState} from "react";
import Link from "next/link";
import {CheckCircle2, Lock, Mail, Sparkles} from "lucide-react";
import {PasswordToggle} from "../components/password-toggle";
import {validateSignup} from "../validate";
/** 密码强度的分级结果。 */
interface PasswordStrength {
score: 0 | 1 | 2 | 3;
label: string;
}
/** 密码强度条的颜色映射。 */
const STRENGTH_BAR_COLOR: Record<PasswordStrength["score"], string> = {
0: "bg-muted",
1: "bg-rose-500",
2: "bg-amber-500",
3: "bg-emerald-500",
};
/** 密码强度标签的颜色映射。 */
const STRENGTH_LABEL_COLOR: Record<PasswordStrength["score"], string> = {
0: "text-muted-foreground",
1: "text-rose-600",
2: "text-amber-600",
3: "text-emerald-600",
};
/** 注册表单主体,保留 query 逻辑、密码强度和校验。 */
export default function SignupForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const strength = useMemo(() => gradePassword(password), [password]);
/** 处理注册表单提交,真实接口接入前只执行前端校验和占位提交。 */
async function handleSubmit(event: any) {
event.preventDefault();
setError(null);
const validationError = validateSignup({email, password});
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
// await submitSignup({email, password});
} catch (err) {
// setError(err instanceof Error ? err.message : "Sign-up failed.");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-7">
<div className="space-y-2">
<div
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">
<Sparkles className="h-3 w-3" aria-hidden="true"/>
24-hour free trial / no card required
</div>
<h1 className="text-2xl font-semibold tracking-tight">Create your StoreAI account</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
Two scheduled reports a day from your store. Cancel anytime.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<div className="relative">
<Mail
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@brand.com"
className="flex h-11 w-full rounded-md border border-border bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2"
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<div className="relative">
<Lock
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 8 characters"
className="flex h-11 w-full rounded-md border border-border bg-background px-3 py-2 pl-10 pr-10 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2"
/>
<PasswordToggle
isShown={showPassword}
onToggle={() => setShowPassword((current) => !current)}
/>
</div>
{/*密码等级*/}
{
password && <div className="flex items-center gap-2 pt-1">
<div className="flex flex-1 gap-1">
{[1, 2, 3].map((slot) => (
<div
key={slot}
className={`h-1 flex-1 rounded-full transition-colors ${
strength.score >= slot ? STRENGTH_BAR_COLOR[strength.score] : "bg-muted"
}`}
/>
))}
</div>
<div className={`text-[11px] font-medium ${STRENGTH_LABEL_COLOR[strength.score]}`}>
{strength.label}
</div>
</div>
}
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700">
{error}
</div>
)}
<button
disabled={loading}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60">
{loading ? "Creating account..." : "Start free"}
</button>
</form>
<ul className="space-y-1.5 text-xs text-muted-foreground">
<TrustRow>24-hour free trial - full feature access</TrustRow>
<TrustRow>No credit card needed to start</TrustRow>
<TrustRow>Cancel anytime from billing</TrustRow>
</ul>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href={"/login"} className="font-medium text-foreground underline underline-offset-4">
Sign in
</Link>
</p>
</div>
);
}
/** 按长度和字符类型给密码强度分级。 */
function gradePassword(password: string): PasswordStrength {
if (!password) return {score: 0, label: ""};
if (password.length < 8) return {score: 1, label: "Too short"};
const hasNum = /\d/.test(password);
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasSym = /[^A-Za-z0-9]/.test(password);
const variety = Number(hasNum) + Number(hasLower) + Number(hasUpper) + Number(hasSym);
if (password.length >= 12 && variety >= 3) return {score: 3, label: "Strong"};
if (password.length >= 10 && variety >= 2) return {score: 2, label: "Good"};
return {score: 1, label: "Weak"};
}
/** 注册页信任背书列表中的单行。 */
function TrustRow({children}: { children: React.ReactNode }) {
return (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" aria-hidden="true"/>
{children}
</li>
);
}

View File

@@ -0,0 +1,14 @@
/** 注册表单提交参数。 */
interface SignupPayload {
email: string;
password: string;
}
/** 注册参数校验,保留原型里的邮箱和密码长度约束。 */
export function validateSignup({email, password}: SignupPayload): string | null {
if (!email.trim()) return "Email is required.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Enter a valid email address.";
if (!password) return "Password is required.";
if (password.length < 8) return "Password must be at least 8 characters.";
return null;
}