From 7d51f5200d07462c9cef61805406ff5616a37bab Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 11:27:10 +0800 Subject: [PATCH 01/66] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=91=A8?= =?UTF-8?q?=E6=9C=AB=E5=A5=91=E7=BA=A6=E7=9B=B2=E7=9B=92=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E9=A6=96=E9=A1=B5=E9=87=8D=E6=9E=84=E4=B8=BA=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BlindBoxIdea 数据模型及 migration - 新增盲盒 API (提交想法/查询/抽取) - 新增周末契约盲盒页面 (动效震动+彩带开奖) - 原首页功能拆分至 /panic 路由 - 首页重构为极速救场 + 周末契约双卡片入口 --- .../migration.sql | 47 ++ prisma/schema.prisma | 8 + src/app/api/blindbox/draw/route.ts | 39 + src/app/api/blindbox/route.ts | 50 ++ src/app/page.tsx | 750 ++++-------------- src/app/panic/page.tsx | 639 +++++++++++++++ src/app/room/[id]/blindbox/page.tsx | 409 ++++++++++ 7 files changed, 1361 insertions(+), 581 deletions(-) create mode 100644 prisma/migrations/20260226020724_add_blindbox_idea/migration.sql create mode 100644 src/app/api/blindbox/draw/route.ts create mode 100644 src/app/api/blindbox/route.ts create mode 100644 src/app/panic/page.tsx create mode 100644 src/app/room/[id]/blindbox/page.tsx 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", + })} +

    +
    +
    + ))} +
    + + )} + +
    +
    + ); +} From e10e3c823019c40f7273241bf7c44826bc39fc5a Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 11:27:18 +0800 Subject: [PATCH 02/66] =?UTF-8?q?ui:=20=E5=85=A8=E7=AB=99=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - globals.css 定义语义化 token (background/surface/elevated/border/muted/dim/accent) - 所有页面和组件迁移至暗色 token,移除硬编码 bg-white/text-zinc-*/bg-gray-* - RestaurantCard 和 MatchResult 适配暗色卡片风格 - 按钮颜色分层:系统CTA(accent)/模式强调(橙/紫)/危险(rose)/次级(surface) - 修复 room 页深色文字在深背景不可见的可访问性问题 --- src/app/globals.css | 23 +++++- src/app/invite/[id]/page.tsx | 62 +++++++-------- src/app/profile/page.tsx | 109 +++++++++++++-------------- src/app/room/[id]/page.tsx | 10 +-- src/components/ActionButtons.tsx | 4 +- src/components/AuthModal.tsx | 40 +++++----- src/components/LeaveConfirmModal.tsx | 16 ++-- src/components/MatchResult.tsx | 87 +++++++++++---------- src/components/QrInviteModal.tsx | 24 +++--- src/components/RestaurantCard.tsx | 28 +++---- src/components/RoomManageModal.tsx | 38 +++++----- src/components/SwipeDeck.tsx | 32 ++++---- src/components/TopNav.tsx | 18 ++--- 13 files changed, 258 insertions(+), 233 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 22f9ba3..0790fe0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,13 +1,27 @@ @import "tailwindcss"; :root { - --background: #f8f9fa; - --foreground: #171717; + --background: #030712; + --foreground: #f3f4f6; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + + --color-surface: #111827; + --color-elevated: #1f2937; + --color-inset: #0a0f1a; + + --color-border: #1f2937; + --color-subtle: #374151; + + --color-muted: #6b7280; + --color-dim: #4b5563; + + --color-accent: #10b981; + --color-accent-hover: #059669; + --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @@ -39,3 +53,8 @@ body { from { opacity: 1; } to { opacity: 0; } } + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} diff --git a/src/app/invite/[id]/page.tsx b/src/app/invite/[id]/page.tsx index 84ef7e8..cccc86b 100644 --- a/src/app/invite/[id]/page.tsx +++ b/src/app/invite/[id]/page.tsx @@ -62,7 +62,7 @@ export default function InvitePage() { if (status === "loading") { return (
    -
    +
    ); } @@ -70,16 +70,16 @@ export default function InvitePage() { if (status === "not_found") { return (
    -
    - +
    +
    -

    房间不存在

    -

    +

    房间不存在

    +

    这个房间已过期或不存在,请让朋友重新分享链接

    @@ -88,44 +88,44 @@ export default function InvitePage() { } return ( -
    +
    -
    +
    {scene === "drink" ? : }
    -

    +

    NoWhatever

    -

    +

    别说随便

    -

    +

    {sceneConfig.inviteText}

    -
    - +
    + {roomId}
    {userCount > 0 && ( -
    +
    - 已有 {userCount} 人在房间 + 已有 {userCount} 人在房间
    )} @@ -138,35 +138,35 @@ export default function InvitePage() { transition={{ duration: 0.5, delay: 0.2 }} >
    -
    - +
    +
    - 加入房间 - + 加入房间 + 和朋友一起
    - +
    -
    - +
    +
    - 各自滑卡 - + 各自滑卡 + 右滑喜欢的店
    - +
    -
    - +
    +
    - 匹配结果 - + 匹配结果 + 滑中同一家就去
    @@ -181,7 +181,7 @@ export default function InvitePage() { -

    个人中心

    +

    个人中心

    {/* Profile card */} @@ -290,7 +289,7 @@ export default function ProfilePage() { className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`} > {profile.avatar} - + @@ -306,34 +305,34 @@ export default function ProfilePage() { }} maxLength={16} autoFocus - className="h-8 flex-1 rounded-lg border border-zinc-200 px-2 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + className="h-8 flex-1 rounded-lg border-none bg-elevated px-2 text-sm text-white outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50" />
    ) : (
    -

    {profile.username}

    +

    {profile.username}

    )} - {usernameMsg &&

    {usernameMsg}

    } + {usernameMsg &&

    {usernameMsg}

    }
    @@ -354,8 +353,8 @@ export default function ProfilePage() { onClick={() => handleSaveAvatar(a.emoji)} className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${ profile.avatar === a.emoji - ? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1` - : "bg-zinc-50 hover:bg-zinc-100" + ? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface` + : "bg-elevated hover:bg-subtle" }`} > {a.emoji} @@ -369,7 +368,7 @@ export default function ProfilePage() { {/* Change password */} { setEditingPassword(!editingPassword); setPasswordMsg(""); }} className="flex w-full items-center gap-2" > - -

    修改密码

    + +

    修改密码

    @@ -393,46 +392,46 @@ export default function ProfilePage() { >
    -

    当前密码

    +

    当前密码

    { setCurrentPassword(e.target.value); setPasswordMsg(""); }} - className="h-9 w-full rounded-lg border border-zinc-200 px-3 pr-9 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + className="h-9 w-full rounded-lg border-none bg-elevated px-3 pr-9 text-sm text-white outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50" />
    -

    新密码

    +

    新密码

    { setNewPassword(e.target.value); setPasswordMsg(""); }} placeholder="至少 6 个字符" - className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-white outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50" />
    -

    确认新密码

    +

    确认新密码

    { setConfirmPassword(e.target.value); setPasswordMsg(""); }} placeholder="再次输入新密码" - className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-white outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50" />
    {passwordMsg && ( -

    +

    {passwordMsg}

    )} @@ -440,7 +439,7 @@ export default function ProfilePage() { @@ -452,15 +451,15 @@ export default function ProfilePage() { {/* Email binding */}
    - -

    绑定邮箱

    - (可选) + +

    绑定邮箱

    + (可选)
    {emailMsg && ( -

    +

    {emailMsg}

    )} @@ -490,7 +489,7 @@ export default function ProfilePage() { {/* Decision History */}
    - -

    + +

    决策记录 {history.length > 0 && `(${history.length})`}

    @@ -525,10 +524,10 @@ export default function ProfilePage() { > {historyLoading ? (
    - +
    ) : history.length === 0 ? ( -

    +

    还没有决策记录

    ) : ( @@ -539,7 +538,7 @@ export default function ProfilePage() { href={amapNavUrl(d.restaurantData)} target="_blank" rel="noopener noreferrer" - className="flex gap-3 rounded-xl bg-zinc-50 p-2.5 transition-colors active:bg-zinc-100" + className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle" > {firstImage(d.restaurantData) && ( )}
    -

    {d.restaurantName}

    -
    +

    {d.restaurantName}

    +
    {d.matchType === "unanimous" ? "全员一致" : "最佳匹配"} {d.participants} 人参与 {new Date(d.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })} @@ -568,7 +567,7 @@ export default function ProfilePage() { {/* Favorites */}
    - -

    + +

    收藏餐厅 {favorites.length > 0 && `(${favorites.length})`}

    @@ -603,10 +602,10 @@ export default function ProfilePage() { > {favLoading ? (
    - +
    ) : favorites.length === 0 ? ( -

    +

    还没有收藏的餐厅

    ) : ( @@ -616,7 +615,7 @@ export default function ProfilePage() { return (
    {firstImage(r) && ( )}
    -

    {r.name}

    -
    +

    {r.name}

    +
    {r.rating} @@ -644,7 +643,7 @@ export default function ProfilePage() {
    @@ -667,7 +666,7 @@ export default function ProfilePage() { > @@ -113,8 +113,8 @@ export default function RoomPage() { if (!ready) { return (
    -
    -

    正在加载数据...

    +
    +

    正在加载数据...

    ); } diff --git a/src/components/ActionButtons.tsx b/src/components/ActionButtons.tsx index 86f4238..29f600f 100644 --- a/src/components/ActionButtons.tsx +++ b/src/components/ActionButtons.tsx @@ -16,7 +16,7 @@ export default function ActionButtons({ return (
    onAction("left")} @@ -27,7 +27,7 @@ export default function ActionButtons({ onAction("right")} diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 1a233ba..3bcaf1b 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -117,7 +117,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { {open && (
    - 欢迎 + 欢迎
    {/* Tabs */} -
    +
    {(["login", "register"] as const).map((t) => ( @@ -208,7 +208,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { {/* Confirm password (register only) */} {tab === "register" && (
    -

    确认密码

    +

    确认密码

    )} @@ -225,9 +225,9 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { {/* Avatar picker (register only) */} {tab === "register" && (
    -

    +

    选择头像 - (可选) + (可选)

    {AVATARS.map((a) => ( @@ -236,8 +236,8 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { onClick={() => setAvatar(a.emoji)} className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${ avatar === a.emoji - ? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1` - : "bg-zinc-50 hover:bg-zinc-100" + ? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface` + : "bg-elevated hover:bg-subtle" }`} > {a.emoji} @@ -249,7 +249,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { {error && ( @@ -260,7 +260,7 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index bef706b..deb561d 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -58,7 +58,7 @@ function NoMatchResult({ return ( - + @@ -107,7 +107,7 @@ function NoMatchResult({ router.push("/")} - className="flex items-center justify-center gap-2 rounded-full bg-white/15 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/25" + className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-muted ring-1 ring-border transition-colors hover:bg-elevated" whileTap={{ scale: 0.95 }} > @@ -132,7 +132,7 @@ function RunnerUpCard({ href={buildNavUrl(restaurant)} target="_blank" rel="noopener noreferrer" - className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20" + className="flex gap-3 rounded-xl bg-surface/80 p-2.5 ring-1 ring-border/50 backdrop-blur-sm transition-colors hover:bg-elevated/80" > {restaurant.images?.[0] && ( {restaurant.name}

    -
    +
    {restaurant.rating} @@ -159,7 +159,7 @@ function RunnerUpCard({ )}
    -

    +

    {likes}/{userCount} 人想去

    @@ -282,25 +282,28 @@ export default function MatchResult({ return ( -
    + {/* Accent glow behind icon */} +
    + +
    {isUnanimous ? ( - + ) : ( - + )} @@ -314,7 +317,7 @@ export default function MatchResult({ - - + + 默契度 100% · {userCount} 人全员一致 - + )} + {/* Result card */}
    -

    +

    {restaurant.name}

    {restaurant.category && ( - + {restaurant.category} )}
    -
    +
    {restaurant.rating} - + {restaurant.price} {restaurant.distance && ( @@ -387,13 +391,13 @@ export default function MatchResult({
    {restaurant.address && ( -

    +

    {restaurant.address}

    )} {restaurant.openTime && ( -
    +
    {restaurant.openTime}
    @@ -407,7 +411,7 @@ export default function MatchResult({ .map((t) => ( {t.trim()} @@ -417,6 +421,7 @@ export default function MatchResult({
    + {/* Action buttons */} @@ -439,7 +442,7 @@ export default function MatchResult({ {restaurant.tel && ( @@ -449,7 +452,7 @@ export default function MatchResult({ @@ -457,6 +460,7 @@ export default function MatchResult({ + {/* Runner ups */} {!isUnanimous && runnerUpRestaurants.length > 0 && (
    -
    - +
    +

    邀请饭搭子

    -

    +

    {sceneConfig.qrSubtitle}

    -
    +
    - 房间号 - + 房间号 + {roomId}
    @@ -116,14 +116,14 @@ export default function QrInviteModal({
    - -

    房间管理

    + +

    房间管理

    -

    +

    房间号 {roomId}

    @@ -131,8 +131,8 @@ export default function RoomManageModal({ disabled={loading !== null} className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${ locked - ? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100" - : "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50" + ? "bg-accent/15 text-accent ring-1 ring-accent/30 active:bg-accent/25" + : "bg-elevated text-gray-300 ring-1 ring-border active:bg-subtle" }`} > {loading === "lock" || loading === "unlock" ? ( @@ -148,7 +148,7 @@ export default function RoomManageModal({ {/* User list with kick */}
    -

    +

    房间成员({users.length})

    @@ -164,7 +164,7 @@ export default function RoomManageModal({ return (
    {isCreator && ( - + 房主 )} - + {displayName}
    {swiped}/{totalCards} {finished ? " 已完成" : " 进行中"} @@ -211,7 +211,7 @@ export default function RoomManageModal({ @@ -219,7 +219,7 @@ export default function RoomManageModal({ ) : ( @@ -265,7 +265,7 @@ export default function RoomManageModal({
    -

    +

    NoWhatever - + 别说随便

    -
    +
    {locked && ( )} - + {roomId}
    - {userCount} + {userCount}
    +
    +

    {room.name}

    +

    房间 {room.code}

    +
    + + {/* Members */} +
    + {room.members.slice(0, 4).map((m) => ( +
    + {m.avatar} +
    + ))} + {room.members.length > 4 && ( +
    + +{room.members.length - 4} +
    + )} +
    + + +
    + + {/* Invite panel */} + + {showInvite && ( + +
    + 房间号 + + {room.code} + +
    + + +
    + + )} + + + {/* Non-member state */} + {!isMember ? ( + + +

    你还不是这个房间的成员

    + +
    + ) : ( + <> + {/* Blind Box Visual */} +
    + +
    +
    +
    +
    +
    + + + + + ✨ + +
    + + + + 盒子里已有{" "} + {poolCount}{" "} + 个想法 + +
    + + {/* Pool / Shaking / Reveal phases */} + + {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} + +
    + + {/* Attribution */} +
    + {revealedIdea.user && ( + + {revealedIdea.user.avatar} {revealedIdea.user.username} 投入 + + )} + {revealedIdea.drawnBy && ( + <> + · + + {revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中 + + + )} +
    + +

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

    +
    +
    + + { setPhase("pool"); setRevealedIdea(null); }} + 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 }} + > + 继续投入想法 + + + )} + + + {/* History */} + {drawnHistory.length > 0 && phase !== "shaking" && ( + +
    + +

    + 履约记录 +

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

    + {item.content} +

    +
    + {item.user && ( + {item.user.avatar} {item.user.username} 投入 + )} + {item.drawnBy && ( + <> + · + {item.drawnBy.avatar} {item.drawnBy.username} 抽中 + + )} + · + + {new Date(item.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + weekday: "short", + })} + +
    +
    +
    + ))} +
    + + )} + + )} + + {/* Toast */} + + {toast && ( + + {toast} + + )} + + +
    +
    + ); +} diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx new file mode 100644 index 0000000..824335f --- /dev/null +++ b/src/app/blindbox/page.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Package, + Plus, + LogIn, + Users, + Sparkles, + Loader2, + ChevronRight, +} from "lucide-react"; +import { getCachedProfile, isRegistered } from "@/lib/userId"; +import AuthModal from "@/components/AuthModal"; +import type { UserProfile } from "@/types"; + +interface RoomSummary { + id: string; + code: string; + name: string; + memberCount: number; + poolCount: number; + members: { id: string; username: string; avatar: string }[]; + lastDrawn: { content: string; createdAt: string } | null; +} + +export default function BlindboxLobbyPage() { + const router = useRouter(); + const [loggedIn, setLoggedIn] = useState(false); + const [profile, setProfile] = useState(null); + const [showAuth, setShowAuth] = useState(false); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + + const [createName, setCreateName] = useState(""); + const [creating, setCreating] = useState(false); + const [joinCode, setJoinCode] = useState(""); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const registered = isRegistered(); + setLoggedIn(registered); + if (registered) { + setProfile(getCachedProfile()); + } + }, []); + + const fetchRooms = useCallback(async () => { + const p = getCachedProfile(); + if (!p) return; + setLoading(true); + try { + const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`); + const data = await res.json(); + setRooms(data.rooms ?? []); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (loggedIn) fetchRooms(); + else setLoading(false); + }, [loggedIn, fetchRooms]); + + const handleAuth = (p: UserProfile) => { + setProfile(p); + setLoggedIn(true); + setShowAuth(false); + }; + + const handleCreate = async () => { + if (creating || !profile) return; + setCreating(true); + setError(""); + try { + const res = await fetch("/api/blindbox/room", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, name: createName.trim() || undefined }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + router.push(`/blindbox/${data.code}`); + } catch (e) { + setError(e instanceof Error ? e.message : "创建失败"); + } finally { + setCreating(false); + } + }; + + const handleJoin = async () => { + if (joining || !profile || !joinCode.trim()) return; + setJoining(true); + setError(""); + try { + const res = await fetch("/api/blindbox/room/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, code: joinCode.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + router.push(`/blindbox/${data.code}`); + } catch (e) { + setError(e instanceof Error ? e.message : "加入失败"); + } finally { + setJoining(false); + } + }; + + return ( +
    + {/* Ambient */} +
    +
    + + {/* Header */} +
    + +
    +

    🎁 周末契约

    +

    + ADVENTURE ROULETTE +

    +
    + {profile && ( +
    + {profile.avatar} + {profile.username} +
    + )} +
    + + + {!loggedIn ? ( + /* ============ Layer 1: Unauthenticated — Feature intro ============ */ + + {/* Hero icon */} + +
    +
    + +
    + + ✨ + + + +

    + 和 TA 一起,拆开周末 +

    +

    + 平日蓄水,周末开奖。把所有"想做但一直没做"的事, + 交给命运来决定。 +

    + + {/* Steps */} +
    + {[ + { step: "1", icon: Plus, text: "创建专属房间,邀请 TA 加入" }, + { step: "2", icon: Package, text: "平时随时塞入疯狂想法" }, + { step: "3", icon: Sparkles, text: "周末一起盲抽,绝不反悔" }, + ].map((s) => ( +
    +
    + {s.step} +
    +

    {s.text}

    +
    + ))} +
    + + {/* CTA */} + setShowAuth(true)} + className="mt-10 flex h-12 w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-bold text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.97 }} + > + + 登录 / 注册,开始你的第一个盲盒 + + +

    + 仅需用户名 + 密码,10 秒注册 +

    + + ) : loading ? ( + /* ============ Loading ============ */ + + +

    加载中...

    +
    + ) : rooms.length === 0 ? ( + /* ============ Layer 2: Logged in, no rooms — Create first ============ */ + + +
    + + + +

    还没有盲盒房间

    +

    + 创建第一个房间,邀请 TA 一起玩 +

    + + {/* Inline create form */} +
    +
    + { + setCreateName(e.target.value.slice(0, 30)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} + maxLength={30} + className="h-11 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" + /> + +
    + + {/* Join alternative */} +
    +
    + 或输入房间号加入 +
    +
    + +
    + { + setJoinCode(e.target.value.toUpperCase().slice(0, 6)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }} + maxLength={6} + className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600" + /> + +
    + + {error && ( + + {error} + + )} +
    + + ) : ( + /* ============ Layer 3: Logged in, has rooms — Room list ============ */ + + {/* Create row */} +
    + { + setCreateName(e.target.value.slice(0, 30)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} + maxLength={30} + className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600" + /> + +
    + + {/* Join row */} +
    + { + setJoinCode(e.target.value.toUpperCase().slice(0, 6)); + setError(""); + }} + onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }} + maxLength={6} + className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600" + /> + +
    + + {error && ( + + {error} + + )} + + {/* Room list */} +
    + {rooms.map((room, i) => ( + router.push(`/blindbox/${room.code}`)} + className="group flex w-full items-center gap-3 rounded-2xl bg-surface p-4 text-left ring-1 ring-border transition-all hover:bg-elevated hover:ring-purple-500/30" + initial={{ opacity: 0, x: -20 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.06 }} + whileTap={{ scale: 0.98 }} + > + {/* Icon */} +
    + +
    + + {/* Info */} +
    +

    {room.name}

    +
    + + + {room.memberCount} + + + + {room.poolCount} 待抽 + +
    + {room.lastDrawn && ( +

    + 最近抽中:{room.lastDrawn.content} +

    + )} +
    + + {/* Members preview */} +
    + {room.members.slice(0, 3).map((m) => ( +
    + {m.avatar} +
    + ))} + {room.memberCount > 3 && ( +
    + +{room.memberCount - 3} +
    + )} +
    + + +
    + ))} +
    +
    + )} + + + {/* Auth Modal */} + setShowAuth(false)} + onAuth={handleAuth} + defaultTab="register" + /> +
    + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d7076d..eff6e0b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,115 +1,87 @@ "use client"; -import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; -import { Zap, Gift, Clock, Trophy } from "lucide-react"; +import { Zap, Gift, Clock, ChevronRight } from "lucide-react"; import BrandLogo from "@/components/BrandLogo"; -function generateRoomCode() { - return Math.random().toString(36).substring(2, 8).toUpperCase(); -} - -interface DrawnIdea { - id: string; - content: string; - createdAt: string; -} - export default function LandingPage() { const router = useRouter(); - const [drawnHistory, setDrawnHistory] = useState([]); - const [blindboxRoom, setBlindboxRoom] = useState(""); - - useEffect(() => { - 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(() => {}); - } - }, []); - - const handlePanicMode = () => { - router.push("/panic"); - }; - - const handleAdventureMode = () => { - let room = blindboxRoom; - if (!room) { - room = generateRoomCode(); - localStorage.setItem("nw_blindbox_room", room); - setBlindboxRoom(room); - } - router.push(`/room/${room}/blindbox`); - }; return ( -
    +
    + {/* Ambient glow */} +
    +
    + {/* Header */} - -
    -

    + +
    +

    NoWhatever

    -

    +

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

    - 别再说"随便"了。两个模式,覆盖你们所有的选择困难症。 + 别再说"随便"了。 +
    + 两个模式,覆盖你们所有的选择困难症。
    {/* Dual Cards */} -
    +
    {/* Card A: Panic Mode */} router.push("/panic")} + className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-5 pb-4 text-left shadow-xl shadow-orange-500/25 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-orange-500/35" 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 }} + whileTap={{ scale: 0.97 }} > -
    -
    +
    +
    -
    +
    -
    +

    ⚡️ 极速救场

    -

    +

    PANIC MODE

    -

    +

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

    -
    - - 即时决策 · 转盘匹配 +
    +
    + + 即时决策 · 转盘匹配 +
    +
    + 进入 + +
    @@ -124,93 +96,56 @@ export default function LandingPage() { {/* Card B: Adventure Roulette */} router.push("/blindbox")} + className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-5 pb-4 text-left shadow-xl shadow-purple-600/20 ring-1 ring-purple-400/15 transition-shadow hover:shadow-2xl 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 }} + whileTap={{ scale: 0.97 }} > -
    -
    +
    +
    -
    +
    -
    +

    🎁 周末契约

    -

    +

    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 — 拒绝"随便",从今天开始 + NOWHATEVER — 拒绝随便,从今天开始
    ); diff --git a/src/app/room/[id]/blindbox/page.tsx b/src/app/room/[id]/blindbox/page.tsx deleted file mode 100644 index 3a4da1e..0000000 --- a/src/app/room/[id]/blindbox/page.tsx +++ /dev/null @@ -1,409 +0,0 @@ -"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", - })} -

    -
    -
    - ))} -
    - - )} - -
    -
    - ); -} diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 3bcaf1b..2201d2b 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -13,11 +13,12 @@ interface AuthModalProps { open: boolean; onClose: () => void; onAuth: (profile: UserProfile) => void; + defaultTab?: Tab; } -export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { +export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) { const backdropRef = useRef(null); - const [tab, setTab] = useState("login"); + const [tab, setTab] = useState(defaultTab); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); diff --git a/src/lib/blindbox.ts b/src/lib/blindbox.ts new file mode 100644 index 0000000..3105e85 --- /dev/null +++ b/src/lib/blindbox.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export function errorResponse(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +export function generateRoomCode(): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let code = ""; + for (let i = 0; i < 6; i++) { + code += chars[Math.floor(Math.random() * chars.length)]; + } + return code; +} + +export async function generateUniqueRoomCode(): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const code = generateRoomCode(); + const existing = await prisma.blindBoxRoom.findUnique({ where: { code } }); + if (!existing) return code; + } + throw new Error("无法生成唯一房间号"); +} + +export async function validateMembership(roomId: string, userId: string) { + const member = await prisma.blindBoxMember.findUnique({ + where: { roomId_userId: { roomId, userId } }, + }); + return !!member; +} + +export async function getRoomByCode(code: string) { + return prisma.blindBoxRoom.findUnique({ + where: { code }, + include: { + members: { + include: { user: { select: { id: true, username: true, avatar: true } } }, + }, + _count: { select: { ideas: { where: { status: "in_pool" } } } }, + }, + }); +} From 08eb55ca410e6c6fb8ffcfb6f48083da33e0532e Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 13:50:38 +0800 Subject: [PATCH 05/66] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E7=BB=93=E6=9E=9C=E5=8D=A1=E7=89=87=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=BD=A2=E6=88=90=E7=94=A8=E6=88=B7=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ShareCardModal 组件,支持餐厅匹配和盲盒契约两种分享卡片 - 卡片包含品牌标识、匹配结果、餐厅/想法详情、二维码 - 使用 html-to-image 生成高清 PNG,支持保存图片和 Web Share API 分享 - 餐厅图片通过 canvas 转 data URL 处理跨域 - 集成到 MatchResult(极速救场)和 BlindBox reveal(周末契约) --- package-lock.json | 7 + package.json | 1 + src/app/blindbox/[code]/page.tsx | 44 +- src/components/MatchResult.tsx | 63 +- src/components/ShareCardModal.tsx | 952 ++++++++++++++++++++++++++++++ 5 files changed, 1018 insertions(+), 49 deletions(-) create mode 100644 src/components/ShareCardModal.tsx diff --git a/package-lock.json b/package-lock.json index ec8835e..664b278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", "prisma": "^6.19.2", @@ -4260,6 +4261,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 8db0243..11715c8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", + "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", "prisma": "^6.19.2", diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 1076222..f0938aa 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; +import ShareCardModal from "@/components/ShareCardModal"; import type { UserProfile } from "@/types"; interface RoomInfo { @@ -57,6 +58,7 @@ export default function BlindboxRoomPage() { const [submitFlash, setSubmitFlash] = useState(false); const [error, setError] = useState(""); const [showInvite, setShowInvite] = useState(false); + const [showShareCard, setShowShareCard] = useState(false); const [toast, setToast] = useState(""); const boxControls = useAnimation(); @@ -522,13 +524,23 @@ export default function BlindboxRoomPage() {
    - { setPhase("pool"); setRevealedIdea(null); }} - 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 }} - > - 继续投入想法 - +
    + setShowShareCard(true)} + className="flex h-10 items-center gap-2 rounded-full bg-purple-600 px-5 text-xs font-bold text-white shadow-lg shadow-purple-900/30 transition-colors hover:bg-purple-500" + whileTap={{ scale: 0.96 }} + > + + 分享契约 + + { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }} + 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 }} + > + 继续投入想法 + +
    )} @@ -590,6 +602,24 @@ export default function BlindboxRoomPage() { )} + {revealedIdea && room && ( + setShowShareCard(false)} + data={{ + type: "blindbox", + idea: revealedIdea.content, + submitter: revealedIdea.user ?? undefined, + drawer: revealedIdea.drawnBy ?? undefined, + roomName: room.name, + }} + onToast={(msg) => { + setToast(msg); + setTimeout(() => setToast(""), 2200); + }} + /> + )} + {/* Toast */} {toast && ( diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index deb561d..2b0ccbe 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -23,6 +23,7 @@ import { import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types"; import { fireCelebration, playChime } from "@/lib/celebrate"; import { isRegistered } from "@/lib/userId"; +import ShareCardModal from "@/components/ShareCardModal"; interface MatchResultProps { restaurant: Restaurant; @@ -183,6 +184,7 @@ export default function MatchResult({ }: MatchResultProps) { const router = useRouter(); const [showRunnerUps, setShowRunnerUps] = useState(false); + const [showShareCard, setShowShareCard] = useState(false); const [toast, setToast] = useState(""); const celebratedRef = useRef(false); const historySavedRef = useRef(false); @@ -223,46 +225,9 @@ export default function MatchResult({ }).catch(() => {}); }, [userId, roomId, restaurant, matchType, userCount]); - const handleShare = useCallback(async () => { - const verb = scene === "drink" ? "喝" : "吃"; - const lines = [ - isUnanimous - ? `🎉 默契度 100%!${userCount} 人全员一致选了同一家!` - : `🎉 我们用 NoWhatever 选好了去哪${verb}!`, - ``, - `📍 ${restaurant.name}`, - restaurant.rating ? `⭐ ${restaurant.rating}` : "", - restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "", - restaurant.address ? `📮 ${restaurant.address}` : "", - ``, - isUnanimous ? `✨ 这就是心有灵犀吧~` : "", - ].filter(Boolean); - - const text = lines.join("\n"); - const navUrl = buildNavUrl(restaurant); - - const shareData = { - title: `我们选了${restaurant.name}!`, - text, - url: navUrl, - }; - - try { - if (navigator.share && navigator.canShare?.(shareData)) { - await navigator.share(shareData); - return; - } - } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; - } - - try { - await navigator.clipboard.writeText(`${text}\n\n${navUrl}`); - showToast("已复制,快去发给朋友吧!"); - } catch { - showToast("复制失败,请手动复制"); - } - }, [restaurant, showToast, isUnanimous, userCount, scene]); + const handleOpenShareCard = useCallback(() => { + setShowShareCard(true); + }, []); if (matchType === "no_match") { return ; @@ -451,12 +416,12 @@ export default function MatchResult({ )} - 分享结果到群里 + 生成分享卡片 @@ -557,6 +522,20 @@ export default function MatchResult({
    + setShowShareCard(false)} + data={{ + type: "restaurant", + restaurant, + matchType, + matchLikes, + userCount, + scene, + }} + onToast={showToast} + /> + {toast && ( void; + data: ShareCardData; + onToast?: (msg: string) => void; +} + +async function loadImageAsDataUrl(src: string): Promise { + try { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = src; + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(); + }); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0); + return canvas.toDataURL("image/jpeg", 0.85); + } catch { + return null; + } +} + +async function generateImage(el: HTMLElement): Promise { + return toPng(el, { + pixelRatio: 2, + quality: 0.95, + cacheBust: true, + skipAutoScale: true, + filter: (node) => { + if (node instanceof HTMLElement && node.dataset.shareExclude === "true") { + return false; + } + return true; + }, + }); +} + +function downloadDataUrl(dataUrl: string, filename: string) { + const link = document.createElement("a"); + link.download = filename; + link.href = dataUrl; + link.click(); +} + +function dataUrlToFile(dataUrl: string, filename: string): File { + const arr = dataUrl.split(","); + const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png"; + const bstr = atob(arr[1]); + const u8 = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i); + return new File([u8], filename, { type: mime }); +} + +function RestaurantShareCard({ + data, + cardRef, + imageDataUrl, +}: { + data: Extract; + cardRef: React.RefObject; + imageDataUrl: string | null; +}) { + const { restaurant, matchType, matchLikes, userCount, scene } = data; + const isUnanimous = matchType === "unanimous"; + const verb = scene === "drink" ? "喝" : "吃"; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + const accentFrom = isUnanimous ? "#059669" : "#b45309"; + const accentTo = isUnanimous ? "#34d399" : "#fbbf24"; + const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d"; + const accentBg = isUnanimous + ? "rgba(16, 185, 129, 0.12)" + : "rgba(245, 158, 11, 0.12)"; + + return ( +
    +
    + {/* Decorative glows */} +
    +
    + + {/* Brand header */} +
    +
    + +
    +
    + NoWhatever +
    +
    + 别说随便 · PANIC MODE +
    +
    +
    +
    + + {/* Thin accent line */} +
    + + {/* Hero section */} +
    +
    + {isUnanimous ? "🎉" : "🏆"} +
    +
    + 就去这{verb}! +
    +
    + {isUnanimous && ( + + )} + + {isUnanimous + ? `默契度 100% · ${userCount}人全员一致` + : `${matchLikes}/${userCount} 人选了这家`} + + {isUnanimous && ( + + )} +
    +
    + + {/* Restaurant card */} +
    +
    + {imageDataUrl && ( + {restaurant.name} + )} +
    +
    +
    + {restaurant.name} +
    + {restaurant.category && ( + + {restaurant.category} + + )} +
    + +
    + {restaurant.rating > 0 && ( + + + {restaurant.rating} + + )} + {restaurant.price && restaurant.price !== "未知" && ( + + {restaurant.price} + + )} + {restaurant.distance && ( + + + {restaurant.distance} + + )} +
    + + {restaurant.address && ( +
    + 📍 {restaurant.address} +
    + )} + + {restaurant.tag && ( +
    + {restaurant.tag + .split(",") + .slice(0, 4) + .map((t) => ( + + {t.trim()} + + ))} +
    + )} +
    +
    +
    + + {/* QR footer */} +
    +
    + +
    +
    +
    + 扫码一起「别说随便」 +
    +
    + {shareUrl.replace(/^https?:\/\//, "")} +
    +
    +
    +
    +
    + ); +} + +function BlindboxShareCard({ + data, + cardRef, +}: { + data: Extract; + cardRef: React.RefObject; +}) { + const { idea, submitter, drawer, roomName } = data; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + + return ( +
    +
    + {/* Decorative glows */} +
    +
    + + {/* Brand header */} +
    + 🎁 +
    +
    + NoWhatever +
    +
    + 别说随便 · ADVENTURE ROULETTE +
    +
    +
    + + {/* Thin accent line */} +
    + + {/* Room name badge */} +
    +
    + ✦ {roomName} ✦ +
    +
    + + {/* Idea card */} +
    +
    + {/* Corner decorations */} +
    +
    +
    +
    + +
    + {idea} +
    + +
    + +
    + 此契约一旦开启,绝不反悔 +
    +
    +
    + + {/* Attribution */} + {(submitter || drawer) && ( +
    + {submitter && ( +
    + {submitter.avatar} + {submitter.username} 投入 +
    + )} + {submitter && drawer && ( + · + )} + {drawer && ( +
    + {drawer.avatar} + {drawer.username} 抽中 +
    + )} +
    + )} + + {/* QR footer */} +
    +
    + +
    +
    +
    + 扫码一起「别说随便」 +
    +
    + {shareUrl.replace(/^https?:\/\//, "")} +
    +
    +
    +
    +
    + ); +} + +export default function ShareCardModal({ + open, + onClose, + data, + onToast, +}: ShareCardModalProps) { + const cardRef = useRef(null); + const backdropRef = useRef(null); + const [generating, setGenerating] = useState(false); + const [imageDataUrl, setImageDataUrl] = useState(null); + const [imageLoading, setImageLoading] = useState(false); + + useEffect(() => { + if (!open) { + setImageDataUrl(null); + return; + } + if (data.type !== "restaurant") return; + + const src = data.restaurant.images?.[0]; + if (!src) return; + + setImageLoading(true); + loadImageAsDataUrl(src) + .then(setImageDataUrl) + .finally(() => setImageLoading(false)); + }, [open, data]); + + const handleGenerate = useCallback(async (): Promise => { + if (!cardRef.current) return null; + setGenerating(true); + try { + return await generateImage(cardRef.current); + } catch { + try { + setImageDataUrl(null); + await new Promise((r) => setTimeout(r, 100)); + if (!cardRef.current) return null; + return await generateImage(cardRef.current); + } catch { + return null; + } + } finally { + setGenerating(false); + } + }, []); + + const handleSave = useCallback(async () => { + const png = await handleGenerate(); + if (!png) { + onToast?.("生成图片失败,请重试"); + return; + } + const name = + data.type === "restaurant" + ? `NoWhatever_${data.restaurant.name}.png` + : `NoWhatever_周末契约.png`; + downloadDataUrl(png, name); + onToast?.("图片已保存"); + }, [handleGenerate, onToast, data]); + + const handleShare = useCallback(async () => { + const png = await handleGenerate(); + if (!png) { + onToast?.("生成图片失败,请重试"); + return; + } + + const file = dataUrlToFile(png, "NoWhatever.png"); + + const shareData: ShareData = { files: [file] }; + try { + if (navigator.canShare?.(shareData)) { + await navigator.share(shareData); + return; + } + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return; + } + + downloadDataUrl(png, "NoWhatever.png"); + onToast?.("图片已保存,快去分享吧!"); + }, [handleGenerate, onToast]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === backdropRef.current) onClose(); + }; + + return ( + + {open && ( + + + {/* Close button (floating) */} + + + {/* Card */} +
    + {imageLoading ? ( +
    + +
    + ) : data.type === "restaurant" ? ( + + ) : ( + + )} +
    + + {/* Action buttons */} +
    + + +
    + +

    + 长按图片也可以保存到相册 +

    +
    +
    + )} +
    + ); +} From afde70c98ebdf4367f24759311aaa0586b6463eb Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 13:52:20 +0800 Subject: [PATCH 06/66] =?UTF-8?q?fix:=20=E5=88=86=E4=BA=AB=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=88=B0=E5=8D=A1=E7=89=87=E5=8F=B3=E4=B8=8A=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ShareCardModal.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index f56d85b..a6b1790 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -878,16 +878,14 @@ export default function ShareCardModal({ exit={{ opacity: 0, scale: 0.92 }} transition={{ duration: 0.25, ease: "easeOut" }} > - {/* Close button (floating) */} - - - {/* Card */} -
    + {/* Card + close button wrapper */} +
    + {imageLoading ? (
    Date: Thu, 26 Feb 2026 14:03:38 +0800 Subject: [PATCH 07/66] =?UTF-8?q?feat:=20=E5=8C=B9=E9=85=8D=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E9=A1=B5=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?=E2=80=94=20=E6=B5=AE=E5=8A=A8=E6=93=8D=E4=BD=9C=E6=A0=8F?= =?UTF-8?q?=E3=80=81=E6=94=B6=E8=97=8F=E3=80=81=E5=86=B3=E8=B5=9B=E5=BC=95?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 底部操作改为固定浮动栏,"再来一轮"和"换一批店"始终可见 - 结果卡片右上角新增收藏按钮,复用已有收藏 API - 非全员一致时"Top N 决赛"按钮上方增加引导文案 - 更新 ROADMAP 标记已完成项,移除低优先级条目 --- ROADMAP.md | 113 ++++++++++++++++++++++++++ src/components/MatchResult.tsx | 140 ++++++++++++++++++++------------- 2 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..4e19d46 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,113 @@ +# NoWhatever 产品优化路线图 + +> 基于当前产品形态的全面审视,按优先级排列。 +> 已完成项标记 ~~删除线~~。 + +--- + +## P0 — 核心闭环 + +### ~~分享结果卡片~~(已完成) +- ~~匹配成功 / 盲盒开奖后,一键生成品牌分享图~~ +- ~~支持保存图片、Web Share API 分享~~ +- ~~卡片包含餐厅/想法详情 + 二维码,形成增长飞轮~~ + +### 匹配成功页补全后续动作 +- ~~一键导航提升为首要 CTA~~(已完成,导航按钮已作为 accent 主按钮置顶) +- ~~加入一键打电话订位~~(已完成,`tel` 字段存在时展示拨号按钮) +- "不满意?再来一轮" 提升可见度:从底部灰色文字改为固定在页面底部的半透明浮动条,带 `RotateCcw` 图标 + 引导文案 +- 加入"收藏这家"快捷操作(复用已有收藏 API `/api/user/favorite`,用心形图标放在结果卡片右上角) +- 非全员一致时,在"Top N 决赛"按钮上方增加一句引导语("还有 X 家不相上下,再选一轮?"),降低用户理解成本 + +--- + +## P1 — 体验提升 + +### 单人也能用(Solo 模式) +- 只有一人创建房间时,等待页面体验空白 +- 加入 Solo 模式:一人时自动随机推荐 / 转盘选择 +- 降低使用门槛,不依赖必须拉人 + +### 盲盒想法互动 +- 对想法点赞 / 加权(增加被抽中概率) +- 想法分类标签(美食 / 旅行 / 运动 / 奇葩挑战) +- 抽中后打卡确认(拍照上传,形成回忆) +- "本周契约执行率" 统计 + +### PWA 支持 +- 添加 Web App Manifest,支持"添加到主屏幕" +- Service Worker 离线缓存基础页面 +- `viewport-fit=cover` 适配刘海屏 + +### 首次体验引导优化 +- 极速救场完成一轮后引导注册("注册保存记录") +- 盲盒模式先展示 demo / 动画,让用户看到价值再引导注册 +- 统一两个模式的登录体验(目前极速救场不需登录,盲盒必须登录) + +--- + +## P2 — 场景拓展 & 数据 + +### 更多极速救场场景 +- 当前只有"吃饭"和"喝酒"两个场景 +- 可扩展:看电影、去公园、玩什么游戏、周末去哪 +- 复用同一套滑卡机制,接入不同 POI 数据源 + +### 个人数据洞察 +- 你最常吃的菜系 Top 3 +- 你和 TA 的口味重合度 +- 月度决策次数趋势图 +- 在个人中心以简单可视化展示 + +### 首页社交证明 +- "已帮助 X 对情侣做出 Y 次决定"(全局计数器) +- 最近一次匹配的匿名动态("3分钟前,一对情侣在北京选中了 XXX") +- 提升首页说服力,推动新用户转化 + +### 盲盒开奖提醒 +- 周五下午推送"本周盲盒待抽 X 个想法" +- 浏览器 Notification API 提醒 +- 房间内"设定开奖日"功能,到时间提醒所有成员 + +--- + +## P3 — 长期留存 + +### 成就 & 激励系统 +- 决策次数徽章("已拯救 10 次选择困难症") +- 连续使用天数 +- 盲盒投放数量成就 +- 在个人中心展示,增加用户粘性 + +### 盲盒房间生命周期 +- 闲置 30 天自动归档 +- 支持删除 / 退出房间 +- 房间设置页(修改名称、管理成员、清空想法池) + +### 浅色模式 +- 当前暗色主题是唯一选项 +- 白天户外使用体验差 +- 跟随系统 / 手动切换 + +### 空状态插图优化 +- 个人中心"还没有决策记录""还没有收藏"用纯文字展示 +- 替换为插图 + CTA 按钮("去创建第一个房间") + +--- + +## 技术债务 + +### 安全 & 稳定性 +- [ ] API 接口加入 Rate Limiting +- [ ] 添加全局 Error Boundary +- [ ] 历史记录 / 收藏列表加分页 +- [ ] 餐厅图片加载失败时的 fallback 占位 + +### 性能优化 +- [ ] 餐厅图片使用 Next.js Image 组件优化 +- [ ] 加入 Loading Skeleton 替代纯 spinner +- [ ] 盲盒房间过期策略(避免僵尸房间堆积) + +### 监控 +- [ ] 接入基础数据埋点(页面 PV、功能使用率) +- [ ] 错误上报(Sentry 或类似) diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 2b0ccbe..e677175 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -19,6 +19,7 @@ import { RefreshCw, Share2, Zap, + Heart, } from "lucide-react"; import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types"; import { fireCelebration, playChime } from "@/lib/celebrate"; @@ -189,6 +190,8 @@ export default function MatchResult({ const celebratedRef = useRef(false); const historySavedRef = useRef(false); const isUnanimous = matchType === "unanimous"; + const [favorited, setFavorited] = useState(false); + const [favLoading, setFavLoading] = useState(false); const showToast = useCallback((msg: string) => { setToast(msg); @@ -229,6 +232,25 @@ export default function MatchResult({ setShowShareCard(true); }, []); + const handleFavorite = useCallback(async () => { + if (!isRegistered() || favorited || favLoading) return; + setFavLoading(true); + try { + const res = await fetch("/api/user/favorite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, restaurant }), + }); + if (res.ok) { + setFavorited(true); + showToast("已收藏"); + } + } catch { + /* ignore */ + } + setFavLoading(false); + }, [userId, restaurant, favorited, favLoading, showToast]); + if (matchType === "no_match") { return ; } @@ -247,7 +269,7 @@ export default function MatchResult({ return ( + {isRegistered() && ( + + + + )} {restaurant.images?.[0] && ( )} + + {canNarrow && ( +
    +

    + 还有 {runnerUpRestaurants.length} 家不相上下,再选一轮? +

    + onNarrow(narrowIds)} + disabled={resetting} + className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50" + whileTap={{ scale: 0.95 }} + > + + {resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`} + +
    + )}
    )} - - {/* Bottom actions */} - - {canNarrow ? ( - <> - onNarrow(narrowIds)} - disabled={resetting} - className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50" - whileTap={{ scale: 0.95 }} - > - - {resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`} - - router.push("/")} - className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${ - isUnanimous ? "text-emerald-400" : "text-amber-400" - }`} - > - - 换一批店 - - - ) : ( - <> - - - {resetting ? "重置中..." : "再来一轮"} - - router.push("/")} - className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${ - isUnanimous ? "text-emerald-400" : "text-amber-400" - }`} - > - - 换一批店 - - - )} -
    + {/* Floating bottom bar */} + +
    + 不满意? + + + {resetting ? "重置中..." : "再来一轮"} + + router.push("/")} + className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-subtle" + whileTap={{ scale: 0.95 }} + > + + 换一批店 + +
    +
    + setShowShareCard(false)} From f8a64b3281689eedb78afd199f0bb958ea417ae0 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:08:23 +0800 Subject: [PATCH 08/66] =?UTF-8?q?docs:=20=E9=87=8D=E5=86=99=20README?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=86=B3=E7=AD=96=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E4=B8=8E=E5=8F=8C=E6=A8=A1=E5=BC=8F=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E5=BD=A2=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 14bc3a6..3da2142 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,52 @@ # NoWhatever — 别说随便 -像 Tinder 一样滑卡片,和朋友一起决定去哪吃!解决聚餐时"随便都行"的纠结痛点,无需下载 App,用完即走。 +> 亲密关系决策引擎。别再说"随便"了,两个模式覆盖你们所有的选择困难症。 + +## 两大模式 + +### ⚡️ 极速救场 · Panic Mode + +10 秒内出结果,立刻闭嘴,听天由命。 + +- 基于 GPS 或手动选点,搜索附近餐厅 / 酒吧 +- Tinder 式滑卡投票 — 右滑想去、左滑跳过 +- 多人实时匹配 — 分享房间链接,所有人同时滑,自动算出最优解 +- 全员一致时触发庆祝特效,非全员一致可发起 Top N 决赛 +- 匹配结果支持一键导航、电话订位、收藏、生成分享卡片 + +### 🎁 周末契约 · Adventure Roulette + +丢入疯狂想法,周末盲盒开奖,绝不反悔。 + +- 创建专属房间,邀请 TA 用 6 位房间号加入 +- 平日随时向盲盒池投放想法(美食 / 旅行 / 运动 / 奇葩挑战) +- 周末一起盲抽,开奖结果不可反悔 +- 支持多个房间并行,房间成员共同管理想法池 + +## 通用能力 + +- **用户系统** — 用户名 + 密码注册,10 秒完成,头像自选 +- **分享卡片** — 匹配 / 开奖结果一键生成品牌分享图,支持保存 & Web Share API +- **个人中心** — 决策历史回顾、餐厅收藏管理 +- **多场景** — 吃饭 / 喝酒场景切换,复用同一套滑卡机制 ## Tech Stack -- **Next.js** (App Router) + **React** + **TypeScript** -- **Tailwind CSS** — Utility-first styling +- **Next.js 16** (App Router) + **React 19** + **TypeScript** +- **Prisma** + **SQLite** — 数据持久化 +- **Tailwind CSS v4** — Utility-first styling - **Framer Motion** — Physics-based swipe & drag animations +- **SWR** — 实时轮询 & 数据缓存 - **Lucide React** — Icon library +- **canvas-confetti** — 匹配庆祝特效 +- **html-to-image** + **qrcode.react** — 分享卡片 & 邀请二维码 ## Getting Started ```bash npm install +npx prisma generate +npx prisma db push npm run dev ``` @@ -23,16 +57,37 @@ Open [http://localhost:3000](http://localhost:3000) in your browser (best viewed ``` src/ ├── app/ -│ ├── globals.css # Global styles (mobile-first, no scroll) -│ ├── layout.tsx # Root layout with viewport meta -│ └── page.tsx # Main entry page -├── components/ -│ ├── TopNav.tsx # Navigation bar with room info -│ ├── RestaurantCard.tsx # Restaurant display card -│ ├── SwipeableCard.tsx # Framer Motion drag/swipe logic -│ ├── SwipeDeck.tsx # Card stack orchestrator -│ ├── ActionButtons.tsx # Nope / Like action buttons -│ └── MatchResult.tsx # Match celebration screen +│ ├── page.tsx # 首页 — 双模式入口 +│ ├── panic/page.tsx # 极速救场 — 定位 / 选点 / 创建房间 +│ ├── room/[id]/page.tsx # 滑卡房间 — 多人实时投票 +│ ├── invite/[id]/page.tsx # 邀请页 — 扫码 / 链接加入房间 +│ ├── blindbox/page.tsx # 周末契约大厅 — 房间列表 +│ ├── blindbox/[code]/page.tsx # 盲盒房间 — 想法投放 & 开奖 +│ ├── profile/page.tsx # 个人中心 — 历史 / 收藏 / 资料 +│ └── api/ # API Routes +│ ├── auth/ # 登录 / 注册 +│ ├── room/ # 房间 CRUD / 滑动 / 匹配 +│ ├── blindbox/ # 盲盒房间 / 想法 / 抽奖 +│ ├── user/ # 用户资料 / 历史 / 收藏 +│ └── location/ # 地理编码 / 地点建议 +├── components/ # UI 组件 +│ ├── SwipeDeck.tsx # 卡片堆栈编排器 +│ ├── SwipeableCard.tsx # 拖拽 / 滑动逻辑 +│ ├── RestaurantCard.tsx # 餐厅信息展示卡 +│ ├── MatchResult.tsx # 匹配成功庆祝页 +│ ├── ShareCardModal.tsx # 分享卡片生成弹窗 +│ ├── AuthModal.tsx # 登录 / 注册弹窗 +│ ├── TopNav.tsx # 顶部导航栏 +│ └── ... # 其他 UI 组件 +├── hooks/ +│ └── useRoomPolling.ts # 房间状态实时轮询 +├── lib/ # 工具函数 & 服务 +│ ├── prisma.ts # Prisma 客户端 +│ ├── buildRoomStatus.ts # 房间状态构建 & 匹配算法 +│ ├── sceneConfig.ts # 场景配置(吃饭 / 喝酒) +│ ├── celebrate.ts # 庆祝特效 & 音效 +│ ├── userId.ts # 用户 ID & 注册状态管理 +│ └── ... # 其他工具 └── types/ - └── index.ts # TypeScript type definitions + └── index.ts # TypeScript 类型定义 ``` From 4e60dc3cde6a2ca0118965293f0838c9de8081b6 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:15:14 +0800 Subject: [PATCH 09/66] =?UTF-8?q?fix:=20=E5=8D=95=E4=BA=BA=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=AD=89=E5=BE=85=20spinner=EF=BC=8C?= =?UTF-8?q?=E9=80=82=E9=85=8D=E7=BB=93=E6=9E=9C=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwipeDeck: userCount === 1 时不再显示"等待其他人完成选择" - MatchResult: 单人时标题改为"帮你选好了",副标题改为"你的首选" - MatchResult: 单人时隐藏"默契度 100%"徽章(无意义) - 更新 ROADMAP 将 Solo 模式改为单人体验修复 --- ROADMAP.md | 7 +++---- src/components/MatchResult.tsx | 13 ++++++++----- src/components/SwipeDeck.tsx | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4e19d46..7b8d538 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,10 +23,9 @@ ## P1 — 体验提升 -### 单人也能用(Solo 模式) -- 只有一人创建房间时,等待页面体验空白 -- 加入 Solo 模式:一人时自动随机推荐 / 转盘选择 -- 降低使用门槛,不依赖必须拉人 +### 单人等待体验修复 +- `userCount === 1` 时跳过"等待其他人完成选择"spinner,直接出结果 +- 匹配文案适配单人场景("就去这了"→"帮你选好了","X/X 人想去"→"你的首选") ### 盲盒想法互动 - 对想法点赞 / 加权(增加被抽中概率) diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index e677175..2d84eb3 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -189,6 +189,7 @@ export default function MatchResult({ const [toast, setToast] = useState(""); const celebratedRef = useRef(false); const historySavedRef = useRef(false); + const isSolo = userCount <= 1; const isUnanimous = matchType === "unanimous"; const [favorited, setFavorited] = useState(false); const [favLoading, setFavLoading] = useState(false); @@ -300,7 +301,7 @@ export default function MatchResult({ animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.35 }} > - 就去这了! + {isSolo ? "帮你选好了!" : "就去这了!"} - {isUnanimous - ? "大家一拍即合!" - : `${matchLikes}/${userCount} 人想去这家`} + {isSolo + ? "你的首选,别犹豫了" + : isUnanimous + ? "大家一拍即合!" + : `${matchLikes}/${userCount} 人想去这家`} - {isUnanimous && ( + {isUnanimous && !isSolo && ( r.id === resolvedMatchId) ?? null : null; - const showWaiting = allSwiped && !resolvedMatchId; + const showWaiting = allSwiped && !resolvedMatchId && userCount > 1; return ( <> From 26656f1e01d68c1cd47c6e66deb9a1df021ff27c Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:18:32 +0800 Subject: [PATCH 10/66] =?UTF-8?q?feat:=20=E5=8C=B9=E9=85=8D=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E9=A1=B5=E5=BC=95=E5=AF=BC=E6=9C=AA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B3=A8=E5=86=8C=EF=BC=8C=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未注册用户在匹配成功页看到注册引导卡片 - 注册后自动保存本次决策记录,收藏按钮同步出现 - 将 isRegistered() 调用改为 registered 响应式状态 - 更新 ROADMAP 标记已完成 --- ROADMAP.md | 2 +- src/components/MatchResult.tsx | 54 ++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7b8d538..3b2197d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,7 @@ - `viewport-fit=cover` 适配刘海屏 ### 首次体验引导优化 -- 极速救场完成一轮后引导注册("注册保存记录") +- ~~极速救场完成一轮后引导注册("注册保存记录")~~(已完成,匹配成功页展示注册卡片,注册后自动保存记录) - 盲盒模式先展示 demo / 动画,让用户看到价值再引导注册 - 统一两个模式的登录体验(目前极速救场不需登录,盲盒必须登录) diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 2d84eb3..4be47f0 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -20,11 +20,13 @@ import { Share2, Zap, Heart, + UserPlus, } from "lucide-react"; -import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types"; +import { Restaurant, MatchType, RunnerUp, SceneType, UserProfile } from "@/types"; import { fireCelebration, playChime } from "@/lib/celebrate"; import { isRegistered } from "@/lib/userId"; import ShareCardModal from "@/components/ShareCardModal"; +import AuthModal from "@/components/AuthModal"; interface MatchResultProps { restaurant: Restaurant; @@ -193,6 +195,8 @@ export default function MatchResult({ const isUnanimous = matchType === "unanimous"; const [favorited, setFavorited] = useState(false); const [favLoading, setFavLoading] = useState(false); + const [registered, setRegistered] = useState(() => isRegistered()); + const [showAuth, setShowAuth] = useState(false); const showToast = useCallback((msg: string) => { setToast(msg); @@ -212,7 +216,7 @@ export default function MatchResult({ useEffect(() => { if (historySavedRef.current) return; - if (!isRegistered()) return; + if (!registered) return; if (matchType === "no_match") return; historySavedRef.current = true; @@ -227,14 +231,20 @@ export default function MatchResult({ participants: userCount, }), }).catch(() => {}); - }, [userId, roomId, restaurant, matchType, userCount]); + }, [registered, userId, roomId, restaurant, matchType, userCount]); const handleOpenShareCard = useCallback(() => { setShowShareCard(true); }, []); + const handleAuth = useCallback((profile: UserProfile) => { + setRegistered(true); + setShowAuth(false); + showToast(`欢迎,${profile.username}!记录已保存`); + }, [showToast]); + const handleFavorite = useCallback(async () => { - if (!isRegistered() || favorited || favLoading) return; + if (!registered || favorited || favLoading) return; setFavLoading(true); try { const res = await fetch("/api/user/favorite", { @@ -250,7 +260,7 @@ export default function MatchResult({ /* ignore */ } setFavLoading(false); - }, [userId, restaurant, favorited, favLoading, showToast]); + }, [registered, userId, restaurant, favorited, favLoading, showToast]); if (matchType === "no_match") { return ; @@ -344,7 +354,7 @@ export default function MatchResult({ animate={{ y: 0, opacity: 1 }} transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }} > - {isRegistered() && ( + {registered && ( + {/* Registration nudge */} + {!registered && ( + +

    + 注册后,决策记录和收藏不会丢失 +

    +

    + 仅需用户名 + 密码,10 秒完成 +

    + setShowAuth(true)} + className="mt-3 flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-accent text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover" + whileTap={{ scale: 0.95 }} + > + + 注册保存记录 + +
    + )} + {/* Runner ups */} {!isUnanimous && runnerUpRestaurants.length > 0 && ( + setShowAuth(false)} + onAuth={handleAuth} + defaultTab="register" + /> + setShowShareCard(false)} From 1e7851fdb53ce2e9f4890c23bddf11e0fe50d69b Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:22:13 +0800 Subject: [PATCH 11/66] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 已登录:右上角显示头像 + 用户名,点击进入个人中心 - 未登录:右上角显示"登录"按钮,点击弹出注册/登录弹窗 --- src/app/page.tsx | 50 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index eff6e0b..b98f426 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,27 @@ "use client"; +import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; -import { Zap, Gift, Clock, ChevronRight } from "lucide-react"; +import { Zap, Gift, Clock, ChevronRight, User } from "lucide-react"; import BrandLogo from "@/components/BrandLogo"; +import { getCachedProfile } from "@/lib/userId"; +import AuthModal from "@/components/AuthModal"; +import type { UserProfile } from "@/types"; export default function LandingPage() { const router = useRouter(); + const [profile, setProfile] = useState(null); + const [showAuth, setShowAuth] = useState(false); + + useEffect(() => { + setProfile(getCachedProfile()); + }, []); + + const handleAuth = useCallback((p: UserProfile) => { + setProfile(p); + setShowAuth(false); + }, []); return (
    @@ -14,6 +29,32 @@ export default function LandingPage() {
    + {/* User indicator */} + + {profile ? ( + + ) : ( + + )} + + {/* Header */} NOWHATEVER — 拒绝随便,从今天开始 + + setShowAuth(false)} + onAuth={handleAuth} + defaultTab="register" + />
    ); } From d122ee7fb55a0a00e193575bf3fc7f4b0ff96630 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:23:33 +0800 Subject: [PATCH 12/66] =?UTF-8?q?ui:=20=E4=B8=AA=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E9=80=80=E5=87=BA=E6=8C=89=E9=92=AE=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶部导航栏右侧新增退出按钮,无需滚到页面底部 - 底部退出按钮从灰色文字链接改为带边框的圆角按钮 --- src/app/profile/page.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 93aefdb..c4afcde 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -273,7 +273,14 @@ export default function ProfilePage() { > -

    个人中心

    +

    个人中心

    +
    @@ -666,9 +673,9 @@ export default function ProfilePage() { > From f851eed847d143466bb34846ae344db03f9bb110 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 14:26:42 +0800 Subject: [PATCH 13/66] =?UTF-8?q?fix:=20=E5=8C=B9=E9=85=8D=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E9=A1=B5=E6=A0=87=E9=A2=98=E5=92=8C=E5=89=AF=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=B7=BB=E5=8A=A0=20text-center=20=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=B1=85=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MatchResult.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 4be47f0..54c882e 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -306,7 +306,7 @@ export default function MatchResult({ Date: Thu, 26 Feb 2026 14:42:40 +0800 Subject: [PATCH 14/66] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=80=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=A4=B4=E5=83=8F=E5=BE=BD=E7=AB=A0=EF=BC=8C=E6=89=80?= =?UTF-8?q?=E6=9C=89=E9=A1=B5=E9=9D=A2=E5=8F=B3=E4=B8=8A=E8=A7=92=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GlobalUserBadge 组件,固定在右上角,已登录显示头像+用户名,未登录显示登录按钮 - 通过 layout.tsx 全局挂载,仅在个人中心页隐藏 - userId.ts 登录/登出时派发 nowhatever_auth 事件,组件实时响应 - 移除各页面重复的用户指示器(首页、极速救场、周末契约大厅、个人中心顶栏退出按钮) - TopNav 右侧留出空间避免与全局徽章重叠 - 头像徽章采用暗色主题风格(bg-surface/80) --- src/app/blindbox/page.tsx | 16 ++++--- src/app/layout.tsx | 2 + src/app/page.tsx | 49 +------------------- src/app/panic/page.tsx | 40 ++-------------- src/app/profile/page.tsx | 7 --- src/components/GlobalUserBadge.tsx | 73 ++++++++++++++++++++++++++++++ src/components/MatchResult.tsx | 48 +++++++++++++++----- src/components/TopNav.tsx | 2 +- src/lib/userId.ts | 2 + 9 files changed, 129 insertions(+), 110 deletions(-) create mode 100644 src/components/GlobalUserBadge.tsx diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx index 824335f..5f171ea 100644 --- a/src/app/blindbox/page.tsx +++ b/src/app/blindbox/page.tsx @@ -49,6 +49,16 @@ export default function BlindboxLobbyPage() { } }, []); + useEffect(() => { + const handler = () => { + const registered = isRegistered(); + setLoggedIn(registered); + setProfile(registered ? getCachedProfile() : null); + }; + window.addEventListener("nowhatever_auth", handler); + return () => window.removeEventListener("nowhatever_auth", handler); + }, []); + const fetchRooms = useCallback(async () => { const p = getCachedProfile(); if (!p) return; @@ -135,12 +145,6 @@ export default function BlindboxLobbyPage() { ADVENTURE ROULETTE

    - {profile && ( -
    - {profile.avatar} - {profile.username} -
    - )}
    diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 216ae40..e6ad5ac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; +import GlobalUserBadge from "@/components/GlobalUserBadge"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,6 +29,7 @@ export default function RootLayout({ return ( + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index b98f426..52f5c60 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,27 +1,12 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; -import { Zap, Gift, Clock, ChevronRight, User } from "lucide-react"; +import { Zap, Gift, Clock, ChevronRight } from "lucide-react"; import BrandLogo from "@/components/BrandLogo"; -import { getCachedProfile } from "@/lib/userId"; -import AuthModal from "@/components/AuthModal"; -import type { UserProfile } from "@/types"; export default function LandingPage() { const router = useRouter(); - const [profile, setProfile] = useState(null); - const [showAuth, setShowAuth] = useState(false); - - useEffect(() => { - setProfile(getCachedProfile()); - }, []); - - const handleAuth = useCallback((p: UserProfile) => { - setProfile(p); - setShowAuth(false); - }, []); return (
    @@ -29,32 +14,6 @@ export default function LandingPage() {
    - {/* User indicator */} - - {profile ? ( - - ) : ( - - )} - - {/* Header */} - setShowAuth(false)} - onAuth={handleAuth} - defaultTab="register" - />
    ); } diff --git a/src/app/panic/page.tsx b/src/app/panic/page.tsx index d1d508f..2e1cd3e 100644 --- a/src/app/panic/page.tsx +++ b/src/app/panic/page.tsx @@ -3,12 +3,10 @@ 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 { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, ArrowLeft } from "lucide-react"; +import { getUserId, getCachedPreferences } from "@/lib/userId"; import { SCENES, getSceneConfig } from "@/lib/sceneConfig"; -import type { UserProfile, SceneType } from "@/types"; +import type { SceneType } from "@/types"; interface LocationSuggestion { id: string; @@ -90,13 +88,7 @@ export default function PanicPage() { 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); @@ -265,27 +257,6 @@ export default function PanicPage() { 返回 - {/* Profile / Auth button */} -
    - {profile ? ( - - ) : ( - - )} -
    - - setAuthModalOpen(false)} - onAuth={(p) => setProfile(p)} - />
    ); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index c4afcde..0debe0e 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -274,13 +274,6 @@ export default function ProfilePage() {

    个人中心

    -
    diff --git a/src/components/GlobalUserBadge.tsx b/src/components/GlobalUserBadge.tsx new file mode 100644 index 0000000..b514e94 --- /dev/null +++ b/src/components/GlobalUserBadge.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { motion } from "framer-motion"; +import { User } from "lucide-react"; +import { getCachedProfile } from "@/lib/userId"; +import AuthModal from "@/components/AuthModal"; +import type { UserProfile } from "@/types"; + +const HIDDEN_PREFIXES = ["/profile"]; + +export default function GlobalUserBadge() { + const router = useRouter(); + const pathname = usePathname(); + const [profile, setProfile] = useState(null); + const [showAuth, setShowAuth] = useState(false); + const hidden = HIDDEN_PREFIXES.some((p) => pathname.startsWith(p)); + + useEffect(() => { + setProfile(getCachedProfile()); + }, [pathname]); + + useEffect(() => { + const handler = () => setProfile(getCachedProfile()); + window.addEventListener("nowhatever_auth", handler); + return () => window.removeEventListener("nowhatever_auth", handler); + }, []); + + const handleAuth = useCallback((p: UserProfile) => { + setProfile(p); + setShowAuth(false); + }, []); + + if (hidden) return null; + + return ( + <> + + {profile ? ( + + ) : ( + + )} + + + setShowAuth(false)} + onAuth={handleAuth} + /> + + ); +} diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 54c882e..fb4c6c2 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -22,7 +22,13 @@ import { Heart, UserPlus, } from "lucide-react"; -import { Restaurant, MatchType, RunnerUp, SceneType, UserProfile } from "@/types"; +import { + Restaurant, + MatchType, + RunnerUp, + SceneType, + UserProfile, +} from "@/types"; import { fireCelebration, playChime } from "@/lib/celebrate"; import { isRegistered } from "@/lib/userId"; import ShareCardModal from "@/components/ShareCardModal"; @@ -237,11 +243,14 @@ export default function MatchResult({ setShowShareCard(true); }, []); - const handleAuth = useCallback((profile: UserProfile) => { - setRegistered(true); - setShowAuth(false); - showToast(`欢迎,${profile.username}!记录已保存`); - }, [showToast]); + const handleAuth = useCallback( + (profile: UserProfile) => { + setRegistered(true); + setShowAuth(false); + showToast(`欢迎,${profile.username}!记录已保存`); + }, + [showToast], + ); const handleFavorite = useCallback(async () => { if (!registered || favorited || favLoading) return; @@ -296,7 +305,12 @@ export default function MatchResult({ {isUnanimous ? ( @@ -311,7 +325,7 @@ export default function MatchResult({ animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.35 }} > - {isSolo ? "帮你选好了!" : "就去这了!"} + {isSolo ? "帮你选好了" : "就去这了"} {registered && ( )} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 29cd89f..63eed2e 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -47,7 +47,7 @@ export default function TopNav({ return ( <> -
    From 69dc78300e5edde40cbcc026d6b19ce2bb15f361 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 15:00:30 +0800 Subject: [PATCH 16/66] =?UTF-8?q?feat:=20=E7=9B=B2=E7=9B=92=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4=EF=BC=88=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E8=80=85=EF=BC=89=E5=92=8C=E9=80=80=E5=87=BA=EF=BC=88?= =?UTF-8?q?=E6=88=90=E5=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/blindbox/room/[code] 根据身份区分行为 - 房间页底部两步确认按钮,防止误操作 - 更新 ROADMAP:该功能从 P3 提升至 P1,移除低价值项 --- ROADMAP.md | 9 ++-- src/app/api/blindbox/room/[code]/route.ts | 33 ++++++++++++ src/app/blindbox/[code]/page.tsx | 65 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3b2197d..498edc5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,6 +38,10 @@ - Service Worker 离线缓存基础页面 - `viewport-fit=cover` 适配刘海屏 +### 盲盒房间删除 / 退出 +- 房间创建者可删除房间(级联清理成员 & 想法) +- 非创建者可退出房间(从成员列表移除自己) + ### 首次体验引导优化 - ~~极速救场完成一轮后引导注册("注册保存记录")~~(已完成,匹配成功页展示注册卡片,注册后自动保存记录) - 盲盒模式先展示 demo / 动画,让用户看到价值再引导注册 @@ -78,11 +82,6 @@ - 盲盒投放数量成就 - 在个人中心展示,增加用户粘性 -### 盲盒房间生命周期 -- 闲置 30 天自动归档 -- 支持删除 / 退出房间 -- 房间设置页(修改名称、管理成员、清空想法池) - ### 浅色模式 - 当前暗色主题是唯一选项 - 白天户外使用体验差 diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts index 8baa90b..498b199 100644 --- a/src/app/api/blindbox/room/[code]/route.ts +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; import { errorResponse, getRoomByCode } from "@/lib/blindbox"; export async function GET( @@ -28,3 +29,35 @@ export async function GET( return errorResponse("获取房间信息失败", 500); } } + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ code: string }> }, +) { + try { + const { code } = await params; + const { userId } = await req.json(); + + if (!userId) return errorResponse("缺少用户 ID", 400); + + const room = await prisma.blindBoxRoom.findUnique({ + where: { code: code.toUpperCase() }, + }); + if (!room) return errorResponse("房间不存在", 404); + + if (room.creatorId === userId) { + await prisma.blindBoxRoom.delete({ where: { id: room.id } }); + return NextResponse.json({ action: "deleted" }); + } + + const membership = await prisma.blindBoxMember.findUnique({ + where: { roomId_userId: { roomId: room.id, userId } }, + }); + if (!membership) return errorResponse("你不是该房间成员", 403); + + await prisma.blindBoxMember.delete({ where: { id: membership.id } }); + return NextResponse.json({ action: "left" }); + } catch { + return errorResponse("操作失败", 500); + } +} diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index f0938aa..fda5a94 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -14,6 +14,8 @@ import { Share2, LogIn, Copy, + Trash2, + LogOut, } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; @@ -60,6 +62,8 @@ export default function BlindboxRoomPage() { const [showInvite, setShowInvite] = useState(false); const [showShareCard, setShowShareCard] = useState(false); const [toast, setToast] = useState(""); + const [confirmLeave, setConfirmLeave] = useState(false); + const [leaving, setLeaving] = useState(false); const boxControls = useAnimation(); const inputRef = useRef(null); @@ -250,6 +254,36 @@ export default function BlindboxRoomPage() { handleCopyCode(); }; + const isCreator = profile?.id === room?.creatorId; + + const handleLeaveOrDelete = async () => { + if (!confirmLeave) { + setConfirmLeave(true); + setTimeout(() => setConfirmLeave(false), 3000); + return; + } + if (leaving || !profile || !room) return; + setLeaving(true); + try { + const res = await fetch(`/api/blindbox/room/${room.code}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "操作失败"); + } + router.replace("/blindbox"); + } catch (e) { + setToast(e instanceof Error ? e.message : "操作失败"); + setTimeout(() => setToast(""), 2200); + setConfirmLeave(false); + } finally { + setLeaving(false); + } + }; + if (pageLoading) { return (
    @@ -620,6 +654,37 @@ export default function BlindboxRoomPage() { /> )} + {/* Leave / Delete */} + {isMember && room && ( + + + + )} + {/* Toast */} {toast && ( From 12279117f3815f85290a4c9e9a10720b2c0c59dd Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 15:15:32 +0800 Subject: [PATCH 17/66] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=80=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=EF=BC=88=E6=B5=85=E8=89=B2/?= =?UTF-8?q?=E6=B7=B1=E8=89=B2/=E8=B7=9F=E9=9A=8F=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS 变量驱动的主题系统,所有颜色响应 data-theme 属性 - 新增语义化色彩 heading/secondary/tertiary,替换硬编码 text-white/text-gray-* - 右上角三态主题按钮(自动/浅色/深色),全局可用无需登录 - layout.tsx 内联脚本防闪烁 - 修复个人中心页面溢出无法滚动 --- src/app/blindbox/[code]/page.tsx | 10 +++--- src/app/blindbox/page.tsx | 18 +++++----- src/app/globals.css | 54 +++++++++++++++++++++------- src/app/invite/[id]/page.tsx | 12 +++---- src/app/layout.tsx | 7 +++- src/app/page.tsx | 6 ++-- src/app/panic/page.tsx | 8 ++--- src/app/profile/page.tsx | 28 +++++++-------- src/app/room/[id]/page.tsx | 2 +- src/components/AuthModal.tsx | 12 +++---- src/components/GlobalUserBadge.tsx | 32 ++++++++++++++--- src/components/LeaveConfirmModal.tsx | 4 +-- src/components/MatchResult.tsx | 24 ++++++------- src/components/QrInviteModal.tsx | 4 +-- src/components/RestaurantCard.tsx | 2 +- src/components/RoomManageModal.tsx | 10 +++--- src/components/TopNav.tsx | 8 ++--- src/lib/theme.ts | 37 +++++++++++++++++++ 18 files changed, 186 insertions(+), 92 deletions(-) create mode 100644 src/lib/theme.ts diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index fda5a94..cfc9ff0 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -307,7 +307,7 @@ export default function BlindboxRoomPage() {
    -

    {room.name}

    +

    {room.name}

    房间 {room.code}

    @@ -354,7 +354,7 @@ export default function BlindboxRoomPage() {
    @@ -377,7 +377,7 @@ export default function BlindboxRoomPage() { animate={{ opacity: 1, y: 0 }} > -

    你还不是这个房间的成员

    +

    你还不是这个房间的成员

    -

    🎁 周末契约

    +

    🎁 周末契约

    ADVENTURE ROULETTE

    @@ -176,10 +176,10 @@ export default function BlindboxLobbyPage() { -

    +

    和 TA 一起,拆开周末

    -

    +

    平日蓄水,周末开奖。把所有"想做但一直没做"的事, 交给命运来决定。

    @@ -195,7 +195,7 @@ export default function BlindboxLobbyPage() {
    {s.step}
    -

    {s.text}

    +

    {s.text}

    ))}
    @@ -244,8 +244,8 @@ export default function BlindboxLobbyPage() { -

    还没有盲盒房间

    -

    +

    还没有盲盒房间

    +

    创建第一个房间,邀请 TA 一起玩

    @@ -297,7 +297,7 @@ export default function BlindboxLobbyPage() {