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
@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"avatar" TEXT NOT NULL DEFAULT '🐱',
"email" TEXT,
"preferences" TEXT NOT NULL DEFAULT '{}',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Decision" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"restaurantName" TEXT NOT NULL,
"restaurantData" TEXT NOT NULL,
"matchType" TEXT NOT NULL,
"participants" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Decision_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Favorite" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"restaurantData" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "BlindBoxIdea" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'in_pool',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+8
View File
@@ -45,3 +45,11 @@ model Favorite {
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
model BlindBoxIdea {
id String @id @default(uuid())
roomId String
content String
status String @default("in_pool")
createdAt DateTime @default(now())
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
try {
const { roomId } = await req.json();
if (!roomId || typeof roomId !== "string") {
return NextResponse.json({ error: "roomId 不能为空" }, { status: 400 });
}
const pool = await prisma.blindBoxIdea.findMany({
where: { roomId: roomId.trim(), status: "in_pool" },
select: { id: true },
});
if (pool.length === 0) {
return NextResponse.json(
{ error: "盒子是空的,先往里面塞点想法吧!" },
{ status: 404 },
);
}
const picked = pool[Math.floor(Math.random() * pool.length)];
const idea = await prisma.blindBoxIdea.update({
where: { id: picked.id },
data: { status: "drawn" },
});
return NextResponse.json({
id: idea.id,
content: idea.content,
createdAt: idea.createdAt,
});
} catch {
return NextResponse.json({ error: "抽取失败" }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
try {
const { roomId, content } = await req.json();
if (!roomId || typeof roomId !== "string") {
return NextResponse.json({ error: "roomId 不能为空" }, { status: 400 });
}
if (!content || typeof content !== "string" || content.trim().length === 0) {
return NextResponse.json({ error: "内容不能为空" }, { status: 400 });
}
if (content.trim().length > 200) {
return NextResponse.json({ error: "内容不能超过 200 字" }, { status: 400 });
}
const idea = await prisma.blindBoxIdea.create({
data: {
roomId: roomId.trim(),
content: content.trim(),
},
});
return NextResponse.json({ id: idea.id }, { status: 201 });
} catch {
return NextResponse.json({ error: "提交失败" }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const roomId = req.nextUrl.searchParams.get("roomId");
if (!roomId) {
return NextResponse.json({ error: "缺少 roomId" }, { status: 400 });
}
const [poolCount, drawn] = await Promise.all([
prisma.blindBoxIdea.count({
where: { roomId, status: "in_pool" },
}),
prisma.blindBoxIdea.findMany({
where: { roomId, status: "drawn" },
orderBy: { createdAt: "desc" },
select: { id: true, content: true, createdAt: true },
}),
]);
return NextResponse.json({ poolCount, drawn });
}
+169 -581
View File
@@ -1,629 +1,217 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useEffect } 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 { motion } from "framer-motion";
import { Zap, Gift, Clock, Trophy } from "lucide-react";
import BrandLogo from "@/components/BrandLogo";
import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
import type { UserProfile, SceneType } from "@/types";
interface LocationSuggestion {
function generateRoomCode() {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
interface DrawnIdea {
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;
}
content: string;
createdAt: string;
}
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);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [blindboxRoom, setBlindboxRoom] = useState("");
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");
const saved = localStorage.getItem("nw_blindbox_room");
if (saved) {
setBlindboxRoom(saved);
fetch(`/api/blindbox?roomId=${saved}`)
.then((r) => r.json())
.then((data) => {
if (data.drawn) setDrawnHistory(data.drawn);
})
.catch(() => {});
}
}, []);
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 handlePanicMode = () => {
router.push("/panic");
};
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);
const handleAdventureMode = () => {
let room = blindboxRoom;
if (!room) {
room = generateRoomCode();
localStorage.setItem("nw_blindbox_room", room);
setBlindboxRoom(room);
}
router.push(`/room/${room}/blindbox`);
};
return (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6">
{/* 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-[5rem] truncate text-xs font-semibold text-zinc-700">{profile.username}</span>
</button>
) : (
<button
onClick={() => setAuthModalOpen(true)}
className="flex h-8 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>
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-8 overflow-y-auto scrollbar-none">
{/* Header */}
<motion.div
className="flex items-center gap-3"
initial={{ y: -20, opacity: 0 }}
initial={{ y: -30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<BrandLogo size={44} />
<BrandLogo size={40} />
<div>
<h1 className="text-2xl font-black tracking-tight text-zinc-900">
<h1 className="text-xl font-black tracking-tight text-white">
NoWhatever
</h1>
<p className="text-xs font-medium tracking-widest text-zinc-400">
便
<p className="text-[10px] font-medium tracking-[0.2em] text-muted">
便 ·
</p>
</div>
</motion.div>
<motion.p
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-zinc-500"
className="mt-3 max-w-xs text-center text-xs leading-relaxed text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.05 }}
transition={{ delay: 0.2 }}
>
{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-emerald-50">
<Users size={14} className="text-emerald-500" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-zinc-300" />
<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-50">
<Heart size={14} className="text-amber-500" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-zinc-300" />
<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-50">
<Sparkles size={14} className="text-rose-500" />
</div>
<span className="text-[10px] font-semibold text-zinc-700"></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-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-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-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-11 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"
{/* Dual Cards */}
<div className="mt-8 flex w-full max-w-sm flex-col gap-5">
{/* Card A: Panic Mode */}
<motion.button
onClick={handlePanicMode}
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-6 text-left shadow-lg shadow-orange-500/20 transition-shadow hover:shadow-xl hover:shadow-orange-500/30"
initial={{ x: -40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
whileHover={{ scale: 1.02, rotate: -0.5 }}
whileTap={{ scale: 0.98 }}
>
{loading && loadingText ? (
<>
<Loader2 size={18} className="animate-spin" />
{loadingText}
</>
) : (
<>
<Plus size={18} strokeWidth={3} />
</>
)}
</button>
<div className="absolute -right-4 -top-4 h-24 w-24 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -bottom-6 -left-6 h-20 w-20 rounded-full bg-white/10 blur-xl" />
<div className="flex items-center gap-3 py-1">
<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>
<div className="relative z-10">
<div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/10">
<Zap size={22} className="text-white" />
</div>
<div>
<h2 className="text-lg font-black text-white"> </h2>
<p className="text-[10px] font-semibold tracking-wider text-white/70">
PANIC MODE
</p>
</div>
</div>
<p className="mt-4 text-sm font-medium leading-relaxed text-white/90">
10
</p>
<div className="mt-3 flex items-center gap-1.5 text-xs font-bold text-white/60">
<Clock size={12} />
<span> · </span>
</div>
</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("");
<motion.div
className="absolute inset-0 rounded-2xl"
whileHover={{
x: [0, -2, 2, -2, 2, 0],
transition: { duration: 0.4, repeat: Infinity },
}}
disabled={loading}
className="h-11 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-11 w-11 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>
</motion.button>
{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>
{/* Card B: Adventure Roulette */}
<motion.button
onClick={handleAdventureMode}
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-6 text-left shadow-lg shadow-purple-900/30 transition-shadow hover:shadow-xl hover:shadow-purple-500/30"
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
whileHover={{ scale: 1.02, rotate: 0.5 }}
whileTap={{ scale: 0.98 }}
>
<div className="absolute -right-6 -top-6 h-28 w-28 rounded-full bg-purple-500/20 blur-2xl transition-all group-hover:bg-purple-400/30 group-hover:blur-3xl" />
<div className="absolute -bottom-4 -left-4 h-20 w-20 rounded-full bg-indigo-400/15 blur-xl transition-all group-hover:bg-indigo-300/25" />
<AuthModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onAuth={(p) => setProfile(p)}
/>
<div className="relative z-10">
<div className="flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10">
<Gift size={22} className="text-purple-200" />
</div>
<div>
<h2 className="text-lg font-black text-white drop-shadow-[0_0_12px_rgba(192,132,252,0.5)]">
🎁
</h2>
<p className="text-[10px] font-semibold tracking-wider text-purple-300/70">
ADVENTURE ROULETTE
</p>
</div>
</div>
<p className="mt-4 text-sm font-medium leading-relaxed text-purple-100/90">
</p>
<div className="mt-3 flex items-center gap-1.5 text-xs font-bold text-purple-300/60">
<Gift size={12} />
<span> · </span>
</div>
</div>
</motion.button>
</div>
{/* Trophy Wall */}
{drawnHistory.length > 0 && (
<motion.div
className="mt-10 w-full max-w-sm"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.6 }}
>
<div className="mb-3 flex items-center gap-2">
<Trophy size={14} className="text-amber-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{drawnHistory.map((item, i) => (
<motion.div
key={item.id}
className="flex items-start gap-3 rounded-xl bg-surface/80 px-4 py-3 ring-1 ring-border"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 + i * 0.08 }}
>
<span className="mt-0.5 text-base">🏆</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
{item.content}
</p>
<p className="mt-0.5 text-[10px] text-dim">
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
weekday: "short",
})}
</p>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Footer */}
<motion.p
className="mt-auto pt-8 text-center text-[10px] text-dim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
NoWhatever "随便"
</motion.p>
</div>
);
}
+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>
);
}
+409
View File
@@ -0,0 +1,409 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion, AnimatePresence, useAnimation } from "framer-motion";
import { ArrowLeft, Send, Loader2, Package, Flame, Trophy } from "lucide-react";
import confetti from "canvas-confetti";
interface DrawnIdea {
id: string;
content: string;
createdAt: string;
}
type Phase = "pool" | "shaking" | "reveal";
export default function BlindBoxPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const roomId = params.id;
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [poolCount, setPoolCount] = useState(0);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [phase, setPhase] = useState<Phase>("pool");
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
const [submitFlash, setSubmitFlash] = useState(false);
const [error, setError] = useState("");
const boxControls = useAnimation();
const confettiCanvasRef = useRef<HTMLCanvasElement>(null);
const fetchData = useCallback(async () => {
try {
const res = await fetch(`/api/blindbox?roomId=${roomId}`);
const data = await res.json();
setPoolCount(data.poolCount ?? 0);
setDrawnHistory(data.drawn ?? []);
} catch {}
}, [roomId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSubmit = async () => {
const text = input.trim();
if (!text || submitting) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/blindbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId, content: text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
setInput("");
setPoolCount((c) => c + 1);
setSubmitFlash(true);
setTimeout(() => setSubmitFlash(false), 600);
boxControls.start({
scale: [1, 1.08, 1],
rotate: [0, -3, 3, 0],
transition: { duration: 0.5 },
});
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
};
const handleDraw = async () => {
if (poolCount === 0) {
setError("盒子是空的,先往里面塞点想法吧!");
return;
}
setPhase("shaking");
setError("");
await boxControls.start({
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
transition: { duration: 2.5, ease: "easeInOut" },
});
try {
const res = await fetch("/api/blindbox/draw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "抽取失败");
}
const idea = await res.json();
setRevealedIdea(idea);
setPhase("reveal");
setPoolCount((c) => Math.max(0, c - 1));
setDrawnHistory((prev) => [idea, ...prev]);
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "抽取失败");
setPhase("pool");
}
};
const fireConfetti = () => {
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
confetti({
particleCount: 100,
spread: 120,
origin: { y: 0.4 },
colors,
startVelocity: 45,
ticks: 250,
});
const end = Date.now() + 3000;
const frame = () => {
if (Date.now() > end) return;
confetti({
particleCount: 3,
angle: 60,
spread: 55,
origin: { x: 0, y: 0.6 },
colors,
startVelocity: 35,
ticks: 150,
});
confetti({
particleCount: 3,
angle: 120,
spread: 55,
origin: { x: 1, y: 0.6 },
colors,
startVelocity: 35,
ticks: 150,
});
requestAnimationFrame(frame);
};
setTimeout(frame, 200);
};
const resetToPool = () => {
setPhase("pool");
setRevealedIdea(null);
};
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
<canvas
ref={confettiCanvasRef}
className="pointer-events-none fixed inset-0 z-50"
/>
{/* Header */}
<div className="flex w-full max-w-sm items-center justify-between">
<button
onClick={() => router.push("/")}
className="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>
<div className="text-right">
<p className="text-xs font-bold text-purple-300/80"></p>
<p className="text-[10px] text-dim"> {roomId}</p>
</div>
</div>
{/* Blind Box Visual */}
<div className="mt-10 flex flex-col items-center">
<motion.div
animate={boxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
<motion.div
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 0.3 }}
>
<Package
size={40}
className="relative z-10 text-purple-300/60"
strokeWidth={1.5}
/>
</motion.div>
<motion.div
className="absolute -right-2 -top-2 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.div>
</div>
</motion.div>
<motion.p
className="mt-4 text-sm font-semibold text-muted"
key={poolCount}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
{" "}
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
</motion.p>
</div>
{/* Pool Phase: Input + Draw */}
<AnimatePresence mode="wait">
{phase === "pool" && (
<motion.div
key="pool"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex w-full gap-2">
<input
type="text"
placeholder="丢入一个疯狂的周末想法..."
value={input}
onChange={(e) => {
setInput(e.target.value);
setError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
maxLength={200}
disabled={submitting}
className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50"
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || submitting}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
>
{submitting ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={20} />
</motion.button>
{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>
)}
{phase === "shaking" && (
<motion.div
key="shaking"
className="mt-8 flex flex-col items-center gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<p className="text-sm font-bold text-purple-300 animate-pulse">
...
</p>
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="h-2 w-2 rounded-full bg-purple-400"
animate={{ y: [0, -8, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
}}
/>
))}
</div>
</motion.div>
)}
{phase === "reveal" && revealedIdea && (
<motion.div
key="reveal"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
>
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
<div className="relative z-10 text-center">
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
</p>
<motion.p
className="mt-4 text-xl font-black leading-relaxed text-white"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{revealedIdea.content}
</motion.p>
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
<p className="mt-3 text-[10px] font-medium text-purple-400/50">
</p>
</div>
</div>
<motion.button
onClick={resetToPool}
className="flex h-10 items-center gap-2 rounded-full bg-surface px-5 text-xs font-semibold text-muted ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.96 }}
>
</motion.button>
</motion.div>
)}
</AnimatePresence>
{/* History */}
{drawnHistory.length > 0 && phase !== "shaking" && (
<motion.div
className="mt-10 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<div className="mb-3 flex items-center gap-2">
<Trophy size={13} className="text-amber-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{drawnHistory.map((item, i) => (
<motion.div
key={item.id}
className="flex items-start gap-3 rounded-xl bg-surface/60 px-4 py-3 ring-1 ring-border/80"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
>
<span className="mt-0.5 text-sm">🏆</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-300">
{item.content}
</p>
<p className="mt-0.5 text-[10px] text-dim">
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
weekday: "short",
})}
</p>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
<div className="h-8 shrink-0" />
</div>
);
}