cc7f6d55a7
- 新增 icon.png (96x96) 和 apple-icon.png (180x180) 作为浏览器/iOS 图标 - 新增 opengraph-image.png 用于微信等社交媒体分享预览 - 新增 BrandLogo SVG 组件,首页标题上方展示品牌图标
625 lines
23 KiB
TypeScript
625 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react";
|
||
import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
|
||
import { getAvatarBg } from "@/lib/avatars";
|
||
import AuthModal from "@/components/AuthModal";
|
||
import BrandLogo from "@/components/BrandLogo";
|
||
import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
|
||
import type { UserProfile, SceneType } from "@/types";
|
||
|
||
interface LocationSuggestion {
|
||
id: string;
|
||
name: string;
|
||
district: string;
|
||
address: string;
|
||
lat: number;
|
||
lng: number;
|
||
}
|
||
|
||
type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
|
||
|
||
const DISTANCE_OPTIONS = [
|
||
{ label: "1km", value: 1000 },
|
||
{ label: "3km", value: 3000 },
|
||
{ label: "5km", value: 5000 },
|
||
] as const;
|
||
|
||
type GpsResult =
|
||
| { ok: true; lat: number; lng: number }
|
||
| { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
|
||
|
||
function requestGps(): Promise<GpsResult> {
|
||
return new Promise((resolve) => {
|
||
if (!navigator.geolocation) {
|
||
resolve({ ok: false, reason: "unsupported" });
|
||
return;
|
||
}
|
||
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) =>
|
||
resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||
(err) => {
|
||
const reason =
|
||
err.code === err.PERMISSION_DENIED
|
||
? "denied"
|
||
: err.code === err.TIMEOUT
|
||
? "timeout"
|
||
: "unknown";
|
||
resolve({ ok: false, reason });
|
||
},
|
||
{ timeout: 8000, enableHighAccuracy: false },
|
||
);
|
||
});
|
||
}
|
||
|
||
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
|
||
try {
|
||
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
||
const data = await res.json();
|
||
return data.name || data.formatted || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export default function LandingPage() {
|
||
const router = useRouter();
|
||
const [roomCode, setRoomCode] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingText, setLoadingText] = useState("");
|
||
const [error, setError] = useState("");
|
||
|
||
const [locationQuery, setLocationQuery] = useState("");
|
||
const [suggestions, setSuggestions] = useState<LocationSuggestion[]>([]);
|
||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||
const [selectedLocation, setSelectedLocation] = useState<LocationSuggestion | null>(null);
|
||
const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
|
||
const [radius, setRadius] = useState(3000);
|
||
const [priceRange, setPriceRange] = useState("any");
|
||
const [cuisine, setCuisine] = useState("");
|
||
const suggestRef = useRef<HTMLDivElement>(null);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||
|
||
const [gpsStatus, setGpsStatus] = useState<GpsStatus>("idle");
|
||
const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||
const [gpsLocationName, setGpsLocationName] = useState<string | null>(null);
|
||
|
||
const [scene, setScene] = useState<SceneType>("eat");
|
||
const sceneConfig = getSceneConfig(scene);
|
||
|
||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const cached = getCachedProfile();
|
||
if (cached) setProfile(cached);
|
||
|
||
const prefs = getCachedPreferences();
|
||
if (prefs.cuisine) setCuisine(prefs.cuisine);
|
||
if (prefs.priceRange) setPriceRange(prefs.priceRange);
|
||
if (prefs.radius) setRadius(prefs.radius);
|
||
}, []);
|
||
|
||
const handleSceneChange = useCallback((s: SceneType) => {
|
||
setScene(s);
|
||
setCuisine("");
|
||
setPriceRange("any");
|
||
}, []);
|
||
|
||
const doGpsLocate = useCallback(async () => {
|
||
setGpsStatus("locating");
|
||
const result = await requestGps();
|
||
if (result.ok) {
|
||
setGpsCoords({ lat: result.lat, lng: result.lng });
|
||
setGpsStatus("success");
|
||
const name = await reverseGeocode(result.lat, result.lng);
|
||
if (name) setGpsLocationName(name);
|
||
} else {
|
||
setGpsCoords(null);
|
||
setGpsLocationName(null);
|
||
setGpsStatus(result.reason === "denied" ? "denied" : "failed");
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
doGpsLocate();
|
||
}, [doGpsLocate]);
|
||
|
||
const fetchSuggestions = useCallback(async (query: string) => {
|
||
if (query.length < 1) {
|
||
setSuggestions([]);
|
||
setShowSuggestions(false);
|
||
return;
|
||
}
|
||
setFetchingSuggestions(true);
|
||
try {
|
||
const res = await fetch(`/api/location/suggest?keywords=${encodeURIComponent(query)}`);
|
||
const data: LocationSuggestion[] = await res.json();
|
||
setSuggestions(data);
|
||
setShowSuggestions(data.length > 0);
|
||
} catch {
|
||
setSuggestions([]);
|
||
} finally {
|
||
setFetchingSuggestions(false);
|
||
}
|
||
}, []);
|
||
|
||
const handleLocationInput = (val: string) => {
|
||
setLocationQuery(val);
|
||
setSelectedLocation(null);
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
|
||
};
|
||
|
||
const handleSelectLocation = (loc: LocationSuggestion) => {
|
||
setSelectedLocation(loc);
|
||
setLocationQuery(loc.name);
|
||
setShowSuggestions(false);
|
||
setSuggestions([]);
|
||
};
|
||
|
||
const clearLocation = () => {
|
||
setSelectedLocation(null);
|
||
setLocationQuery("");
|
||
setSuggestions([]);
|
||
setShowSuggestions(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
|
||
setShowSuggestions(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, []);
|
||
|
||
const joinRoom = async (roomId: string) => {
|
||
const userId = getUserId();
|
||
const res = await fetch(`/api/room/${roomId}/join`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ userId }),
|
||
});
|
||
if (!res.ok) throw new Error("房间不存在");
|
||
return roomId;
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
setError("");
|
||
|
||
let coords: { lat: number; lng: number };
|
||
|
||
if (selectedLocation) {
|
||
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
|
||
} else if (gpsCoords) {
|
||
coords = gpsCoords;
|
||
} else if (gpsStatus === "locating") {
|
||
setError("正在定位中,请稍候...");
|
||
return;
|
||
} else {
|
||
setError("无法获取位置,请在上方搜索并选择一个地点");
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
setLoadingText(sceneConfig.loadingText);
|
||
|
||
const res = await fetch("/api/room/create", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId(), scene }),
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "创建房间失败");
|
||
}
|
||
|
||
if (!data.roomId) {
|
||
throw new Error("创建房间失败");
|
||
}
|
||
|
||
setLoadingText("正在进入房间...");
|
||
await joinRoom(data.roomId);
|
||
router.push(`/room/${data.roomId}`);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "创建失败,请重试");
|
||
setLoading(false);
|
||
setLoadingText("");
|
||
}
|
||
};
|
||
|
||
const handleJoin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (roomCode.length !== 4) {
|
||
setError("请输入 4 位房间号");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
await joinRoom(roomCode);
|
||
router.push(`/room/${roomCode}`);
|
||
} catch {
|
||
setError("房间不存在,请检查房间号");
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
|
||
{/* Profile / Auth button */}
|
||
<div className="absolute right-4 top-4">
|
||
{profile ? (
|
||
<button
|
||
onClick={() => router.push("/profile")}
|
||
className={`flex h-9 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors active:opacity-80 ${getAvatarBg(profile.avatar)}`}
|
||
>
|
||
<span className="text-base leading-none">{profile.avatar}</span>
|
||
<span className="max-w-[5rem] truncate text-xs font-semibold text-zinc-700">{profile.username}</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => setAuthModalOpen(true)}
|
||
className="flex h-9 items-center gap-1.5 rounded-full bg-zinc-100 px-3 text-xs font-medium text-zinc-500 transition-colors active:bg-zinc-200"
|
||
>
|
||
<User size={14} />
|
||
登录
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<motion.div
|
||
className="flex flex-col items-center"
|
||
initial={{ y: -20, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ duration: 0.5 }}
|
||
>
|
||
<BrandLogo size={56} />
|
||
<h1 className="mt-2.5 text-3xl font-black tracking-tight text-zinc-900">
|
||
NoWhatever
|
||
</h1>
|
||
<p className="mt-0.5 text-sm font-medium tracking-widest text-zinc-400">
|
||
别说随便
|
||
</p>
|
||
<p className="mt-3 max-w-xs text-center text-sm leading-relaxed text-zinc-500">
|
||
{sceneConfig.subtitle}
|
||
</p>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
className="mt-8 flex items-start justify-center gap-3"
|
||
initial={{ y: 10, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ duration: 0.5, delay: 0.1 }}
|
||
>
|
||
<div className="flex flex-col items-center gap-1.5 w-20">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50">
|
||
<Users size={18} className="text-emerald-500" />
|
||
</div>
|
||
<span className="text-xs font-semibold text-zinc-700">创建房间</span>
|
||
<span className="text-[10px] leading-tight text-zinc-400 text-center">邀请朋友加入</span>
|
||
</div>
|
||
|
||
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
|
||
|
||
<div className="flex flex-col items-center gap-1.5 w-20">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-50">
|
||
<Heart size={18} className="text-amber-500" />
|
||
</div>
|
||
<span className="text-xs font-semibold text-zinc-700">各自滑卡</span>
|
||
<span className="text-[10px] leading-tight text-zinc-400 text-center">右滑喜欢的店</span>
|
||
</div>
|
||
|
||
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
|
||
|
||
<div className="flex flex-col items-center gap-1.5 w-20">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-50">
|
||
<Sparkles size={18} className="text-rose-500" />
|
||
</div>
|
||
<span className="text-xs font-semibold text-zinc-700">匹配结果</span>
|
||
<span className="text-[10px] leading-tight text-zinc-400 text-center">滑中同一家就去</span>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
className="mt-8 flex items-center justify-center gap-2"
|
||
initial={{ y: 10, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ duration: 0.4, delay: 0.12 }}
|
||
>
|
||
{SCENES.map((s) => {
|
||
const cfg = getSceneConfig(s);
|
||
const active = scene === s;
|
||
return (
|
||
<button
|
||
key={s}
|
||
onClick={() => handleSceneChange(s)}
|
||
disabled={loading}
|
||
className={`flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-semibold transition-all disabled:opacity-50 ${
|
||
active
|
||
? "bg-emerald-500 text-white shadow-md shadow-emerald-200"
|
||
: "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
|
||
}`}
|
||
>
|
||
<span className="text-base leading-none">{cfg.emoji}</span>
|
||
{cfg.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
className="mt-5 flex w-full max-w-xs flex-col gap-3"
|
||
initial={{ y: 20, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ duration: 0.5, delay: 0.15 }}
|
||
>
|
||
<div ref={suggestRef} className="relative">
|
||
<div className="relative flex items-center">
|
||
<MapPin size={16} className="absolute left-3 text-zinc-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="搜索位置(默认当前位置)"
|
||
value={locationQuery}
|
||
onChange={(e) => handleLocationInput(e.target.value)}
|
||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||
disabled={loading}
|
||
className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
|
||
/>
|
||
{(selectedLocation || locationQuery) && !loading && (
|
||
<button
|
||
onClick={clearLocation}
|
||
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
{fetchingSuggestions && (
|
||
<Loader2 size={14} className="absolute right-3 animate-spin text-zinc-300" />
|
||
)}
|
||
</div>
|
||
|
||
{selectedLocation && (
|
||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||
<Navigation size={12} className="shrink-0 text-emerald-500" />
|
||
<span className="truncate text-xs text-emerald-600">
|
||
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{!selectedLocation && !locationQuery && gpsStatus === "locating" && (
|
||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||
<Loader2 size={12} className="shrink-0 animate-spin text-emerald-400" />
|
||
<span className="text-xs text-zinc-400">正在获取当前位置...</span>
|
||
</div>
|
||
)}
|
||
|
||
{!selectedLocation && !locationQuery && gpsStatus === "success" && (
|
||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||
<Navigation size={12} className="shrink-0 text-emerald-500" />
|
||
<span className="truncate text-xs text-emerald-600">
|
||
当前位置:{gpsLocationName || "已定位"}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && (
|
||
<div className="mt-1.5 flex items-center justify-between px-1">
|
||
<div className="flex items-center gap-1.5">
|
||
<MapPin size={12} className="shrink-0 text-amber-500" />
|
||
<span className="text-xs text-amber-600">
|
||
{gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={doGpsLocate}
|
||
className="shrink-0 text-xs font-medium text-emerald-500 active:text-emerald-700"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!selectedLocation && !locationQuery && gpsStatus === "idle" && (
|
||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||
<Navigation size={12} className="shrink-0 text-zinc-400" />
|
||
<span className="text-xs text-zinc-400">将使用当前定位</span>
|
||
</div>
|
||
)}
|
||
|
||
<AnimatePresence>
|
||
{showSuggestions && (
|
||
<motion.ul
|
||
initial={{ opacity: 0, y: -4 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -4 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl border border-zinc-100 bg-white py-1 shadow-lg"
|
||
>
|
||
{suggestions.map((s) => (
|
||
<li key={s.id}>
|
||
<button
|
||
onClick={() => handleSelectLocation(s)}
|
||
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
|
||
>
|
||
<MapPin size={14} className="mt-0.5 shrink-0 text-zinc-400" />
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-medium text-zinc-800">{s.name}</p>
|
||
<p className="truncate text-xs text-zinc-400">{s.district} {s.address}</p>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</motion.ul>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 rounded-xl border border-zinc-100 bg-zinc-50/50 px-3 py-2.5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">{sceneConfig.tagLabel}</span>
|
||
<div className="relative flex flex-1 items-center">
|
||
<input
|
||
type="text"
|
||
placeholder={sceneConfig.tagPlaceholder}
|
||
value={cuisine}
|
||
onChange={(e) => setCuisine(e.target.value)}
|
||
disabled={loading}
|
||
className="h-7 w-full rounded-full border-none bg-white pl-3 pr-7 text-xs text-zinc-700 outline-none ring-1 ring-zinc-200 transition-colors placeholder:text-zinc-300 focus:ring-2 focus:ring-emerald-300 disabled:opacity-50"
|
||
/>
|
||
{cuisine && !loading && (
|
||
<button
|
||
onClick={() => setCuisine("")}
|
||
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<Flame size={11} className="shrink-0 text-orange-400" />
|
||
{sceneConfig.hotTags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => setCuisine(tag)}
|
||
disabled={loading}
|
||
className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||
cuisine === tag
|
||
? "bg-emerald-500 text-white shadow-sm"
|
||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
||
}`}
|
||
>
|
||
{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">距离</span>
|
||
<div className="flex gap-1.5">
|
||
{DISTANCE_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setRadius(opt.value)}
|
||
disabled={loading}
|
||
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||
radius === opt.value
|
||
? "bg-emerald-500 text-white shadow-sm"
|
||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">人均</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{sceneConfig.priceOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setPriceRange(opt.value)}
|
||
disabled={loading}
|
||
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||
priceRange === opt.value
|
||
? "bg-emerald-500 text-white shadow-sm"
|
||
: "bg-white text-zinc-500 hover:bg-zinc-200"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleCreate}
|
||
disabled={loading}
|
||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||
>
|
||
{loading && loadingText ? (
|
||
<>
|
||
<Loader2 size={18} className="animate-spin" />
|
||
{loadingText}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus size={18} strokeWidth={3} />
|
||
创建新房间
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
<div className="flex items-center gap-3 py-2">
|
||
<div className="h-px flex-1 bg-zinc-200" />
|
||
<span className="text-xs text-zinc-400">或加入已有房间</span>
|
||
<div className="h-px flex-1 bg-zinc-200" />
|
||
</div>
|
||
|
||
<form onSubmit={handleJoin} className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
maxLength={4}
|
||
placeholder="输入 4 位房间号"
|
||
value={roomCode}
|
||
onChange={(e) => {
|
||
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
|
||
setError("");
|
||
}}
|
||
disabled={loading}
|
||
className="h-12 flex-1 rounded-xl border border-zinc-200 bg-white px-4 text-center text-lg font-semibold tracking-[0.3em] text-zinc-900 outline-none transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={loading || roomCode.length !== 4}
|
||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-30"
|
||
>
|
||
<LogIn size={18} />
|
||
</button>
|
||
</form>
|
||
|
||
{error && (
|
||
<motion.p
|
||
className="text-center text-xs font-medium text-rose-500"
|
||
initial={{ opacity: 0, y: -4 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
{error}
|
||
</motion.p>
|
||
)}
|
||
</motion.div>
|
||
|
||
<AuthModal
|
||
open={authModalOpen}
|
||
onClose={() => setAuthModalOpen(false)}
|
||
onAuth={(p) => setProfile(p)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|