Files
gitlab-instance-0a899031_cn…/app/components/HeroSection.tsx
2026-03-13 05:51:29 -05:00

362 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation";
import JoinModal from "./JoinModal";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
const avatarPhotos = [
"https://i.pravatar.cc/150?img=32",
"https://i.pravatar.cc/150?img=33",
"https://i.pravatar.cc/150?img=25",
"https://i.pravatar.cc/150?img=52",
"https://i.pravatar.cc/150?img=44",
"https://i.pravatar.cc/150?img=55",
"https://i.pravatar.cc/150?img=47",
"https://i.pravatar.cc/150?img=57",
"https://i.pravatar.cc/150?img=48",
"https://i.pravatar.cc/150?img=59",
"https://i.pravatar.cc/150?img=49",
];
const features = [
{ icon: "🍹", key: "feature1" as const },
{ icon: "❤️", key: "feature2" as const },
{ icon: "🧪", key: "feature3" as const },
{ icon: "🌎", key: "feature4" as const },
{ icon: "💬", key: "feature5" as const },
];
function isPrivateDevHost(hostname: string): boolean {
if (!hostname) return false;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return true;
}
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
return true;
}
const match = hostname.match(/^172\.(\d{1,2})\./);
if (!match) return false;
const secondOctet = Number(match[1]);
return secondOctet >= 16 && secondOctet <= 31;
}
function buildPendingUserId(email: string): string {
return (
"pending_" +
Array.from(new TextEncoder().encode(email))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
);
}
export default function HeroSection() {
const { t: tHero } = useTranslation("hero");
const locale = useLocale();
const [email, setEmail] = useState("");
const [joinModalOpen, setJoinModalOpen] = useState(false);
const [joinModalVipOnly, setJoinModalVipOnly] = useState(false);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const data = await res.json();
setIsLoggedIn(!!data?.user);
} catch {
setIsLoggedIn(false);
}
};
checkAuth();
const onAuth = () => checkAuth();
window.addEventListener("auth:updated", onAuth);
return () => window.removeEventListener("auth:updated", onAuth);
}, []);
const checkUserCacheRef = useRef<{ email: string; data: { exists: boolean; vip: boolean }; ts: number } | null>(null);
const CACHE_TTL_MS = 60_000;
const handleCheckAndProceed = useCallback(
async (em: string) => {
if (checking) return;
const trimmed = em.trim();
if (!trimmed) {
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
return;
}
setError(null);
setChecking(true);
try {
const cached = checkUserCacheRef.current;
if (cached && cached.email === trimmed && Date.now() - cached.ts < CACHE_TTL_MS) {
const { exists, vip } = cached.data;
if (exists) {
setJoinModalVipOnly(!!vip);
setJoinModalOpen(true);
return;
}
}
const checkRes = await fetch("/api/meetup/check-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
credentials: "include",
});
const checkData = await checkRes.json();
if (!checkRes.ok || !checkData?.ok) {
throw new Error(checkData?.error || checkData?.detail || (locale === "zh" ? "操作失败" : "Operation failed"));
}
if (checkData.exists) {
checkUserCacheRef.current = { email: trimmed, data: { exists: true, vip: !!checkData.vip }, ts: Date.now() };
if (checkData.vip) {
setJoinModalOpen(true);
setJoinModalVipOnly(true);
return;
}
setJoinModalVipOnly(false);
setJoinModalOpen(true);
return;
}
// 新用户:支付成功后才创建,用 pending_hex(email) 作为 user_id
const shouldPrecreateUser =
typeof window !== "undefined" &&
isPrivateDevHost(window.location.hostname);
let user_id = buildPendingUserId(trimmed);
if (shouldPrecreateUser) {
const ensureRes = await fetch("/api/meetup/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
credentials: "include",
});
const ensureData = await ensureRes.json();
if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) {
throw new Error(
ensureData?.error ||
ensureData?.detail ||
(locale === "zh" ? "创建用户失败" : "Failed to create user")
);
}
if (ensureData.is_new) {
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
}
user_id = String(ensureData.user_id).trim();
if (ensureData?.token && ensureData?.record) {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: ensureData.token, record: ensureData.record }),
credentials: "include",
});
}
}
const returnUrl =
typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
const env = getPayEnv();
const channel: PayChannel = "wxpay";
redirectToPay({
user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setChecking(false);
}
},
[checking, locale]
);
const handleJoinClick = (e: React.FormEvent) => {
e.preventDefault();
handleCheckAndProceed(email);
};
return (
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa] dark:from-gray-950 dark:via-gray-900 dark:to-gray-950" />
<div
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(249,115,22,0.25) 0%, transparent 70%)",
}}
/>
<div
className="absolute bottom-0 left-0 w-[350px] h-[350px] translate-y-1/3 -translate-x-1/4 opacity-30 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(139,92,246,0.2) 0%, transparent 70%)",
}}
/>
<div className="relative max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-14 md:py-20">
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-10 xl:gap-16">
{/* Left Column */}
<div className="flex-1 min-w-0">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-orange-700 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30 px-3 py-1.5 rounded-full mb-5">
🏆 {tHero("badge")}
</span>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight mb-5">
<span className="hero-gradient-text">{tHero("title")}</span>
<br />
<span className="text-gray-900 dark:text-gray-100">{tHero("titleSuffix")}</span>
</h1>
<p className="text-base sm:text-lg text-gray-500 dark:text-gray-400 max-w-2xl mb-7 leading-relaxed">
{tHero("subtitle")}
</p>
<div className="space-y-2.5 mb-8">
{features.map((f) => (
<div key={f.key} className="flex items-start gap-2.5">
<span className="text-base sm:text-lg shrink-0 mt-0.5">
{f.icon}
</span>
<span className="text-sm sm:text-base text-gray-600 dark:text-gray-400 underline decoration-gray-300 dark:decoration-gray-600 underline-offset-2">
{tHero(f.key)}
</span>
</div>
))}
</div>
<div className="flex items-center gap-3 mb-8">
<div className="flex -space-x-2">
{avatarPhotos.slice(0, 8).map((src, i) => (
<img
key={i}
src={src}
alt=""
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
/>
))}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{tHero("membersJoined")}
</span>
</div>
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
{tHero("mediaPress")}
</p>
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
(name) => (
<span
key={name}
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
>
{name}
</span>
)
)}
</div>
</div>
</div>
{/* Right Column: Video + Email */}
<div className="lg:w-[360px] xl:w-[400px] shrink-0 mt-10 lg:mt-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-800">
<div className="relative h-48 sm:h-56 bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-600 flex items-center justify-center cursor-pointer group overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-4 left-4 w-20 h-16 rounded-lg bg-white/20" />
<div className="absolute bottom-6 right-6 w-24 h-14 rounded-lg bg-white/15" />
<div className="absolute top-1/2 left-1/3 w-16 h-16 rounded-full bg-white/10" />
</div>
<div className="absolute bottom-0 left-0 right-0 h-1/3 bg-gradient-to-t from-black/20 to-transparent" />
<button className="relative w-16 h-16 rounded-full bg-white/95 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<svg
className="w-7 h-7 text-emerald-600 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
{!isLoggedIn ? (
<form onSubmit={handleJoinClick} className="p-4 sm:p-5">
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder={tHero("emailPlaceholder")}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
{error && (
<p className="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={checking}
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98] disabled:opacity-70"
>
{checking ? (locale === "zh" ? "处理中…" : "Processing…") : tHero("ctaJoin")}
</button>
</form>
) : (
<div className="p-4 sm:p-5 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
{locale === "zh" ? "您已登录,可前往社区参与互动" : "You are logged in"}
</p>
</div>
)}
</div>
<JoinModal
isOpen={joinModalOpen}
onClose={() => { setJoinModalOpen(false); setJoinModalVipOnly(false); }}
initialEmail={email.trim()}
vipOnly={joinModalVipOnly}
/>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
📍 {tHero("quickLinks.nextStop")}
</Link>
<Link href="/meetups" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
🍹 {tHero("quickLinks.meetups")}
</Link>
<Link href="/dating" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
{tHero("quickLinks.dating")}
</Link>
<Link href="/tools" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
📊 {tHero("quickLinks.tools")}
</Link>
<Link href="/ai" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
🤖 {tHero("quickLinks.ai")}
</Link>
<Link href="/gigs" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
💰 {tHero("quickLinks.gigs")}
</Link>
<Link href="/report" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
📊 {tHero("quickLinks.report")}
</Link>
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
💬 {tHero("quickLinks.join")}
</Link>
</div>
</div>
</div>
</div>
</section>
);
}