This commit is contained in:
eric
2026-03-27 18:20:36 -05:00
parent a8976b2eee
commit 326bad4336
34 changed files with 2787 additions and 996 deletions

View File

@@ -25,7 +25,7 @@ export function AssetStats() {
return (
<div
key={key}
className={styles.card}
className={`${styles.card} ${styles.landingStatCard}`}
style={{
textAlign: "center",
animationDelay: `${0.25 + i * 0.05}s`,

View File

@@ -4,54 +4,75 @@ import { useState } from "react";
import { FAQ_ITEMS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
function ChevronDown() {
return (
<svg className={styles.faqChevron} width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>常见问题</h2>
<div
className="animate__animated animate__fadeInUp"
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{FAQ_ITEMS.map((item, i) => (
<div
key={i}
className={styles.card}
style={{
marginBottom: "0.75rem",
cursor: "pointer",
}}
onClick={() => setOpenIndex(openIndex === i ? null : i)}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontWeight: 500,
color: "var(--foreground)",
}}
>
{item.q}
<span style={{ fontSize: "1.2rem", transition: "transform 0.2s", transform: openIndex === i ? "rotate(180deg)" : "none" }}>
▼
</span>
</div>
{openIndex === i && (
<p
style={{
marginTop: "0.75rem",
fontSize: "0.9rem",
color: "var(--muted-foreground)",
lineHeight: 1.5,
}}
<section className={`${styles.section} ${styles.faqSection}`} aria-labelledby="faq-heading">
<div className={`${styles.faqInner} animate__animated animate__fadeInUp`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
<p className={styles.faqEyebrow}>
<span className={styles.faqEyebrowDot} aria-hidden />
FAQ
</p>
<h2 id="faq-heading" className={styles.faqHeadline}>
常见问题
</h2>
<p className={styles.faqSub}>
购买前可快速了解会员权益与流程;点击问题展开详情。
</p>
<div className={styles.faqList} role="list">
{FAQ_ITEMS.map((item, i) => {
const open = openIndex === i;
const panelId = `faq-panel-${i}`;
const triggerId = `faq-trigger-${i}`;
return (
<div
key={i}
className={`${styles.faqItem} ${open ? styles.faqItemOpen : ""}`}
role="listitem"
>
{item.a}
</p>
)}
</div>
))}
<button
type="button"
id={triggerId}
className={styles.faqTrigger}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpenIndex(open ? null : i)}
>
<span className={styles.faqIndex} aria-hidden>
{i + 1}
</span>
<span className={styles.faqQuestionText}>{item.q}</span>
<ChevronDown />
</button>
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
className={`${styles.faqPanel} ${open ? styles.faqPanelOpen : ""}`}
>
<div className={styles.faqPanelInner}>
<p className={styles.faqAnswer}>{item.a}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);

View File

@@ -9,6 +9,9 @@ type LandingCTAProps = {
onLogin: () => void;
};
/** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */
const SHOW_CTA_AUDIENCE_BOUNDARY = false;
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
const [restoreDate, setRestoreDate] = useState<string>("");
useEffect(() => {
@@ -16,50 +19,68 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
}, []);
return (
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
<div
className={`${styles.card} animate__animated animate__zoomIn`}
style={{
maxWidth: "560px",
margin: "0 auto",
padding: "1.5rem",
textAlign: "center",
animationDelay: "0.2s",
animationFillMode: "both",
}}
>
<h2 style={{ fontSize: "1.35rem", margin: "0 0 0.5rem", color: "var(--foreground)" }}>
限时特惠 · {restoreDate || "—"}恢复原价
</h2>
<p style={{ fontSize: "0.9rem", color: "var(--muted-foreground)", margin: "0 0 1rem" }}>
阶梯定价 · 数字交付 · 不支持退款
</p>
<p style={{ fontSize: "0.8rem", color: "var(--muted-foreground)", marginBottom: "1.5rem" }}>
🚫 不适合躺赚、现金流紧张
</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "1rem",
justifyContent: "center",
alignItems: "center",
}}
>
<button
type="button"
onClick={onJoin}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
<section className={`${styles.section} ${styles.ctaSection}`} aria-labelledby="cta-heading">
<div className={`${styles.ctaInner} animate__animated animate__zoomIn`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
<div className={styles.ctaGlow} aria-hidden />
<div className={styles.ctaCard}>
<h2 id="cta-heading" className={styles.ctaPromoLine}>
<span className={styles.ctaBadgeInline}>
<span className={styles.ctaBadgePulse} aria-hidden />
限时特惠
</span>
<span className={styles.ctaPromoDivider} aria-hidden>
·
</span>
<span className={styles.ctaPromoMain}>
<span className={styles.ctaHighlight}>{restoreDate || "—"}</span>
<span className={styles.ctaPromoSuffix}>恢复原价</span>
</span>
</h2>
<div className={styles.ctaChips}>
<span className={styles.ctaChip}>阶梯定价</span>
<span className={styles.ctaChip}>数字交付</span>
<span className={styles.ctaChip}>持续更新</span>
<span className={styles.ctaChip}>不支持退款</span>
</div>
<div
className={`${styles.ctaBoundary} ${SHOW_CTA_AUDIENCE_BOUNDARY ? "" : styles.ctaBoundaryHidden}`}
role="note"
aria-hidden={!SHOW_CTA_AUDIENCE_BOUNDARY}
>
🔒 立即加入
</button>
<button
type="button"
onClick={onLogin}
className={styles.btnSecondary}
>
已有账号登录
</button>
<div className={styles.ctaBoundarySheen} aria-hidden />
<div className={styles.ctaBoundaryInner}>
<p className={styles.ctaBoundaryPos}>
<span className={styles.ctaBoundaryMark} aria-hidden>
✦
</span>
<span>
<strong className={styles.ctaBoundaryStrong}>适合你:</strong>
愿意投入行动、认真做增长与出海的同行者
</span>
</p>
<p className={styles.ctaBoundaryNeg}>
<span className={styles.ctaBoundaryMarkNeg} aria-hidden>
·
</span>
<span>
<strong className={styles.ctaBoundaryStrongNeg}>请止步:</strong>
指望躺赚,或当下现金流极度紧张的朋友
</span>
</p>
</div>
</div>
<div className={styles.ctaActions}>
<button
type="button"
onClick={onJoin}
className={`${styles.btnPrimary} ${styles.btnLarge} ${styles.ctaBtnPrimary}`}
>
🔒 立即加入
</button>
<button type="button" onClick={onLogin} className={styles.ctaBtnSecondary}>
已有账号登录
</button>
</div>
</div>
</div>
</section>

View File

@@ -4,50 +4,23 @@ import styles from "../vip.module.css";
export function LandingHero() {
return (
<section className={`${styles.section} ${styles.heroSection}`} style={{ textAlign: "center" }}>
<section className={`${styles.section} ${styles.heroSection} ${styles.heroSection3d}`} style={{ textAlign: "center" }}>
<p
className="animate__animated animate__fadeInDown"
style={{
display: "inline-block",
fontSize: "0.875rem",
color: "var(--muted-foreground)",
background: "var(--card)",
padding: "0.35rem 0.85rem",
borderRadius: "9999px",
border: "1px solid var(--border)",
marginBottom: "1rem",
animationDelay: "0.1s",
animationFillMode: "both",
}}
className={`${styles.heroEyebrow} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
>
异度星球会员中心
</p>
<h1
className="animate__animated animate__fadeInDown"
style={{
fontSize: "clamp(2rem, 6vw, 3rem)",
fontWeight: 700,
margin: "0 0 0.5rem",
letterSpacing: "-0.02em",
lineHeight: 1.2,
animationDelay: "0.2s",
animationFillMode: "both",
}}
className={`${styles.heroTitle} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
全模块解锁 · 持续更新 · 抱团出海
<span className={styles.heroTitleAccent}>全模块解锁</span>
<span className={styles.heroTitleRest}> · 持续更新 · 抱团出海</span>
</h1>
<p
className="animate__animated animate__fadeInDown"
style={{
fontSize: "1.125rem",
color: "var(--muted-foreground)",
maxWidth: "540px",
margin: "0 auto",
lineHeight: 1.6,
textAlign: "center",
animationDelay: "0.3s",
animationFillMode: "both",
}}
className={`${styles.heroLead} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.3s", animationFillMode: "both" }}
>
一次付费,解锁电子书、视频教程、私密社群、即时问答,每周更新
</p>

View File

@@ -12,6 +12,8 @@ type LoginFormProps = {
) => Promise<boolean | { ok: boolean; error?: string }>;
onSuccess: () => void;
autoFocus?: boolean;
/** 验证请求进行中(用于全屏 loading 等) */
onBusyChange?: (busy: boolean) => void;
};
export function LoginForm({
@@ -19,6 +21,7 @@ export function LoginForm({
onVerifyByEmail,
onSuccess,
autoFocus,
onBusyChange,
}: LoginFormProps) {
const [mode, setMode] = useState<"user_id" | "email">("user_id");
const [userId, setUserId] = useState("");
@@ -41,6 +44,10 @@ export function LoginForm({
if (autoFocus) inputRef.current?.focus();
}, [autoFocus, mode]);
useEffect(() => {
onBusyChange?.(loading);
}, [loading, onBusyChange]);
const handleUserIdSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmed = userId.trim();
@@ -51,14 +58,19 @@ export function LoginForm({
setError(null);
setLoading(true);
let succeeded = false;
try {
const ok = await onVerify(trimmed);
if (ok) onSuccess();
else setError("未找到该账号的会员记录,请确认账号正确");
if (ok) {
succeeded = true;
onSuccess();
} else {
setError("未找到该账号的会员记录,请确认账号正确");
}
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
if (!succeeded) setLoading(false);
}
};
@@ -79,6 +91,7 @@ export function LoginForm({
setError(null);
setLoading(true);
let succeeded = false;
try {
const candidateUserId = getCandidateUserId();
const result = await onVerifyByEmail(
@@ -88,12 +101,16 @@ export function LoginForm({
);
const ok = typeof result === "object" ? result.ok : result;
const errMsg = typeof result === "object" ? result.error : undefined;
if (ok) onSuccess();
else setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
if (ok) {
succeeded = true;
onSuccess();
} else {
setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
}
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
if (!succeeded) setLoading(false);
}
};

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { HomeBootOverlay } from "@/app/components/HomeBootOverlay";
import styles from "../vip.module.css";
import { LoginForm } from "./LoginForm";
@@ -17,11 +18,14 @@ type LoginModalProps = {
};
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
const [verifyOverlay, setVerifyOverlay] = useState(false);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
setVerifyOverlay(false);
}
return () => {
document.body.style.overflow = "";
@@ -31,56 +35,63 @@ export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Escape" && !verifyOverlay) onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
}, [open, onClose, verifyOverlay]);
const handleSuccess = () => {
onClose();
// 勿先 onClose:父级会把 open 设为 false,弹窗卸载会清掉全屏 loading,应在 reload 前保持验证中态
onSuccess();
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
>
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
登录
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<LoginForm
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={handleSuccess}
autoFocus
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={verifyOverlay ? undefined : onClose}
/>
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
登录
</h2>
<button
type="button"
onClick={onClose}
disabled={verifyOverlay}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:pointer-events-none disabled:opacity-40"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<LoginForm
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={handleSuccess}
onBusyChange={setVerifyOverlay}
autoFocus
/>
</div>
</div>
</div>
{verifyOverlay ? (
<HomeBootOverlay visible subtitle="正在验证并解锁会员…" />
) : null}
</>
);
}

View File

@@ -7,9 +7,14 @@ import { NetdiskDownloadsSection } from "@/app/components/NetdiskDownloadsSectio
type TopicEbookShowcaseProps = {
onLockedClick?: () => void;
/** VIP 首页:点击「私密社群」打开社群登录信息弹窗 */
onCommunityCardClick?: () => void;
};
export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
export function TopicEbookShowcase({
onLockedClick,
onCommunityCardClick,
}: TopicEbookShowcaseProps) {
return (
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
{/* 模块展示 */}
@@ -21,6 +26,7 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
{TOPICS_DISPLAY.map((t) => {
const { id, href } = t;
const isLocked = "locked" in t && t.locked && onLockedClick;
const isCommunityModal = id === "community" && onCommunityCardClick;
const content = (
<span className={styles.topicCardInner}>
{isLocked && (
@@ -44,6 +50,19 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
</button>
);
}
if (isCommunityModal) {
return (
<button
key={id}
type="button"
className={styles.topicCard}
onClick={onCommunityCardClick}
style={{ cursor: "pointer", border: "none", font: "inherit" }}
>
{content}
</button>
);
}
if (href) {
return (
<Link