feat: 新增周末契约盲盒功能,首页重构为双模式入口
- 新增 BlindBoxIdea 数据模型及 migration - 新增盲盒 API (提交想法/查询/抽取) - 新增周末契约盲盒页面 (动效震动+彩带开奖) - 原首页功能拆分至 /panic 路由 - 首页重构为极速救场 + 周末契约双卡片入口
This commit is contained in:
@@ -0,0 +1,639 @@
|
||||
"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, ArrowLeft } from "lucide-react";
|
||||
import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
|
||||
import { getAvatarBg } from "@/lib/avatars";
|
||||
import AuthModal from "@/components/AuthModal";
|
||||
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 PanicPage() {
|
||||
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-6 overflow-y-auto scrollbar-none">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="absolute left-4 top-3 flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
返回
|
||||
</button>
|
||||
|
||||
{/* Profile / Auth button */}
|
||||
<div className="absolute right-4 top-3">
|
||||
{profile ? (
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className={`flex h-8 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-20 truncate text-xs font-semibold text-foreground">{profile.username}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthModalOpen(true)}
|
||||
className="flex h-8 items-center gap-1.5 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<User size={14} />
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex items-center gap-3"
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-linear-to-br from-yellow-400 to-orange-500 shadow-lg shadow-orange-500/20">
|
||||
<Sparkles size={22} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-white">
|
||||
⚡ 极速救场
|
||||
</h1>
|
||||
<p className="text-xs font-medium tracking-widest text-muted">
|
||||
10秒内出结果
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
>
|
||||
{sceneConfig.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-4 flex items-center justify-center gap-4"
|
||||
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 w-16">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/15">
|
||||
<Users size={14} className="text-orange-400" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-muted">创建房间</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight size={12} className="shrink-0 text-dim" />
|
||||
|
||||
<div className="flex flex-col items-center gap-1 w-16">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-500/15">
|
||||
<Heart size={14} className="text-amber-400" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-muted">各自滑卡</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight size={12} className="shrink-0 text-dim" />
|
||||
|
||||
<div className="flex flex-col items-center gap-1 w-16">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/15">
|
||||
<Sparkles size={14} className="text-rose-400" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-muted">匹配结果</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-4 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-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
|
||||
active
|
||||
? "bg-orange-500 text-white shadow-md shadow-orange-500/25"
|
||||
: "bg-surface text-muted ring-1 ring-border hover:bg-elevated"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{cfg.emoji}</span>
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-4 flex w-full max-w-xs flex-col gap-2.5"
|
||||
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-muted" />
|
||||
<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-none bg-surface pl-9 pr-9 text-sm text-foreground outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 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-muted hover:text-gray-300"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
{fetchingSuggestions && (
|
||||
<Loader2 size={14} className="absolute right-3 animate-spin text-dim" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLocation && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||||
<Navigation size={12} className="shrink-0 text-orange-400" />
|
||||
<span className="truncate text-xs text-orange-300/80">
|
||||
{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-orange-400" />
|
||||
<span className="text-xs text-muted">正在获取当前位置...</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-orange-400" />
|
||||
<span className="truncate text-xs text-orange-300/80">
|
||||
当前位置:{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-400/80">
|
||||
{gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={doGpsLocate}
|
||||
className="shrink-0 text-xs font-medium text-orange-400 active:text-orange-300"
|
||||
>
|
||||
重试
|
||||
</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-dim" />
|
||||
<span className="text-xs text-dim">将使用当前定位</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 bg-surface py-1 shadow-xl ring-1 ring-subtle"
|
||||
>
|
||||
{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-elevated"
|
||||
>
|
||||
<MapPin size={14} className="mt-0.5 shrink-0 text-dim" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">{s.name}</p>
|
||||
<p className="truncate text-xs text-muted">{s.district} {s.address}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-surface/60 px-3 py-2.5 ring-1 ring-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-xs font-medium text-muted">{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-elevated pl-3 pr-7 text-xs text-foreground outline-none ring-1 ring-subtle transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
|
||||
/>
|
||||
{cuisine && !loading && (
|
||||
<button
|
||||
onClick={() => setCuisine("")}
|
||||
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted hover:text-gray-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-xs font-medium text-muted"></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-orange-500 text-white shadow-sm shadow-orange-500/25"
|
||||
: "bg-elevated text-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-xs font-medium text-muted">距离</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-orange-500 text-white shadow-sm shadow-orange-500/25"
|
||||
: "bg-elevated text-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-xs font-medium text-muted">人均</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-orange-500 text-white shadow-sm shadow-orange-500/25"
|
||||
: "bg-elevated text-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-linear-to-r from-yellow-500 to-orange-500 text-sm font-bold text-white shadow-lg shadow-orange-500/25 transition-all hover:shadow-xl hover:shadow-orange-500/30 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-1">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-dim">或加入已有房间</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</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-11 flex-1 rounded-xl border-none bg-surface px-4 text-center text-lg font-semibold tracking-[0.3em] text-white outline-none ring-1 ring-border transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || roomCode.length !== 4}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-elevated text-gray-300 ring-1 ring-subtle transition-colors hover:bg-subtle disabled:opacity-30"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-center text-xs font-medium text-rose-400"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user