This commit is contained in:
eric
2026-03-15 11:19:52 -05:00
parent 25d6dd6bf7
commit ae17a84ca0
50 changed files with 14087 additions and 1 deletions

View File

@@ -0,0 +1,177 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
pbLogin,
pbRegister,
pbSaveAuth,
} from "@/app/lib/pocketbase";
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (email: string) => void;
}
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
let result;
if (mode === "register") {
if (password !== passwordConfirm) {
setError("两次密码不一致");
setLoading(false);
return;
}
if (password.length < 8) {
setError("密码至少 8 位");
setLoading(false);
return;
}
result = await pbRegister(email, password, passwordConfirm);
} else {
result = await pbLogin(email, password);
}
pbSaveAuth(result.token, result.record);
try {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
credentials: "include",
});
} catch {
/* ignore */
}
onSuccess(email);
onClose();
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!isOpen || !mounted) return null;
const modalContent = (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="flex min-h-svh items-center justify-center p-4 py-8">
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{mode === "login" ? "登录" : "注册"}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{mode === "login" ? "使用邮箱和密码登录" : "创建新账号"}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"></label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
required
minLength={mode === "register" ? 8 : 1}
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{mode === "register" && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"></label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder="再次输入密码"
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
>
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
{mode === "login" ? (
<>
{" "}
<button
type="button"
onClick={() => { setMode("register"); setError(null); }}
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
>
</button>
</>
) : (
<>
{" "}
<button
type="button"
onClick={() => { setMode("login"); setError(null); }}
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
>
</button>
</>
)}
</p>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { memo, useState } from "react";
import { City } from "../data/cities";
interface CityCardProps {
city: City;
rank: number;
onClick?: () => void;
}
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
const [hovered, setHovered] = useState(false);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
className="city-card cursor-pointer relative overflow-hidden rounded-xl min-h-[200px]"
style={{
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
{city.icon}
</span>
<div
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
>
<div className="flex items-start justify-between">
<span className="text-white/70 text-base sm:text-xl font-bold">{rank}</span>
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white">
📡 {city.internetSpeed}Mbps
</span>
</div>
<div className="text-center -mt-2">
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
{city.name}
</h3>
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
👥 {city.nomadsNow}
</span>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
🌡 {city.temperature}°C
</span>
</div>
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
{city.description}
</span>
<span className="font-semibold shrink-0">
¥{city.costPerMonth.toLocaleString()}/
</span>
</div>
</div>
</div>
<div
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-center p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
>
<div className="text-center text-white">
<p className="text-lg font-bold">{city.name}</p>
<p className="text-sm text-white/70 mt-1">{city.description}</p>
</div>
</div>
</div>
);
}
export default memo(CityCardComponent);

View File

@@ -0,0 +1,46 @@
"use client";
import { createPortal } from "react-dom";
import { City } from "../data/cities";
interface CityModalProps {
city: City | null;
isOpen: boolean;
onClose: () => void;
}
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
if (!isOpen || !city) return null;
const modalContent = (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="flex min-h-svh items-center justify-center p-4">
<div
className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-xl dark:border dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{city.name}
</h2>
<p className="mt-2 text-gray-600 dark:text-gray-400">{city.description}</p>
<div className="mt-4 space-y-2 text-sm">
<p>💰 : ¥{city.costPerMonth.toLocaleString()}</p>
<p>📡 : {city.internetSpeed}Mbps</p>
<p>👥 : {city.nomadsNow} </p>
</div>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { memo } from "react";
import { allTags } from "../data/cities";
const SORT_OPTIONS = [
{ value: "score", label: "综合评分" },
{ value: "cost-asc", label: "费用从低到高" },
{ value: "cost-desc", label: "费用从高到低" },
{ value: "internet", label: "网速" },
{ value: "safety", label: "安全" },
{ value: "nomads", label: "游民数量" },
{ value: "temperature", label: "温度" },
];
const tagIcons: Record<string, string> = {
: "🌍",
: "🔥",
便: "💰",
: "🌆",
: "🎨",
: "🏡",
: "🤝",
};
interface FilterBarProps {
activeFilter: string;
onFilterChange: (filter: string) => void;
sortBy: string;
onSortChange: (sort: string) => void;
resultCount: number;
}
function FilterBarComponent({
activeFilter,
onFilterChange,
sortBy,
onSortChange,
resultCount,
}: FilterBarProps) {
return (
<div className="mb-6" id="cities">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100">
</h2>
<span className="text-sm text-gray-400 dark:text-gray-500">
{resultCount}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 dark:text-gray-500">:</span>
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value)}
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 sm:py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] cursor-pointer min-h-[2.5rem] sm:min-h-0"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => onFilterChange(tag)}
className={`shrink-0 flex items-center gap-1 px-3 py-2 sm:py-1.5 rounded-full text-sm font-medium border transition-all min-h-[2.5rem] sm:min-h-0 ${
activeFilter === tag
? "border-gray-900 dark:border-gray-100 bg-gray-100 dark:bg-gray-800"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
<span>{tag}</span>
</button>
))}
</div>
</div>
);
}
export default memo(FilterBarComponent);

View File

@@ -0,0 +1,9 @@
export default function Footer() {
return (
<footer className="border-t border-gray-200 dark:border-gray-800 mt-12 py-8">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 text-center text-sm text-gray-500 dark:text-gray-400">
<p>© {new Date().getFullYear()} Salon. </p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,273 @@
"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>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel } from "@/app/lib/payment";
interface JoinModalProps {
isOpen: boolean;
onClose: () => void;
initialEmail?: string;
vipOnly?: boolean;
}
export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly = false }: JoinModalProps) {
const [email, setEmail] = useState(initialEmail);
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setEmail(initialEmail);
setPassword("");
setError(null);
}
}, [isOpen, initialEmail]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
const em = email.trim();
if (!em) {
setError("请输入邮箱");
return;
}
setError(null);
setLoading(true);
try {
const res = await fetch("/api/salon/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: em, password: password || undefined }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || data?.detail || "操作失败");
}
if (data.is_new) {
await fetch("/api/salon/set-needs-password-change", { method: "POST", credentials: "include" });
}
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, data.record);
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: data.token, record: data.record }),
credentials: "include",
});
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:updated"));
}
if (vipOnly) {
onClose();
return;
}
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/join/paid` : "/join/paid";
const env = getPayEnv();
const channel: PayChannel = "wxpay";
redirectToPay({
user_id: data.user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!isOpen || !mounted) return null;
const modalContent = (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="flex min-h-svh items-center justify-center p-4 py-8">
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{vipOnly ? "验证密码登录" : "加入社区"}
</h2>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"></label>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder="输入你的邮箱..."
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"></label>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder="请输入密码"
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
>
{loading ? "处理中…" : vipOnly ? "登录" : "加入社区 →"}
</button>
</form>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}