首页
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
25
src/app/(auth)/components/password-toggle/index.tsx
Normal file
25
src/app/(auth)/components/password-toggle/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(auth)/index.scss
Normal file
9
src/app/(auth)/index.scss
Normal 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
137
src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/(auth)/login/login-error.ts
Normal file
37
src/app/(auth)/login/login-error.ts
Normal 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;
|
||||
}
|
||||
174
src/app/(auth)/login/page.tsx
Normal file
174
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/app/(auth)/signup/page.tsx
Normal file
204
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(auth)/validate.ts
Normal file
14
src/app/(auth)/validate.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user