feat: 新增周末契约盲盒功能,首页重构为双模式入口

- 新增 BlindBoxIdea 数据模型及 migration
- 新增盲盒 API (提交想法/查询/抽取)
- 新增周末契约盲盒页面 (动效震动+彩带开奖)
- 原首页功能拆分至 /panic 路由
- 首页重构为极速救场 + 周末契约双卡片入口
This commit is contained in:
2026-02-26 11:27:10 +08:00
parent 30d5ad5ff2
commit 7d51f5200d
7 changed files with 1361 additions and 581 deletions
+639
View File
@@ -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>
);
}