Files
2026-03-15 11:19:52 -05:00

274 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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 from "next/link";
import JoinModal from "./JoinModal";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel } from "@/app/lib/payment";
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",
];
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 [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("请输入邮箱");
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/salon/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 || "操作失败");
}
if (checkData.exists) {
checkUserCacheRef.current = { email: trimmed, data: { exists: true, vip: !!checkData.vip }, ts: Date.now() };
setJoinModalVipOnly(!!checkData.vip);
setJoinModalOpen(true);
return;
}
const shouldPrecreateUser =
typeof window !== "undefined" &&
isPrivateDevHost(window.location.hostname);
let user_id = buildPendingUserId(trimmed);
if (shouldPrecreateUser) {
const ensureRes = await fetch("/api/salon/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 || "创建用户失败");
}
if (ensureData.is_new) {
await fetch("/api/salon/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}/join/paid` : "/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]
);
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="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">
<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">
🏆
</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"></span>
<br />
<span className="text-gray-900 dark:text-gray-100"></span>
</h1>
<p className="text-base sm:text-lg text-gray-500 dark:text-gray-400 max-w-2xl mb-7 leading-relaxed">
</p>
<div className="space-y-2.5 mb-8">
{[
{ icon: "🍹", text: "线下聚会与线上分享" },
{ icon: "❤️", text: "结识志同道合的伙伴" },
{ icon: "🧪", text: "探索新城市与新可能" },
{ icon: "🌎", text: "全球数字游民网络" },
{ icon: "💬", text: "社区互助与资源共享" },
].map((f) => (
<div key={f.icon} 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">
{f.text}
</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">
5,000+
</span>
</div>
</div>
<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 overflow-hidden">
<button className="relative w-16 h-16 rounded-full bg-white/95 flex items-center justify-center shadow-lg 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="输入邮箱,加入社区"
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 ? "处理中…" : "加入社区 →"}
</button>
</form>
) : (
<div className="p-4 sm:p-5 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
</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="/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">
💬
</Link>
</div>
</div>
</div>
</div>
</section>
);
}