diff --git a/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql b/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql new file mode 100644 index 0000000..b898939 --- /dev/null +++ b/prisma/migrations/20260226020724_add_blindbox_idea/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f3a86b3..3d95785 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) +} diff --git a/src/app/api/blindbox/draw/route.ts b/src/app/api/blindbox/draw/route.ts new file mode 100644 index 0000000..96e7b31 --- /dev/null +++ b/src/app/api/blindbox/draw/route.ts @@ -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 }); + } +} diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts new file mode 100644 index 0000000..72a0d42 --- /dev/null +++ b/src/app/api/blindbox/route.ts @@ -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 }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 095461a..3d7076d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 { - 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 { - 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([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const [selectedLocation, setSelectedLocation] = useState(null); - const [fetchingSuggestions, setFetchingSuggestions] = useState(false); - const [radius, setRadius] = useState(3000); - const [priceRange, setPriceRange] = useState("any"); - const [cuisine, setCuisine] = useState(""); - const suggestRef = useRef(null); - const debounceRef = useRef>(null); - - const [gpsStatus, setGpsStatus] = useState("idle"); - const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null); - const [gpsLocationName, setGpsLocationName] = useState(null); - - const [scene, setScene] = useState("eat"); - const sceneConfig = getSceneConfig(scene); - - const [profile, setProfile] = useState(null); - const [authModalOpen, setAuthModalOpen] = useState(false); + const [drawnHistory, setDrawnHistory] = useState([]); + 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 ( -
- {/* Profile / Auth button */} -
- {profile ? ( - - ) : ( - - )} -
- +
+ {/* Header */} - +
-

+

NoWhatever

-

- 别说随便 +

+ 别说随便 · 亲密关系决策引擎

- {sceneConfig.subtitle} + 别再说"随便"了。两个模式,覆盖你们所有的选择困难症。 - -
-
- -
- 创建房间 -
- - - -
-
- -
- 各自滑卡 -
- - - -
-
- -
- 匹配结果 -
-
- - - {SCENES.map((s) => { - const cfg = getSceneConfig(s); - const active = scene === s; - return ( - - ); - })} - - - -
-
- - 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 && ( - - )} - {fetchingSuggestions && ( - - )} -
- - {selectedLocation && ( -
- - - {selectedLocation.district} {selectedLocation.address || selectedLocation.name} - -
- )} - - {!selectedLocation && !locationQuery && gpsStatus === "locating" && ( -
- - 正在获取当前位置... -
- )} - - {!selectedLocation && !locationQuery && gpsStatus === "success" && ( -
- - - 当前位置:{gpsLocationName || "已定位"} - -
- )} - - {!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && ( -
-
- - - {gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置 - -
- -
- )} - - {!selectedLocation && !locationQuery && gpsStatus === "idle" && ( -
- - 将使用当前定位 -
- )} - - - {showSuggestions && ( - - {suggestions.map((s) => ( -
  • - -
  • - ))} -
    - )} -
    -
    - -
    -
    - {sceneConfig.tagLabel} -
    - 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 && ( - - )} -
    -
    - -
    - -
    - - {sceneConfig.hotTags.map((tag) => ( - - ))} -
    -
    - -
    - 距离 -
    - {DISTANCE_OPTIONS.map((opt) => ( - - ))} -
    -
    - -
    - 人均 -
    - {sceneConfig.priceOptions.map((opt) => ( - - ))} -
    -
    - -
    - - +
    +
    -
    -
    - 或加入已有房间 -
    -
    +
    +
    +
    + +
    +
    +

    ⚡️ 极速救场

    +

    + PANIC MODE +

    +
    +
    +

    + 10秒内出结果,立刻闭嘴,听天由命 +

    +
    + + 即时决策 · 转盘匹配 +
    +
    -
    - { - setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4)); - setError(""); + - - + - {error && ( - - {error} - - )} -
    + {/* Card B: Adventure Roulette */} + +
    +
    - setAuthModalOpen(false)} - onAuth={(p) => setProfile(p)} - /> +
    +
    +
    + +
    +
    +

    + 🎁 周末契约 +

    +

    + ADVENTURE ROULETTE +

    +
    +
    +

    + 丢入疯狂想法,周末盲盒开奖,绝不反悔 +

    +
    + + 盲盒蓄水 · 仪式开奖 +
    +
    + +
    + + {/* Trophy Wall */} + {drawnHistory.length > 0 && ( + +
    + +

    + 契约画廊 +

    +
    +
    +
    + {drawnHistory.map((item, i) => ( + + 🏆 +
    +

    + {item.content} +

    +

    + {new Date(item.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + weekday: "short", + })} +

    +
    +
    + ))} +
    + + )} + + {/* Footer */} + + NoWhatever — 拒绝"随便",从今天开始 +
    ); } diff --git a/src/app/panic/page.tsx b/src/app/panic/page.tsx new file mode 100644 index 0000000..d1d508f --- /dev/null +++ b/src/app/panic/page.tsx @@ -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 { + 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 { + 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([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(null); + const [fetchingSuggestions, setFetchingSuggestions] = useState(false); + const [radius, setRadius] = useState(3000); + const [priceRange, setPriceRange] = useState("any"); + const [cuisine, setCuisine] = useState(""); + const suggestRef = useRef(null); + const debounceRef = useRef>(null); + + const [gpsStatus, setGpsStatus] = useState("idle"); + const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null); + const [gpsLocationName, setGpsLocationName] = useState(null); + + const [scene, setScene] = useState("eat"); + const sceneConfig = getSceneConfig(scene); + + const [profile, setProfile] = useState(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 ( +
    + {/* Back button */} + + + {/* Profile / Auth button */} +
    + {profile ? ( + + ) : ( + + )} +
    + + +
    + +
    +
    +

    + ⚡ 极速救场 +

    +

    + 10秒内出结果 +

    +
    +
    + + + {sceneConfig.subtitle} + + + +
    +
    + +
    + 创建房间 +
    + + + +
    +
    + +
    + 各自滑卡 +
    + + + +
    +
    + +
    + 匹配结果 +
    +
    + + + {SCENES.map((s) => { + const cfg = getSceneConfig(s); + const active = scene === s; + return ( + + ); + })} + + + +
    +
    + + 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 && ( + + )} + {fetchingSuggestions && ( + + )} +
    + + {selectedLocation && ( +
    + + + {selectedLocation.district} {selectedLocation.address || selectedLocation.name} + +
    + )} + + {!selectedLocation && !locationQuery && gpsStatus === "locating" && ( +
    + + 正在获取当前位置... +
    + )} + + {!selectedLocation && !locationQuery && gpsStatus === "success" && ( +
    + + + 当前位置:{gpsLocationName || "已定位"} + +
    + )} + + {!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && ( +
    +
    + + + {gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置 + +
    + +
    + )} + + {!selectedLocation && !locationQuery && gpsStatus === "idle" && ( +
    + + 将使用当前定位 +
    + )} + + + {showSuggestions && ( + + {suggestions.map((s) => ( +
  • + +
  • + ))} +
    + )} +
    +
    + +
    +
    + {sceneConfig.tagLabel} +
    + 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 && ( + + )} +
    +
    + +
    + +
    + + {sceneConfig.hotTags.map((tag) => ( + + ))} +
    +
    + +
    + 距离 +
    + {DISTANCE_OPTIONS.map((opt) => ( + + ))} +
    +
    + +
    + 人均 +
    + {sceneConfig.priceOptions.map((opt) => ( + + ))} +
    +
    + +
    + + + +
    +
    + 或加入已有房间 +
    +
    + +
    + { + 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" + /> + +
    + + {error && ( + + {error} + + )} + + + setAuthModalOpen(false)} + onAuth={(p) => setProfile(p)} + /> +
    + ); +} diff --git a/src/app/room/[id]/blindbox/page.tsx b/src/app/room/[id]/blindbox/page.tsx new file mode 100644 index 0000000..3a4da1e --- /dev/null +++ b/src/app/room/[id]/blindbox/page.tsx @@ -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([]); + const [phase, setPhase] = useState("pool"); + const [revealedIdea, setRevealedIdea] = useState(null); + const [submitFlash, setSubmitFlash] = useState(false); + const [error, setError] = useState(""); + const boxControls = useAnimation(); + const confettiCanvasRef = useRef(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 ( +
    + + + {/* Header */} +
    + +
    +

    周末契约

    +

    房间 {roomId}

    +
    +
    + + {/* Blind Box Visual */} +
    + +
    + +
    +
    +
    +
    + + + + + + + ✨ + +
    + + + + 盒子里已有{" "} + {poolCount}{" "} + 个想法 + +
    + + {/* Pool Phase: Input + Draw */} + + {phase === "pool" && ( + +
    + { + 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" + /> + +
    + + +
    + + 开启周末盲盒(绝不反悔) + + + {error && ( + + {error} + + )} + + )} + + {phase === "shaking" && ( + +

    + 命运正在决定... +

    +
    + {[0, 1, 2].map((i) => ( + + ))} +
    +
    + )} + + {phase === "reveal" && revealedIdea && ( + +
    +
    +
    +
    +
    + +
    +

    + ✦ 周末契约 ✦ +

    + + {revealedIdea.content} + +
    +

    + 此契约一旦开启,绝不反悔 +

    +
    +
    + + + 继续投入想法 + + + )} + + + {/* History */} + {drawnHistory.length > 0 && phase !== "shaking" && ( + +
    + +

    + 履约记录 +

    +
    +
    +
    + {drawnHistory.map((item, i) => ( + + 🏆 +
    +

    + {item.content} +

    +

    + {new Date(item.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + weekday: "short", + })} +

    +
    +
    + ))} +
    + + )} + +
    +
    + ); +}