feat: 新增周末契约盲盒功能,首页重构为双模式入口
- 新增 BlindBoxIdea 数据模型及 migration - 新增盲盒 API (提交想法/查询/抽取) - 新增周末契约盲盒页面 (动效震动+彩带开奖) - 原首页功能拆分至 /panic 路由 - 首页重构为极速救场 + 周末契约双卡片入口
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user