diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index b44f989..e962a4a 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -19,17 +19,11 @@ import ContractHistoryItem from "@/components/ContractHistoryItem"; import EmptyState from "@/components/EmptyState"; import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton"; import { buildNavUrl } from "@/lib/navigation"; -import type { DecisionRecord, ContractRecord, Restaurant } from "@/types"; +import { useAchievements } from "@/hooks/useAchievements"; +import type { Restaurant } from "@/types"; type Tab = "decisions" | "contracts"; -interface Stats { - totalDecisions: number; - totalContracts: number; - completedContracts: number; - completionRate: number; -} - function firstImage(r: Restaurant): string { if (r.images?.length > 0) return r.images[0]; const legacy = (r as unknown as Record).image; @@ -38,16 +32,8 @@ function firstImage(r: Restaurant): string { export default function AchievementsPage() { const router = useRouter(); - const [loading, setLoading] = useState(true); const [tab, setTab] = useState("decisions"); - const [stats, setStats] = useState({ - totalDecisions: 0, - totalContracts: 0, - completedContracts: 0, - completionRate: 0, - }); - const [decisions, setDecisions] = useState([]); - const [contracts, setContracts] = useState([]); + const [userId, setUserId] = useState(undefined); useEffect(() => { if (!isRegistered()) { @@ -55,21 +41,11 @@ export default function AchievementsPage() { return; } const p = getCachedProfile(); - if (!p) return; - - (async () => { - try { - const res = await fetch(`/api/user/achievements?userId=${p.id}`); - if (!res.ok) return; - const data = await res.json(); - setStats(data.stats); - setDecisions(data.decisions); - setContracts(data.contracts); - } catch (e) { console.error("AchievementsPage: fetch failed:", e); } - finally { setLoading(false); } - })(); + if (p) setUserId(p.id); }, [router]); + const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId); + const statCards = [ { label: "决策记录", diff --git a/src/app/api/blindbox/plan/suggest-item/route.ts b/src/app/api/blindbox/plan/suggest-item/route.ts index c5086e3..181c069 100644 --- a/src/app/api/blindbox/plan/suggest-item/route.ts +++ b/src/app/api/blindbox/plan/suggest-item/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { apiHandler, ApiError } from "@/lib/api"; import { suggestAlternativeItems } from "@/lib/ai"; -import { searchPois } from "@/lib/blindboxPlanGen"; +import { searchPois } from "@/lib/amap"; export const POST = apiHandler(async (req) => { const { activity, time, location } = await req.json(); diff --git a/src/app/api/debug/transit/route.ts b/src/app/api/debug/transit/route.ts index 3bd6622..3cb3320 100644 --- a/src/app/api/debug/transit/route.ts +++ b/src/app/api/debug/transit/route.ts @@ -4,7 +4,7 @@ * Only available in development. */ import { NextRequest, NextResponse } from "next/server"; -import { requireAmapApiKey } from "@/lib/amap"; +import { getTransitDirection } from "@/lib/amap"; export async function GET(req: NextRequest) { if (process.env.NODE_ENV !== "development") { @@ -22,69 +22,17 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 }); } - const apiKey = requireAmapApiKey(); - const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated"); - url.searchParams.set("key", apiKey); - url.searchParams.set("origin", `${oLng},${oLat}`); - url.searchParams.set("destination", `${dLng},${dLat}`); - url.searchParams.set("city", city); - url.searchParams.set("cityd", city); - url.searchParams.set("output", "json"); - - const res = await fetch(url.toString()); - const raw = await res.json(); - - if (raw.status !== "1" || !raw.route?.transits?.length) { - return NextResponse.json({ error: "未找到路线", raw }); - } - - const transit = raw.route.transits[0]; - const durationMin = Math.ceil(Number(transit.duration) / 60); - const distanceKm = Math.round(Number(raw.route.distance) / 100) / 10; - - // Parse segments the same way as production code - const parts: string[] = []; - const segmentDebug: unknown[] = []; - for (const seg of (transit.segments ?? []) as Record[]) { - const bus = seg.bus as { buslines?: Record[] } | undefined; - segmentDebug.push({ - hasWalking: !!seg.walking, - hasBus: !!seg.bus, - buslines: bus?.buslines?.map((l) => ({ - name: l.name, - type: l.type, - via_num: l.via_num, - departure: (l.departure_stop as Record | undefined)?.name, - arrival: (l.arrival_stop as Record | undefined)?.name, - })), - }); - if (!bus?.buslines?.length) continue; - for (const line of bus.buslines) { - const name = String(line.name ?? ""); - const viaNum = Number(line.via_num ?? 0); - parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name); - } - } - - return NextResponse.json({ - parsed: { - durationMin, - distanceKm, - description: parts.join(" → ") || "步行", - }, - segmentDebug, - rawTransit: { - duration: transit.duration, - nightflag: transit.nightflag, - segmentCount: transit.segments?.length, - }, - // First 3 routes for comparison - allRoutes: raw.route.transits.slice(0, 3).map((t: Record) => ({ - durationMin: Math.ceil(Number(t.duration) / 60), - segments: (t.segments as Record[] ?? []).map((s) => { - const b = s.bus as { buslines?: Record[] } | undefined; - return b?.buslines?.map((l) => `${l.name}(${l.via_num}站)`) ?? ["步行"]; - }), - })), + const result = await getTransitDirection({ + originLat: oLat, + originLng: oLng, + destLat: dLat, + destLng: dLng, + city, }); + + if (!result) { + return NextResponse.json({ error: "未找到路线" }); + } + + return NextResponse.json({ parsed: result }); } diff --git a/src/app/api/location/regeo/route.test.ts b/src/app/api/location/regeo/route.test.ts index 0d1ef03..cc09de5 100644 --- a/src/app/api/location/regeo/route.test.ts +++ b/src/app/api/location/regeo/route.test.ts @@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockReverseGeocode = vi.fn(); vi.mock("@/lib/amap", () => ({ - requireAmapApiKey: vi.fn().mockReturnValue("test-key"), + reverseGeocode: (...args: unknown[]) => mockReverseGeocode(...args), })); -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - import { GET } from "./route"; const mockCtx = { params: Promise.resolve({}) }; @@ -20,19 +18,9 @@ beforeEach(() => { describe("GET /api/location/regeo", () => { it("returns reverse geocoded location", async () => { - mockFetch.mockResolvedValue({ - json: () => - Promise.resolve({ - status: "1", - regeocode: { - formatted_address: "上海市黄浦区人民大道", - addressComponent: { - district: "黄浦区", - township: "南京东路街道", - neighborhood: { name: "人民广场" }, - }, - }, - }), + mockReverseGeocode.mockResolvedValue({ + name: "黄浦区 南京东路街道 人民广场", + formatted: "上海市黄浦区人民大道", }); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); @@ -45,9 +33,7 @@ describe("GET /api/location/regeo", () => { }); it("returns null name when API returns no result", async () => { - mockFetch.mockResolvedValue({ - json: () => Promise.resolve({ status: "0" }), - }); + mockReverseGeocode.mockResolvedValue({ name: null, formatted: null }); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); const res = await GET(req, mockCtx); @@ -62,7 +48,8 @@ describe("GET /api/location/regeo", () => { }); it("returns 503 when API unavailable", async () => { - mockFetch.mockRejectedValue(new Error("network")); + const { ApiError } = await import("@/lib/api"); + mockReverseGeocode.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503)); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); const res = await GET(req, mockCtx); diff --git a/src/app/api/location/regeo/route.ts b/src/app/api/location/regeo/route.ts index debc5b5..7d6e1d5 100644 --- a/src/app/api/location/regeo/route.ts +++ b/src/app/api/location/regeo/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { apiHandler, ApiError } from "@/lib/api"; -import { requireAmapApiKey } from "@/lib/amap"; +import { reverseGeocode } from "@/lib/amap"; export const GET = apiHandler(async (req) => { const lat = req.nextUrl.searchParams.get("lat"); @@ -8,37 +8,6 @@ export const GET = apiHandler(async (req) => { if (!lat || !lng) throw new ApiError("lat and lng are required"); - const apiKey = requireAmapApiKey(); - - const url = new URL("https://restapi.amap.com/v3/geocode/regeo"); - url.searchParams.set("key", apiKey); - url.searchParams.set("location", `${lng},${lat}`); - url.searchParams.set("extensions", "base"); - - let data; - try { - const res = await fetch(url.toString()); - data = await res.json(); - } catch { - throw new ApiError("位置服务暂时不可用,请稍后重试", 503); - } - - if (data.status !== "1" || !data.regeocode) { - return NextResponse.json({ name: null }); - } - - const comp = data.regeocode.addressComponent; - const district = comp?.district || comp?.city || ""; - const township = comp?.township || ""; - const neighborhood = comp?.neighborhood?.name || ""; - - const name = [district, township, neighborhood] - .filter(Boolean) - .join(" ") - .trim(); - - return NextResponse.json({ - name: name || data.regeocode.formatted_address || null, - formatted: data.regeocode.formatted_address || null, - }); + const result = await reverseGeocode({ lat: Number(lat), lng: Number(lng) }); + return NextResponse.json(result); }); diff --git a/src/app/api/location/search/route.test.ts b/src/app/api/location/search/route.test.ts index 73dd897..baecf6d 100644 --- a/src/app/api/location/search/route.test.ts +++ b/src/app/api/location/search/route.test.ts @@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockSearchPlaceText = vi.fn(); vi.mock("@/lib/amap", () => ({ - requireAmapApiKey: vi.fn().mockReturnValue("test-key"), + searchPlaceText: (...args: unknown[]) => mockSearchPlaceText(...args), })); -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - import { GET } from "./route"; const mockCtx = { params: Promise.resolve({}) }; @@ -20,21 +18,17 @@ beforeEach(() => { describe("GET /api/location/search", () => { it("returns search results", async () => { - mockFetch.mockResolvedValue({ - json: () => - Promise.resolve({ - status: "1", - pois: [ - { - id: "poi-1", - name: "星巴克", - address: "南京路1号", - location: "121.4,31.2", - business: { rating: "4.5", cost: "40" }, - }, - ], - }), - }); + mockSearchPlaceText.mockResolvedValue([ + { + id: "poi-1", + name: "星巴克", + address: "南京路1号", + lat: 31.2, + lng: 121.4, + rating: 4.5, + cost: 40, + }, + ]); const req = createRequest("/api/location/search?keywords=星巴克"); const res = await GET(req, mockCtx); @@ -48,9 +42,7 @@ describe("GET /api/location/search", () => { }); it("returns empty when no results", async () => { - mockFetch.mockResolvedValue({ - json: () => Promise.resolve({ status: "1", pois: [] }), - }); + mockSearchPlaceText.mockResolvedValue([]); const req = createRequest("/api/location/search?keywords=不存在的地方"); const res = await GET(req, mockCtx); @@ -65,7 +57,8 @@ describe("GET /api/location/search", () => { }); it("returns 503 when API unavailable", async () => { - mockFetch.mockRejectedValue(new Error("network error")); + const { ApiError } = await import("@/lib/api"); + mockSearchPlaceText.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503)); const req = createRequest("/api/location/search?keywords=test"); const res = await GET(req, mockCtx); diff --git a/src/app/api/location/search/route.ts b/src/app/api/location/search/route.ts index 6462ab3..7e9813e 100644 --- a/src/app/api/location/search/route.ts +++ b/src/app/api/location/search/route.ts @@ -1,19 +1,6 @@ import { NextResponse } from "next/server"; import { apiHandler, ApiError } from "@/lib/api"; -import { requireAmapApiKey } from "@/lib/amap"; - -interface AmapPoiV5 { - id: string; - name: string; - address?: string; - location?: string; - type?: string; - business?: { - rating?: string; - cost?: string; - tel?: string; - }; -} +import { searchPlaceText } from "@/lib/amap"; export const GET = apiHandler(async (req) => { const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); @@ -22,48 +9,6 @@ export const GET = apiHandler(async (req) => { const city = req.nextUrl.searchParams.get("city")?.trim(); const types = req.nextUrl.searchParams.get("types")?.trim(); - const apiKey = requireAmapApiKey(); - - const url = new URL("https://restapi.amap.com/v5/place/text"); - url.searchParams.set("key", apiKey); - url.searchParams.set("keywords", keywords); - url.searchParams.set("show_fields", "business"); - url.searchParams.set("page_size", "10"); - - if (city) url.searchParams.set("region", city); - if (types) url.searchParams.set("types", types); - - let data; - try { - const res = await fetch(url.toString()); - data = await res.json(); - } catch { - throw new ApiError("位置服务暂时不可用,请稍后重试", 503); - } - - if (data.status !== "1" || !data.pois?.length) { - return NextResponse.json([]); - } - - const results = data.pois - .filter((poi: AmapPoiV5) => poi.location) - .map((poi: AmapPoiV5) => { - const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number); - const ratingStr = poi.business?.rating; - const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null; - const costStr = poi.business?.cost; - const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null; - - return { - id: poi.id, - name: poi.name, - address: poi.address || "", - lat, - lng, - rating, - cost, - }; - }); - + const results = await searchPlaceText({ keywords, city: city || undefined, types: types || undefined }); return NextResponse.json(results); }); diff --git a/src/app/api/location/suggest/route.test.ts b/src/app/api/location/suggest/route.test.ts index 79957c9..f81b592 100644 --- a/src/app/api/location/suggest/route.test.ts +++ b/src/app/api/location/suggest/route.test.ts @@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockGetInputTips = vi.fn(); vi.mock("@/lib/amap", () => ({ - requireAmapApiKey: vi.fn().mockReturnValue("test-key"), + getInputTips: (...args: unknown[]) => mockGetInputTips(...args), })); -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - import { GET } from "./route"; const mockCtx = { params: Promise.resolve({}) }; @@ -20,21 +18,16 @@ beforeEach(() => { describe("GET /api/location/suggest", () => { it("returns suggestions", async () => { - mockFetch.mockResolvedValue({ - json: () => - Promise.resolve({ - status: "1", - tips: [ - { - id: "tip-1", - name: "人民广场", - district: "黄浦区", - address: "人民大道", - location: "121.4737,31.2304", - }, - ], - }), - }); + mockGetInputTips.mockResolvedValue([ + { + id: "tip-1", + name: "人民广场", + district: "黄浦区", + address: "人民大道", + lat: 31.2304, + lng: 121.4737, + }, + ]); const req = createRequest("/api/location/suggest?keywords=人民广场"); const res = await GET(req, mockCtx); @@ -53,16 +46,9 @@ describe("GET /api/location/suggest", () => { }); it("filters tips without location", async () => { - mockFetch.mockResolvedValue({ - json: () => - Promise.resolve({ - status: "1", - tips: [ - { id: "tip-1", name: "有位置", location: "121.4,31.2" }, - { id: "tip-2", name: "无位置", location: "" }, - ], - }), - }); + mockGetInputTips.mockResolvedValue([ + { id: "tip-1", name: "有位置", district: "", address: "", lat: 31.2, lng: 121.4 }, + ]); const req = createRequest("/api/location/suggest?keywords=test"); const res = await GET(req, mockCtx); @@ -72,7 +58,8 @@ describe("GET /api/location/suggest", () => { }); it("returns 503 when API fails", async () => { - mockFetch.mockRejectedValue(new Error("network")); + const { ApiError } = await import("@/lib/api"); + mockGetInputTips.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503)); const req = createRequest("/api/location/suggest?keywords=test"); const res = await GET(req, mockCtx); diff --git a/src/app/api/location/suggest/route.ts b/src/app/api/location/suggest/route.ts index 35242f3..b03c02f 100644 --- a/src/app/api/location/suggest/route.ts +++ b/src/app/api/location/suggest/route.ts @@ -1,47 +1,12 @@ import { NextResponse } from "next/server"; -import { apiHandler, ApiError } from "@/lib/api"; -import { requireAmapApiKey } from "@/lib/amap"; +import { apiHandler } from "@/lib/api"; +import { getInputTips } from "@/lib/amap"; export const GET = apiHandler(async (req) => { const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); if (!keywords) return NextResponse.json([]); - const apiKey = requireAmapApiKey(); - - const location = req.nextUrl.searchParams.get("location"); - - const url = new URL("https://restapi.amap.com/v3/assistant/inputtips"); - url.searchParams.set("key", apiKey); - url.searchParams.set("keywords", keywords); - url.searchParams.set("datatype", "poi"); - if (location) { - url.searchParams.set("location", location); - } - - let data; - try { - const res = await fetch(url.toString()); - data = await res.json(); - } catch { - throw new ApiError("位置服务暂时不可用,请稍后重试", 503); - } - - if (data.status !== "1" || !data.tips) return NextResponse.json([]); - - const suggestions = data.tips - .filter((t: { location?: string }) => t.location && t.location !== "") - .slice(0, 8) - .map((t: { id: string; name: string; district?: string; address?: string; location: string }) => { - const [lng, lat] = t.location.split(",").map(Number); - return { - id: t.id, - name: t.name, - district: t.district || "", - address: t.address || "", - lat, - lng, - }; - }); - + const location = req.nextUrl.searchParams.get("location") || undefined; + const suggestions = await getInputTips({ keywords, location }); return NextResponse.json(suggestions); }); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index ab510f1..3c58409 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -1,685 +1,105 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { motion, AnimatePresence, useAnimation } from "framer-motion"; +import { useState, useEffect, useRef } from "react"; +import { useParams } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, - Send, Loader2, Package, - Flame, - Users, Share2, LogIn, Copy, Trash2, LogOut, MapPin, - Calendar, Sparkles, ClipboardCheck, ChevronRight, - Lightbulb, - Shuffle, } from "lucide-react"; -import confetti from "canvas-confetti"; -import { getCachedProfile, isRegistered } from "@/lib/userId"; import ShareCardModal from "@/components/ShareCardModal"; import Button from "@/components/Button"; -import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas"; -import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory"; +import BlindboxMyIdeas from "@/components/BlindboxMyIdeas"; +import BlindboxDrawnHistory from "@/components/BlindboxDrawnHistory"; import WeekendTimeSelector from "@/components/WeekendTimeSelector"; import BlindboxPlan from "@/components/BlindboxPlan"; -import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal"; +import BlindboxPoolPhase from "@/components/BlindboxPoolPhase"; +import BlindboxRevealPhase from "@/components/BlindboxRevealPhase"; +import ContractCompletionModal from "@/components/ContractCompletionModal"; import { useToast } from "@/hooks/useToast"; -import { useShare } from "@/hooks/useShare"; import { BlindboxRoomSkeleton } from "@/components/Skeleton"; -import type { UserProfile, WeekendPlanData } from "@/types"; - -interface RoomInfo { - id: string; - code: string; - name: string; - creatorId: string; - city: string | null; - address: string | null; - lat: number | null; - lng: number | null; - poolCount: number; - members: { id: string; username: string; avatar: string }[]; -} +import { useBlindboxRoom } from "@/hooks/useBlindboxRoom"; +import { useBlindboxIdeas } from "@/hooks/useBlindboxIdeas"; +import { useBlindboxPlan } from "@/hooks/useBlindboxPlan"; +import { useBlindboxDraw } from "@/hooks/useBlindboxDraw"; type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; -const PLAN_STATUS_STEPS = [ - "正在分析你们的想法...", - "正在搜索地点...", - "正在规划路线...", - "快好了...", -]; - -const IDEA_INSPIRATIONS = [ - "去城市最高楼看日落", - "挑战一人做一道菜", - "找一家从没去过的店", - "逛一个从没去过的街区", - "去公园野餐", - "看一场午夜电影", - "在家做一顿异国料理", - "骑车去郊外探险", - "去二手市场淘宝", - "去博物馆逛半天", - "试一项没玩过的运动", - "随机坐公交到终点站", - "一起画画或做手工", - "找个天台看星星", - "去一家评分最低的餐厅", - "穿最好看的衣服去拍照", - "交换手机玩一个小时", - "一起去做志愿者", -]; - -function pickRandom(arr: T[], n: number): T[] { - const shuffled = [...arr].sort(() => Math.random() - 0.5); - return shuffled.slice(0, n); -} - export default function BlindboxRoomPage() { const { code } = useParams<{ code: string }>(); - const router = useRouter(); - - const [profile, setProfile] = useState(null); - const [room, setRoom] = useState(null); - const [isMember, setIsMember] = useState(false); - const [joiningRoom, setJoiningRoom] = useState(false); - const [pageLoading, setPageLoading] = useState(true); - - const [input, setInput] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4)); - const [suggestionsLoading, setSuggestionsLoading] = useState(false); - const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static"); - const [poolCount, setPoolCount] = useState(0); - const [myIdeas, setMyIdeas] = useState([]); - const [drawnHistory, setDrawnHistory] = useState([]); - const [phase, setPhase] = useState("pool"); - const [revealedIdea, setRevealedIdea] = useState(null); - const [submitFlash, setSubmitFlash] = useState(false); - const [error, setError] = useState(""); - const [showInvite, setShowInvite] = useState(false); - const [showShareCard, setShowShareCard] = useState(false); const toast = useToast(); - const [confirmLeave, setConfirmLeave] = useState(false); - const [leaving, setLeaving] = useState(false); - const [locating, setLocating] = useState(false); - const [planId, setPlanId] = useState(null); - const [planDays, setPlanDays] = useState([]); - const [planAccepted, setPlanAccepted] = useState(false); - const [generating, setGenerating] = useState(false); - const [planStatusMessages, setPlanStatusMessages] = useState([]); - const planLogRef = useRef(null); - const [showPlanShareCard, setShowPlanShareCard] = useState(false); - const [activeContract, setActiveContract] = useState<{ - id: string; - days: WeekendPlanData[]; - endTime: string | null; - } | null>(null); - const [pendingContracts, setPendingContracts] = useState([]); - - const boxControls = useAnimation(); - const inputRef = useRef(null); + const [phase, setPhase] = useState("pool"); const timersRef = useRef[]>([]); - const confettiAliveRef = useRef(false); useEffect(() => { - return () => { - timersRef.current.forEach(clearTimeout); - confettiAliveRef.current = false; - }; + return () => { timersRef.current.forEach(clearTimeout); }; }, []); + // Hook: Room + const { + profile, room, isMember, joiningRoom, pageLoading, locating, + confirmLeave, leaving, showInvite, setShowInvite, isCreator, + handleJoinRoom, handleSetLocation, handleLeaveOrDelete, + handleCopyCode, handleShareRoom, handleBackToLobby, + } = useBlindboxRoom(code); + + // Hook: Ideas + const { + input, setInput, submitting, suggestions, suggestionsLoading, + suggestionsSource, poolCount, setPoolCount, myIdeas, drawnHistory, + setDrawnHistory, submitFlash, error, setError, inputRef, + boxControls: ideaBoxControls, + fetchIdeas, fetchSuggestions, refreshSuggestions, + handleSubmit, handleEditIdea, handleDeleteIdea, + } = useBlindboxIdeas(room, profile); + + // Hook: Draw (needs fireConfetti for plan too) + const { + revealedIdea, showShareCard, setShowShareCard, + boxControls: drawBoxControls, + fireConfetti, handleDraw, handleContinue, + } = useBlindboxDraw(room, profile, poolCount, setPoolCount, setDrawnHistory, setError, setPhase); + + // Hook: Plan + const { + planDays, planAccepted, generating, planStatusMessages, + showPlanShareCard, setShowPlanShareCard, activeContract, + pendingContracts, planLogRef, + fetchAcceptedPlan, handleGeneratePlan, handlePlanDaysChange, + handleRefine, handleAcceptPlan, handleRegenerate, + showActiveContract, clearPendingContracts, + } = useBlindboxPlan(room, profile, phase, setPhase, fireConfetti); + + // Fetch data when member joins — run once when isMember+room are ready + const dataFetchedRef = useRef(false); useEffect(() => { - if (!isRegistered()) { - router.replace("/blindbox"); - return; - } - setProfile(getCachedProfile()); - }, [router]); - - const fetchRoom = useCallback(async () => { - if (!code) return; - try { - const res = await fetch(`/api/blindbox/room/${code}`); - if (!res.ok) { - router.replace("/blindbox"); - return; - } - const data: RoomInfo = await res.json(); - setRoom(data); - - const p = getCachedProfile(); - const memberCheck = data.members.some((m) => m.id === p?.id); - setIsMember(memberCheck); - setPoolCount(data.poolCount); - } catch { - router.replace("/blindbox"); - } finally { - setPageLoading(false); - } - }, [code, router]); - - useEffect(() => { - fetchRoom(); - }, [fetchRoom]); - - const fetchIdeas = useCallback(async () => { - const p = getCachedProfile(); - if (!room || !p) return; - try { - const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`); - if (res.ok) { - const data = await res.json(); - setPoolCount(data.poolCount ?? 0); - setMyIdeas(data.myIdeas ?? []); - setDrawnHistory(data.drawn ?? []); - } - } catch (e) { console.error("fetchIdeas failed:", e); } - }, [room]); - - const fetchSuggestions = useCallback(async () => { - const p = getCachedProfile(); - if (!room || !p) return; - setSuggestionsLoading(true); - try { - const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${p.id}`); - if (res.ok) { - const data = await res.json(); - if (data.suggestions?.length > 0) { - setSuggestions(data.suggestions); - setSuggestionsSource("ai"); - setSuggestionsLoading(false); - return; - } - } - } catch (e) { console.error("fetchSuggestions failed:", e); } - setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); - setSuggestionsSource("static"); - setSuggestionsLoading(false); - }, [room]); - - const refreshSuggestions = useCallback(() => { - if (suggestionsSource === "ai") { - fetchSuggestions(); - } else { - setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); - } - }, [suggestionsSource, fetchSuggestions]); - - const fetchAcceptedPlan = useCallback(async () => { - const p = getCachedProfile(); - if (!room || !p) return; - try { - const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`); - if (!res.ok) return; - const data = await res.json(); - if (data.plan) { - setActiveContract({ - id: data.plan.id, - days: data.plan.days, - endTime: data.plan.endTime ?? null, - }); - } - } catch (e) { console.error("fetchAcceptedPlan failed:", e); } - }, [room]); - - useEffect(() => { - if (isMember && room) { + if (isMember && room && !dataFetchedRef.current) { + dataFetchedRef.current = true; fetchIdeas(); fetchAcceptedPlan(); fetchSuggestions(); } }, [isMember, room, fetchIdeas, fetchAcceptedPlan, fetchSuggestions]); - // Check for expired contracts on load - useEffect(() => { - const p = getCachedProfile(); - if (!isMember || !p) return; - (async () => { - try { - const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`); - if (!res.ok) return; - const data = await res.json(); - if (data.pending?.length) setPendingContracts(data.pending); - } catch (e) { console.error("fetchPendingContracts failed:", e); } - })(); - }, [isMember]); - - // Browser notification timer for active contract - useEffect(() => { - if (!activeContract?.endTime) return; - const end = new Date(activeContract.endTime).getTime(); - const now = Date.now(); - const ms = end - now; - if (ms <= 0) return; - - if (typeof Notification !== "undefined" && Notification.permission === "default") { - Notification.requestPermission(); - } - - const timer = setTimeout(() => { - if (typeof Notification !== "undefined" && Notification.permission === "granted") { - const n = new Notification("周末契约到期", { - body: "你的周末契约已结束,完成了吗?", - icon: "/icon-192x192.png", - }); - n.onclick = () => { window.focus(); n.close(); }; - } - // Refresh pending contracts - const p = getCachedProfile(); - if (p) { - fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) - .then((r) => r.json()) - .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) - .catch((e) => { console.error("refreshPendingContracts failed:", e); }); - } - }, ms); - - return () => clearTimeout(timer); - }, [activeContract?.endTime]); - - useEffect(() => { - if (planLogRef.current) { - planLogRef.current.scrollTop = planLogRef.current.scrollHeight; - } - }, [planStatusMessages]); - + // Focus input when member useEffect(() => { if (isMember && inputRef.current) { const t = setTimeout(() => inputRef.current?.focus(), 300); timersRef.current.push(t); } - }, [isMember]); - - const handleJoinRoom = async () => { - if (joiningRoom || !profile || !room) return; - setJoiningRoom(true); - try { - const res = await fetch("/api/blindbox/room/join", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: profile.id, code }), - }); - if (res.ok) { - setIsMember(true); - fetchRoom(); - } - } catch (e) { console.error("handleJoinRoom failed:", e); } - finally { setJoiningRoom(false); } - }; - - const handleSetLocation = useCallback(async () => { - if (locating || !profile || !room) return; - setLocating(true); - try { - const pos = await new Promise((resolve, reject) => - navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), - ); - const { latitude: lat, longitude: lng } = pos.coords; - const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); - const regeo = regeoRes.ok ? await regeoRes.json() : {}; - const cityName = regeo.name || "未知位置"; - const addressLabel = regeo.formatted || cityName; - - const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }), - }); - if (!patchRes.ok) throw new Error("保存位置失败"); - - setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev); - toast.show("位置已设置"); - } catch { - toast.show("获取位置失败,请允许定位权限"); - } finally { - setLocating(false); - } - }, [locating, profile, room, toast]); - - const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => { - if (generating || !profile || !room) return; - setGenerating(true); - setPhase("planning"); - setError(""); - setPlanStatusMessages([PLAN_STATUS_STEPS[0]]); - const payload = { - roomId: room.id, - userId: profile.id, - availableTime: timeConfig, - }; - const stepRef = { current: 0 }; - const fallbackTimer = setInterval(() => { - stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length; - setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]); - }, 2800); - try { - const res = await fetch("/api/blindbox/plan/stream", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "生成失败"); - } - const reader = res.body?.getReader(); - const decoder = new TextDecoder(); - if (!reader) throw new Error("无法读取响应"); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split("\n\n"); - buffer = blocks.pop() ?? ""; - for (const block of blocks) { - let eventType = ""; - let data = ""; - for (const line of block.split("\n")) { - if (line.startsWith("event:")) eventType = line.slice(6).trim(); - else if (line.startsWith("data:")) data = line.slice(5).trim(); - } - if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]); - else if (eventType === "plan") { - const parsed = JSON.parse(data); - setPlanId(parsed.id); - setPlanDays(parsed.days); - setPlanAccepted(false); - setPhase("plan_reveal"); - fireConfetti(); - } else if (eventType === "error") { - setError(data || "生成计划失败"); - setPhase("pool"); - } - } - } - } catch (e) { - try { - const res = await fetch("/api/blindbox/plan", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "生成失败"); - } - const data = await res.json(); - setPlanId(data.id); - setPlanDays(data.days); - setPlanAccepted(false); - setPhase("plan_reveal"); - fireConfetti(); - } catch (fallbackErr) { - setError(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败"); - setPhase("pool"); - } - } finally { - clearInterval(fallbackTimer); - setGenerating(false); - } - }, [generating, profile, room]); - - const handleSubmit = async () => { - const text = input.trim(); - if (!text || submitting || !profile || !room) return; - setSubmitting(true); - setError(""); - try { - const res = await fetch("/api/blindbox", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "提交失败"); - } - const data = await res.json(); - setInput(""); - setPoolCount((c) => c + 1); - setMyIdeas((prev) => [{ - id: data.id, - content: text, - createdAt: new Date().toISOString(), - ...data.tags && { - category: data.tags.category, - timeSlot: data.tags.timeSlot, - estimatedMinutes: data.tags.estimatedMinutes, - costLevel: data.tags.costLevel, - intensity: data.tags.intensity, - needsBooking: data.tags.needsBooking, - searchQuery: data.tags.searchQuery, - searchType: data.tags.searchType, - }, - }, ...prev]); - setSubmitFlash(true); - timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600)); - boxControls.start({ - scale: [1, 1.08, 1], - rotate: [0, -3, 3, 0], - transition: { duration: 0.5 }, - }); - fetchSuggestions(); - } catch (e) { - setError(e instanceof Error ? e.message : "提交失败"); - } finally { - setSubmitting(false); - } - }; - - const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => { - if (!profile) return; - const trimmed = newContent.trim(); - if (!trimmed || trimmed.length > 200) return; - try { - const res = await fetch("/api/blindbox", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "编辑失败"); - } - const data = await res.json(); - setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { - ...i, - content: trimmed, - ...data.tags && { - category: data.tags.category, - timeSlot: data.tags.timeSlot, - estimatedMinutes: data.tags.estimatedMinutes, - costLevel: data.tags.costLevel, - intensity: data.tags.intensity, - needsBooking: data.tags.needsBooking, - searchQuery: data.tags.searchQuery, - searchType: data.tags.searchType, - }, - } : i))); - } catch (e) { - toast.show(e instanceof Error ? e.message : "编辑失败"); - } - }, [profile, toast]); - - const handleDeleteIdea = useCallback(async (ideaId: string) => { - if (!profile) return; - try { - const res = await fetch("/api/blindbox", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ideaId, userId: profile.id }), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "删除失败"); - } - setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); - setPoolCount((c) => Math.max(0, c - 1)); - } catch (e) { - toast.show(e instanceof Error ? e.message : "删除失败"); - } - }, [profile]); - - const handleDraw = async () => { - if (poolCount === 0 || !profile || !room) { - 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: room.id, userId: profile.id }), - }); - - 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 }); - confettiAliveRef.current = true; - const end = Date.now() + 3000; - const frame = () => { - if (Date.now() > end || !confettiAliveRef.current) 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); - }; - timersRef.current.push(setTimeout(frame, 200)); - }; - - const { share, copyToClipboard } = useShare(); - - const handleCopyCode = useCallback( - () => room ? copyToClipboard(room.code, "房间号已复制") : undefined, - [room, copyToClipboard], - ); - - const handleShare = useCallback(() => { - if (!room) return; - const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : ""; - share( - { title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url }, - handleCopyCode, - ); - }, [room, share, handleCopyCode]); - - const isCreator = profile?.id === room?.creatorId; - - const handleBackToLobby = useCallback(() => { - router.push("/blindbox"); - router.refresh(); - }, [router]); - - const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => { - if (!planId || !profile) return; - const prevDays = planDays; - setPlanDays(newDays); - if (planAccepted) { - setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev); - } - try { - const res = await fetch("/api/blindbox/plan", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }), - }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败"); - } catch (e) { - setPlanDays(prevDays); - if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev); - toast.show(e instanceof Error ? e.message : "保存失败"); - } - }, [planId, profile, planDays, planAccepted, toast]); - - const handleRefine = useCallback(async (instruction: string) => { - if (!profile || !planDays.length) return; - const prevDays = planDays; - try { - const res = await fetch("/api/blindbox/plan/refine", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: profile.id, instruction, days: planDays }), - }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败"); - const data = await res.json(); - await handlePlanDaysChange(data.days); - } catch (e) { - setPlanDays(prevDays); - toast.show(e instanceof Error ? e.message : "AI 调整失败"); - } - }, [profile, planDays, handlePlanDaysChange, toast]); - - /** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */ - const handleLeaveOrDelete = async () => { - if (!confirmLeave) { - setConfirmLeave(true); - timersRef.current.push(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.push("/blindbox"); - router.refresh(); - } catch (e) { - toast.show(e instanceof Error ? e.message : "操作失败"); - setConfirmLeave(false); - } finally { - setLeaving(false); - } - }; - - if (pageLoading) { - return ; - } + }, [isMember, inputRef]); + if (pageLoading) return ; if (!room) return null; return ( @@ -690,7 +110,7 @@ export default function BlindboxRoomPage() { {/* Header */}
- {/* Members */}
{room.members.slice(0, 4).map((m) => (
{ - setPlanId(activeContract.id); - setPlanDays(activeContract.days); - setPlanAccepted(true); - setPhase("plan_reveal"); - }} + onClick={showActiveContract} > 契约进行中 @@ -786,7 +196,7 @@ export default function BlindboxRoomPage() { 复制 -
- - {!input && ( -
- {suggestionsSource === "ai" ? ( - - ) : ( - - )} - {suggestionsLoading ? ( - <> - {[1, 2, 3].map((i) => ( -
- ))} - - ) : ( - suggestions.map((s) => ( - - )) - )} - -
- )} - -
- -
- - 抽一个 - - { - if (!room?.city) { - toast.show("请先点击房间名下方设置位置"); - return; - } - setPhase("time_select"); - }} - disabled={poolCount < 2} - className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none" - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.97 }} - > - - 周末计划 - -
- - {error && ( - - {error} - - )} - - {planDays.length > 0 && !planAccepted && ( - setPhase("plan_reveal")} - className="flex w-full items-center justify-between rounded-xl bg-purple-600/10 px-4 py-2.5 ring-1 ring-purple-500/30 active:bg-purple-600/20" - initial={{ opacity: 0, y: 4 }} - animate={{ opacity: 1, y: 0 }} - > -
- - 有一个待确认的计划 -
- -
- )} - + setPhase("time_select")} + onRefreshSuggestions={refreshSuggestions} + onShowPlan={() => setPhase("plan_reveal")} + onLocationMissing={() => toast.show("请先点击房间名下方设置位置")} + /> )} {phase === "shaking" && ( @@ -1007,74 +319,11 @@ export default function BlindboxRoomPage() { )} {phase === "reveal" && revealedIdea && ( - -
-
-
-
-
- -
-

- ✦ 周末契约 ✦ -

- - {revealedIdea.content} - -
- - {/* Attribution */} -
- {revealedIdea.user && ( - - {revealedIdea.user.avatar} {revealedIdea.user.username} 投入 - - )} - {revealedIdea.drawnBy && ( - <> - · - - {revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中 - - - )} -
- -

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

-
-
- -
- - -
- + setShowShareCard(true)} + onContinue={handleContinue} + /> )} {phase === "planning" && ( @@ -1136,41 +385,14 @@ export default function BlindboxRoomPage() { location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined} startLocationLabel={room.address ?? room.city ?? undefined} onAccept={async () => { - setPlanAccepted(true); - fireConfetti(); - if (planId && profile) { - try { - const res = await fetch("/api/blindbox/plan", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planId, userId: profile.id, action: "accept" }), - }); - const data = await res.json(); - setActiveContract({ - id: planId, - days: planDays, - endTime: data.endTime ?? null, - }); - } catch (e) { console.error("acceptPlan failed:", e); } - } - toast.show("契约已接受!"); + await handleAcceptPlan(); timersRef.current.push(setTimeout(() => { setPhase("pool"); - setPlanId(null); - setPlanDays([]); - setPlanAccepted(false); }, 1500)); }} - onRegenerate={() => { - setPlanId(null); - setPlanDays([]); - setPlanAccepted(false); - setPhase("time_select"); - }} + onRegenerate={handleRegenerate} onShare={() => setShowPlanShareCard(true)} - onBack={() => { - setPhase("pool"); - }} + onBack={() => setPhase("pool")} /> )} @@ -1227,7 +449,7 @@ export default function BlindboxRoomPage() { /> )} - {/* Leave / Back — hidden during plan view. Creator: 返回大厅 (no delete) + optional 删除房间. Non-creator: 退出房间. */} + {/* Leave / Back */} {isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( - {leaving ? ( - - ) : ( - - )} + {leaving ? : } {confirmLeave ? "确认删除房间?所有想法将被清除" : "删除房间"} @@ -1271,11 +489,7 @@ export default function BlindboxRoomPage() { : "text-muted hover:text-rose-400/80" }`} > - {leaving ? ( - - ) : ( - - )} + {leaving ? : } {confirmLeave ? "确认退出房间?" : "退出房间"} )} @@ -1289,10 +503,7 @@ export default function BlindboxRoomPage() { { - setPendingContracts([]); - setActiveContract(null); - }} + onDone={clearPendingContracts} /> )}
diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx index 3412f68..8802b4b 100644 --- a/src/app/blindbox/page.tsx +++ b/src/app/blindbox/page.tsx @@ -23,6 +23,7 @@ import Button from "@/components/Button"; import Input from "@/components/Input"; import { BlindboxListSkeleton } from "@/components/Skeleton"; import { useToast } from "@/hooks/useToast"; +import { useBlindboxRooms } from "@/hooks/useBlindboxRooms"; import type { UserProfile } from "@/types"; interface RoomSummary { @@ -169,77 +170,48 @@ export default function BlindboxLobbyPage() { 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 toast = useToast(); + const { rooms: swrRooms, isLoading: swrLoading, isUnauthorized, error: swrError, mutate: mutateRooms } = useBlindboxRooms( + loggedIn && profile ? profile.id : undefined, + ); + const rooms = swrRooms; + const loading = !loggedIn ? false : swrLoading; + + // JWT 过期时,重置登录状态让用户重新登录 + useEffect(() => { + if (isUnauthorized && loggedIn) { + setLoggedIn(false); + setProfile(null); + setShowAuth(true); + } + }, [isUnauthorized, loggedIn]); const createNameRef = useRef(null); const joinCodeRef = useRef(null); const [joinCodeLength, setJoinCodeLength] = useState(0); const [creating, setCreating] = useState(false); const [joining, setJoining] = useState(false); const [error, setError] = useState(""); - const [loadError, setLoadError] = useState(false); + const loadError = swrError ? (swrError instanceof Error ? swrError.message : "未知错误") : false; const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [deletingId, setDeletingId] = useState(null); - const fetchRooms = useCallback(async (silent = false) => { - const p = getCachedProfile(); - if (!p) return; - if (!silent) { - setLoading(true); - setLoadError(false); - } - try { - const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`, { cache: "no-store" }); - if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.error ?? `HTTP ${res.status}`); - } - const data = await res.json(); - setRooms(Array.isArray(data.rooms) ? data.rooms : []); - setLoadError(false); - } catch (e) { - if (!silent) setLoadError(e instanceof Error ? e.message : "未知错误"); - } finally { - if (!silent) setLoading(false); - } - }, []); - useEffect(() => { const registered = isRegistered(); setLoggedIn(registered); - if (registered) { - setProfile(getCachedProfile()); - fetchRooms(); - } else { - setLoading(false); - } + if (registered) setProfile(getCachedProfile()); setHydrated(true); - }, [fetchRooms]); + }, []); useEffect(() => { const handler = () => { const registered = isRegistered(); setLoggedIn(registered); setProfile(registered ? getCachedProfile() : null); - if (registered) fetchRooms(); }; window.addEventListener("nowhatever_auth", handler); return () => window.removeEventListener("nowhatever_auth", handler); - }, [fetchRooms]); - - useEffect(() => { - if (!loggedIn) return; - const refresh = () => fetchRooms(true); - window.addEventListener("focus", refresh); - window.addEventListener("pageshow", refresh); - return () => { - window.removeEventListener("focus", refresh); - window.removeEventListener("pageshow", refresh); - }; - }, [loggedIn, fetchRooms]); + }, []); useEffect(() => { if (rooms.length > 0) setJoinCodeLength(0); @@ -249,8 +221,8 @@ export default function BlindboxLobbyPage() { setProfile(p); setLoggedIn(true); setShowAuth(false); - fetchRooms(); - }, [fetchRooms]); + mutateRooms(); + }, [mutateRooms]); const handleCreate = async () => { if (creating || !profile) return; @@ -307,7 +279,7 @@ export default function BlindboxLobbyPage() { const data = await res.json(); throw new Error(data.error || "操作失败"); } - setRooms((prev) => prev.filter((r) => r.id !== room.id)); + mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false); setConfirmDeleteId(null); toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间"); } catch (e) { @@ -441,7 +413,7 @@ export default function BlindboxLobbyPage() {

{loadError}

)} - ))} -
- )} - {address && !open && ( -

- - {address} -

- )} -
- ); -} - -interface SortablePlanItemProps { - id: string; - item: PlanItem; - index: number; - canEdit: boolean; - onEdit: () => void; -} - -function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
-
-
-
- -
-
- {canEdit && ( - - )} - {item.time} -
-
- -

{item.activity}

-
-
- - {item.poi} -
- {item.address && ( -

{item.address}

- )} -
- - - {formatDuration(item.duration)} - - {item.lat !== 0 && item.lng !== 0 && ( - - - 导航 - - )} -
- {item.reason && ( -

- {item.reason} -

- )} -
- {canEdit && ( - - )} -
-
-
- ); -} - export default function BlindboxPlan({ days, onAccept, @@ -291,8 +69,6 @@ export default function BlindboxPlan({ const [draft, setDraft] = useState(null); const [refineInput, setRefineInput] = useState(""); const [refining, setRefining] = useState(false); - const [suggestingAlt, setSuggestingAlt] = useState(false); - const [altSuggestions, setAltSuggestions] = useState([]); useEffect(() => { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); @@ -332,20 +108,14 @@ export default function BlindboxPlan({ onDaysChange(newDays); setEditingItem(null); setDraft(null); - setAltSuggestions([]); } - // Cross-day move: immediately call onDaysChange function handleMoveToDayIndex(targetDayIndex: number) { if (!draft || !editingItem || !onDaysChange) return; if (targetDayIndex === editingItem.dayIndex) return; - const newDays = days.map((day, di) => { if (di === editingItem.dayIndex) { - return { - ...day, - items: day.items.filter((_, ii) => ii !== editingItem.itemIndex), - }; + return { ...day, items: day.items.filter((_, ii) => ii !== editingItem.itemIndex) }; } if (di === targetDayIndex) { return { ...day, items: [...day.items, draft] }; @@ -368,28 +138,11 @@ export default function BlindboxPlan({ } } - async function handleSuggestAlt() { - if (!draft || suggestingAlt) return; - setSuggestingAlt(true); - try { - const res = await fetch("/api/blindbox/plan/suggest-item", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ activity: draft.activity, time: draft.time, location }), - }); - if (!res.ok) return; - const data = await res.json(); - setAltSuggestions(data.suggestions ?? []); - } finally { - setSuggestingAlt(false); - } - } - if (!currentDay) return null; return (
- {/* Day header — sticky top */} + {/* Day header */}
{/* Scrollable timeline */} -
+
`${dayIndex}-${i}`)} strategy={verticalListSortingStrategy} > - {/* Transit from home to first activity */} + {/* Transit from home */} {currentDay.transitFromStart != null && (
@@ -480,14 +230,13 @@ export default function BlindboxPlan({ { setEditingItem({ dayIndex, itemIndex: i }); setDraft({ ...item }); }} /> - {/* Transit connector to next activity */} + {/* Transit connector */} {item.transitToNext != null && i < currentDay.items.length - 1 && (
@@ -501,7 +250,7 @@ export default function BlindboxPlan({
)} - {/* Transit back home after last activity */} + {/* Transit back home */} {i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
@@ -523,7 +272,7 @@ export default function BlindboxPlan({ - {/* Back to pool — at end of scroll content */} + {/* Back to pool */}
- {/* Fixed bottom bar — actions + day navigation */} + {/* Fixed bottom bar */}
- {/* Refine input (Plan A) */} {onRefine && (
)} - {/* Day navigation */} {days.length > 1 && (
{hasPrev && ( @@ -589,34 +336,17 @@ export default function BlindboxPlan({
)} - {/* Action buttons */}
{accepted ? ( - ) : ( <> - - @@ -625,118 +355,17 @@ export default function BlindboxPlan({
{/* Edit item modal */} - { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet"> - {draft && editingItem && ( -
-
-

编辑活动

- -
- -
- - -
- - -
- - setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })} - /> - - {/* AI 推荐替代 (Plan B) */} - {altSuggestions.length === 0 ? ( - - ) : ( -
- 选一个替代方案 - {altSuggestions.map((alt, i) => ( - - ))} - -
- )} - - {days.length > 1 && ( - - )} -
- - -
- )} -
+ { setEditingItem(null); setDraft(null); }} + onMoveToDayIndex={handleMoveToDayIndex} + />
); } diff --git a/src/components/BlindboxPlanShareCard.tsx b/src/components/BlindboxPlanShareCard.tsx index 754c233..c259af7 100644 --- a/src/components/BlindboxPlanShareCard.tsx +++ b/src/components/BlindboxPlanShareCard.tsx @@ -1,5 +1,6 @@ -import { QRCodeSVG } from "qrcode.react"; import type { WeekendPlanData } from "@/types"; +import ShareCardShell from "./ShareCardShell"; +import type { ShareCardTheme } from "./ShareCardShell"; export interface PlanShareData { type: "plan"; @@ -7,6 +8,18 @@ export interface PlanShareData { roomName: string; } +const THEME: ShareCardTheme = { + emoji: "📋", + tagline: "别说随便 · WEEKEND PLAN", + bgColor: "#0a0810", + gradientBorder: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)", + accentLine: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)", + glows: [ + { top: -30, right: -20, width: 140, height: 140, color: "rgba(124,58,237,0.2)" }, + ], + qrFgColor: "#0a0810", +}; + export default function BlindboxPlanShareCard({ data, cardRef, @@ -17,261 +30,130 @@ export default function BlindboxPlanShareCard({ bgDataUrl?: string | null; }) { const { days, roomName } = data; - const shareUrl = - typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; return ( -
-
- {/* Background image */} - {bgDataUrl && ( - - )} - - {/* Decorative glows */} -
- - {/* Brand header */} -
- 📋 -
+ + {/* Each day */} + {days.map((day, dayIdx) => ( +
+ {/* Room + date badge */} +
- NoWhatever + ✦ {roomName} · {day.date} ✦
-
- 别说随便 · WEEKEND PLAN -
-
-
- - {/* Thin accent line */} -
- - {/* Each day */} - {days.map((day, dayIdx) => ( -
- {/* Room + date badge */} -
+ {day.summary && (
- ✦ {roomName} · {day.date} ✦ + {day.summary}
- {day.summary && ( -
- {day.summary} -
- )} -
- - {/* Timeline items */} -
- {day.items.map((item, i) => ( -
-
- {item.time} -
- -
-
- {i < day.items.length - 1 && ( -
- )} -
- -
-
- {item.activity} -
-
- 📍 {item.poi} -
- {item.reason && ( -
- {item.reason} -
- )} -
-
- ))} -
- - {/* Separator between days */} - {dayIdx < days.length - 1 && ( -
)}
- ))} - {/* Contract stamp */} -
- 此契约一旦开启,绝不反悔 -
+ {/* Timeline items */} +
+ {day.items.map((item, i) => ( +
+
+ {item.time} +
- {/* QR footer */} -
-
- +
+ {i < day.items.length - 1 && ( +
+ )} +
+ +
+
+ {item.activity} +
+
+ 📍 {item.poi} +
+ {item.reason && ( +
+ {item.reason} +
+ )} +
+
+ ))} +
+ + {/* Separator between days */} + {dayIdx < days.length - 1 && ( +
-
-
-
- 扫码一起「别说随便」 -
-
- {shareUrl.replace(/^https?:\/\//, "")} -
-
+ )}
+ ))} + + {/* Contract stamp */} +
+ 此契约一旦开启,绝不反悔
-
+ ); } diff --git a/src/components/BlindboxPoolPhase.tsx b/src/components/BlindboxPoolPhase.tsx new file mode 100644 index 0000000..ec1d435 --- /dev/null +++ b/src/components/BlindboxPoolPhase.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + Send, + Loader2, + Flame, + Calendar, + Sparkles, + ChevronRight, + Lightbulb, + Shuffle, +} from "lucide-react"; +import type { WeekendPlanData } from "@/types"; + +interface BlindboxPoolPhaseProps { + input: string; + setInput: (v: string) => void; + submitting: boolean; + suggestions: string[]; + suggestionsLoading: boolean; + suggestionsSource: "static" | "ai"; + poolCount: number; + error: string; + setError: (e: string) => void; + inputRef: React.RefObject; + planDays: WeekendPlanData[]; + planAccepted: boolean; + hasLocation: boolean; + onSubmit: () => void; + onDraw: () => void; + onPlanStart: () => void; + onRefreshSuggestions: () => void; + onShowPlan: () => void; + onLocationMissing: () => void; +} + +export default function BlindboxPoolPhase({ + input, + setInput, + submitting, + suggestions, + suggestionsLoading, + suggestionsSource, + poolCount, + error, + setError, + inputRef, + planDays, + planAccepted, + hasLocation, + onSubmit, + onDraw, + onPlanStart, + onRefreshSuggestions, + onShowPlan, + onLocationMissing, +}: BlindboxPoolPhaseProps) { + return ( + +
+ { setInput(e.target.value); setError(""); }} + onKeyDown={(e) => { if (e.key === "Enter") onSubmit(); }} + 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" + /> + +
+ + {!input && ( +
+ {suggestionsSource === "ai" ? ( + + ) : ( + + )} + {suggestionsLoading ? ( + <> + {[1, 2, 3].map((i) => ( +
+ ))} + + ) : ( + suggestions.map((s) => ( + + )) + )} + +
+ )} + +
+ +
+ + 抽一个 + + { + if (!hasLocation) { onLocationMissing(); return; } + onPlanStart(); + }} + disabled={poolCount < 2} + className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.97 }} + > + + 周末计划 + +
+ + {error && ( + + {error} + + )} + + {planDays.length > 0 && !planAccepted && ( + +
+ + 有一个待确认的计划 +
+ +
+ )} + + ); +} diff --git a/src/components/BlindboxRevealPhase.tsx b/src/components/BlindboxRevealPhase.tsx new file mode 100644 index 0000000..9b80050 --- /dev/null +++ b/src/components/BlindboxRevealPhase.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Share2 } from "lucide-react"; +import Button from "@/components/Button"; +import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; + +interface BlindboxRevealPhaseProps { + idea: DrawnIdea; + onShare: () => void; + onContinue: () => void; +} + +export default function BlindboxRevealPhase({ idea, onShare, onContinue }: BlindboxRevealPhaseProps) { + return ( + +
+
+
+
+
+ +
+

+ ✦ 周末契约 ✦ +

+ + {idea.content} + +
+ +
+ {idea.user && ( + {idea.user.avatar} {idea.user.username} 投入 + )} + {idea.drawnBy && ( + <> + · + {idea.drawnBy.avatar} {idea.drawnBy.username} 抽中 + + )} +
+ +

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

+
+
+ +
+ + +
+ + ); +} diff --git a/src/components/BlindboxShareCard.tsx b/src/components/BlindboxShareCard.tsx index 9891fa7..e122ea2 100644 --- a/src/components/BlindboxShareCard.tsx +++ b/src/components/BlindboxShareCard.tsx @@ -1,4 +1,5 @@ -import { QRCodeSVG } from "qrcode.react"; +import ShareCardShell from "./ShareCardShell"; +import type { ShareCardTheme } from "./ShareCardShell"; export interface BlindboxShareData { type: "blindbox"; @@ -8,6 +9,19 @@ export interface BlindboxShareData { roomName: string; } +const THEME: ShareCardTheme = { + emoji: "🎁", + tagline: "别说随便 · ADVENTURE ROULETTE", + bgColor: "#0a0810", + gradientBorder: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)", + accentLine: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)", + glows: [ + { top: -30, right: -20, width: 140, height: 140, color: "rgba(124,58,237,0.2)" }, + { top: 9999, right: 9999, width: 120, height: 120, color: "rgba(99,102,241,0.12)" }, + ], + qrFgColor: "#0a0810", +}; + export default function BlindboxShareCard({ data, cardRef, @@ -18,324 +32,142 @@ export default function BlindboxShareCard({ bgDataUrl?: string | null; }) { const { idea, submitter, drawer, roomName } = data; - const shareUrl = - typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; return ( -
+ + {/* Bottom-left glow */}
+ + {/* Room name badge */} +
- {/* Background image */} - {bgDataUrl && ( - + ✦ {roomName} ✦ +
+
+ + {/* Idea card */} +
+
+ {/* Corner decorations */} + {[ + { top: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopLeftRadius: 3 }, + { top: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopRightRadius: 3 }, + { bottom: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomLeftRadius: 3 }, + { bottom: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomRightRadius: 3 }, + ].map((style, i) => ( +
+ ))} + +
+ {idea} +
+ +
- )} - {/* 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?:\/\//, "")} -
+ 此契约一旦开启,绝不反悔
-
+ + {/* Attribution */} + {(submitter || drawer) && ( +
+ {submitter && ( +
+ {submitter.avatar} + {submitter.username} 投入 +
+ )} + {submitter && drawer && ( + · + )} + {drawer && ( +
+ {drawer.avatar} + {drawer.username} 抽中 +
+ )} +
+ )} + ); } diff --git a/src/components/PlanItemEditModal.tsx b/src/components/PlanItemEditModal.tsx new file mode 100644 index 0000000..8438112 --- /dev/null +++ b/src/components/PlanItemEditModal.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { X, Sparkles, Loader2 } from "lucide-react"; +import Modal from "@/components/Modal"; +import Button from "@/components/Button"; +import PoiSearchField from "@/components/PoiSearchField"; +import type { PlanItem, WeekendPlanData } from "@/types"; + +interface AltSuggestion { + activity: string; + poi: string; + address: string; + lat: number; + lng: number; + reason: string; +} + +interface PlanItemEditModalProps { + open: boolean; + editingItem: { dayIndex: number; itemIndex: number } | null; + draft: PlanItem | null; + days: WeekendPlanData[]; + location?: string; + onDraftChange: (draft: PlanItem) => void; + onSave: () => void; + onClose: () => void; + onMoveToDayIndex: (targetDayIndex: number) => void; +} + +export default function PlanItemEditModal({ + open, + editingItem, + draft, + days, + location, + onDraftChange, + onSave, + onClose, + onMoveToDayIndex, +}: PlanItemEditModalProps) { + const [suggestingAlt, setSuggestingAlt] = useState(false); + const [altSuggestions, setAltSuggestions] = useState([]); + + async function handleSuggestAlt() { + if (!draft || suggestingAlt) return; + setSuggestingAlt(true); + try { + const res = await fetch("/api/blindbox/plan/suggest-item", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activity: draft.activity, time: draft.time, location }), + }); + if (!res.ok) return; + const data = await res.json(); + setAltSuggestions(data.suggestions ?? []); + } finally { + setSuggestingAlt(false); + } + } + + function handleClose() { + setAltSuggestions([]); + onClose(); + } + + return ( + + {draft && editingItem && ( +
+
+

编辑活动

+ +
+ +
+ + +
+ + +
+ + onDraftChange({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })} + /> + + {/* AI alternative suggestions */} + {altSuggestions.length === 0 ? ( + + ) : ( +
+ 选一个替代方案 + {altSuggestions.map((alt, i) => ( + + ))} + +
+ )} + + {days.length > 1 && ( + + )} +
+ + +
+ )} +
+ ); +} diff --git a/src/components/PoiSearchField.tsx b/src/components/PoiSearchField.tsx new file mode 100644 index 0000000..42b225a --- /dev/null +++ b/src/components/PoiSearchField.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { MapPin, Loader2 } from "lucide-react"; + +export interface PoiSuggestion { + id: string; + name: string; + district: string; + address: string; + lat: number; + lng: number; +} + +interface PoiSearchFieldProps { + poi: string; + address: string; + onSelect: (s: PoiSuggestion) => void; + location?: string; +} + +export default function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProps) { + const [query, setQuery] = useState(poi); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const lastSelectedRef = useRef(poi); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + if (!query.trim() || query === lastSelectedRef.current) { + setSuggestions([]); + setOpen(false); + return; + } + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ keywords: query }); + if (location) params.set("location", location); + const res = await fetch(`/api/location/suggest?${params}`); + if (res.ok) { + const data: PoiSuggestion[] = await res.json(); + setSuggestions(data); + setOpen(data.length > 0); + } + } catch (e) { console.error("PoiSearchField fetch failed:", e); } + finally { setLoading(false); } + }, 400); + return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + }, [query, location]); + + return ( +
+ 地点 +
+ setQuery(e.target.value)} + placeholder="搜索地点名称..." + className="h-9 w-full rounded-lg bg-elevated px-3 pr-8 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600" + /> + {loading && ( + + )} +
+ {open && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} + {address && !open && ( +

+ + {address} +

+ )} +
+ ); +} diff --git a/src/components/RestaurantShareCard.tsx b/src/components/RestaurantShareCard.tsx index c35a31d..2a6070f 100644 --- a/src/components/RestaurantShareCard.tsx +++ b/src/components/RestaurantShareCard.tsx @@ -1,7 +1,8 @@ import { Star, MapPin, Zap } from "lucide-react"; -import { QRCodeSVG } from "qrcode.react"; import type { Restaurant, MatchType, SceneType } from "@/types"; import { getSceneConfig } from "@/lib/sceneConfig"; +import ShareCardShell from "./ShareCardShell"; +import type { ShareCardTheme } from "./ShareCardShell"; export interface RestaurantShareData { type: "restaurant"; @@ -12,6 +13,25 @@ export interface RestaurantShareData { scene?: SceneType; } +function buildTheme(isUnanimous: boolean): ShareCardTheme & { accentText: string; accentBg: string } { + const accentFrom = isUnanimous ? "#059669" : "#b45309"; + const accentTo = isUnanimous ? "#34d399" : "#fbbf24"; + return { + emoji: "⚡", + tagline: "别说随便 · PANIC MODE", + bgColor: "#08080a", + gradientBorder: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`, + accentLine: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`, + glows: [ + { top: -40, right: -30, width: 160, height: 160, color: `${accentFrom}25` }, + { top: 9999, right: 9999, width: 120, height: 120, color: `${accentTo}12` }, + ], + qrFgColor: "#08080a", + accentText: isUnanimous ? "#6ee7b7" : "#fcd34d", + accentBg: isUnanimous ? "rgba(16, 185, 129, 0.12)" : "rgba(245, 158, 11, 0.12)", + }; +} + export default function RestaurantShareCard({ data, cardRef, @@ -26,381 +46,223 @@ export default function RestaurantShareCard({ const { restaurant, matchType, matchLikes, userCount, scene } = data; const isUnanimous = matchType === "unanimous"; const verb = getSceneConfig(scene ?? "eat").verb; - const shareUrl = - typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; - const accentFrom = isUnanimous ? "#059669" : "#b45309"; + const theme = buildTheme(isUnanimous); + const { accentText, accentBg } = theme; 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)"; + + // Fix the second glow position (bottom-left, not the placeholder) + theme.glows[1] = { top: 9999, right: 9999, width: 120, height: 120, color: `${accentTo}12` }; return ( -
+ + {/* Second glow override (bottom-left) */}
+ + {/* Hero section */} +
- {/* Background image */} - {bgDataUrl && ( - - )} - - {/* Decorative glows */} +
+ {isUnanimous ? "🎉" : "🏆"} +
-
- - {/* Brand header */} -
-
- -
+ 就去这{verb}! +
+
+ {isUnanimous && ( + + )} + + {isUnanimous + ? `默契度 100% · ${userCount}人全员一致` + : `${matchLikes}/${userCount} 人选了这家`} + + {isUnanimous && ( + + )} +
+
+ + {/* Restaurant card */} +
+
+ {imageDataUrl && ( + {restaurant.name} + )} +
+
- NoWhatever -
-
- 别说随便 · PANIC MODE + {restaurant.name}
+ {restaurant.category && ( + + {restaurant.category} + + )}
-
-
- {/* 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 && ( -
0 && ( + - {restaurant.tag - .split(",") - .slice(0, 4) - .map((t) => ( - - {t.trim()} - - ))} -
+ + {restaurant.rating} + + )} + {restaurant.price && restaurant.price !== "未知" && ( + + {restaurant.price} + + )} + {restaurant.distance && ( + + + {restaurant.distance} + )}
-
-
- {/* QR footer */} -
-
- -
-
-
- 扫码一起「别说随便」 -
-
- {shareUrl.replace(/^https?:\/\//, "")} -
+ {restaurant.address && ( +
+ 📍 {restaurant.address} +
+ )} + + {restaurant.tag && ( +
+ {restaurant.tag + .split(",") + .slice(0, 4) + .map((t) => ( + + {t.trim()} + + ))} +
+ )}
-
+ ); } diff --git a/src/components/ShareCardShell.tsx b/src/components/ShareCardShell.tsx new file mode 100644 index 0000000..f2ebb09 --- /dev/null +++ b/src/components/ShareCardShell.tsx @@ -0,0 +1,180 @@ +import { QRCodeSVG } from "qrcode.react"; +import type { ReactNode } from "react"; + +export interface ShareCardTheme { + emoji: string; + tagline: string; + bgColor: string; + gradientBorder: string; + accentLine: string; + glows: { top: number; right: number; width: number; height: number; color: string }[]; + qrFgColor: string; +} + +export default function ShareCardShell({ + theme, + cardRef, + bgDataUrl, + children, +}: { + theme: ShareCardTheme; + cardRef: React.RefObject; + bgDataUrl?: string | null; + children: ReactNode; +}) { + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + + return ( +
+
+ {/* Background image */} + {bgDataUrl && ( + + )} + + {/* Decorative glows */} + {theme.glows.map((g, i) => ( +
+ ))} + + {/* Brand header */} +
+
+ {theme.emoji} +
+
+ NoWhatever +
+
+ {theme.tagline} +
+
+
+
+ + {/* Thin accent line */} +
+ + {/* Card-specific content */} + {children} + + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} diff --git a/src/components/SortablePlanItem.tsx b/src/components/SortablePlanItem.tsx new file mode 100644 index 0000000..c7a9512 --- /dev/null +++ b/src/components/SortablePlanItem.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { MapPin, Clock, Navigation, GripVertical, Pencil } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CategoryBadge } from "@/components/BlindboxMyIdeas"; +import { guessCategory, formatDuration } from "@/lib/planUtils"; +import type { PlanItem } from "@/types"; + +interface SortablePlanItemProps { + id: string; + item: PlanItem; + canEdit: boolean; + onEdit: () => void; +} + +export default function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+
+
+ +
+
+ {canEdit && ( + + )} + {item.time} +
+
+ +

{item.activity}

+
+
+ + {item.poi} +
+ {item.address && ( +

{item.address}

+ )} +
+ + + {formatDuration(item.duration)} + + {item.lat !== 0 && item.lng !== 0 && ( + + + 导航 + + )} +
+ {item.reason && ( +

+ {item.reason} +

+ )} +
+ {canEdit && ( + + )} +
+
+
+ ); +} diff --git a/src/hooks/useAchievements.ts b/src/hooks/useAchievements.ts new file mode 100644 index 0000000..2715173 --- /dev/null +++ b/src/hooks/useAchievements.ts @@ -0,0 +1,38 @@ +"use client"; + +import useSWR from "swr"; +import { fetcher } from "@/lib/fetcher"; +import type { DecisionRecord, ContractRecord } from "@/types"; + +interface Stats { + totalDecisions: number; + totalContracts: number; + completedContracts: number; + completionRate: number; +} + +interface AchievementsResponse { + stats: Stats; + decisions: DecisionRecord[]; + contracts: ContractRecord[]; +} + +export function useAchievements(userId: string | undefined) { + const { data, isLoading, error } = useSWR( + userId ? `/api/user/achievements?userId=${userId}` : null, + fetcher, + ); + + return { + stats: data?.stats ?? { + totalDecisions: 0, + totalContracts: 0, + completedContracts: 0, + completionRate: 0, + }, + decisions: data?.decisions ?? [], + contracts: data?.contracts ?? [], + isLoading, + error, + }; +} diff --git a/src/hooks/useBlindboxDraw.ts b/src/hooks/useBlindboxDraw.ts new file mode 100644 index 0000000..0a17050 --- /dev/null +++ b/src/hooks/useBlindboxDraw.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { useAnimation } from "framer-motion"; +import confetti from "canvas-confetti"; +import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; +import type { RoomInfo } from "@/hooks/useBlindboxRoom"; +import type { UserProfile } from "@/types"; + +type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; + +export function useBlindboxDraw( + room: RoomInfo | null, + profile: UserProfile | null, + poolCount: number, + setPoolCount: React.Dispatch>, + setDrawnHistory: React.Dispatch>, + setError: (e: string) => void, + setPhase: (p: Phase) => void, +) { + const [revealedIdea, setRevealedIdea] = useState(null); + const [showShareCard, setShowShareCard] = useState(false); + const boxControls = useAnimation(); + const confettiAliveRef = useRef(false); + const timersRef = useRef[]>([]); + + const fireConfetti = useCallback(() => { + const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"]; + confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 }); + confettiAliveRef.current = true; + const end = Date.now() + 3000; + const frame = () => { + if (Date.now() > end || !confettiAliveRef.current) 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); + }; + timersRef.current.push(setTimeout(frame, 200)); + }, []); + + const handleDraw = async () => { + if (poolCount === 0 || !profile || !room) { + 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: room.id, userId: profile.id }), + }); + + 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 handleContinue = useCallback(() => { + setPhase("pool"); + setRevealedIdea(null); + setShowShareCard(false); + }, [setPhase]); + + return { + revealedIdea, + showShareCard, + setShowShareCard, + boxControls, + fireConfetti, + handleDraw, + handleContinue, + }; +} diff --git a/src/hooks/useBlindboxIdeas.ts b/src/hooks/useBlindboxIdeas.ts new file mode 100644 index 0000000..47e3ed6 --- /dev/null +++ b/src/hooks/useBlindboxIdeas.ts @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { useAnimation } from "framer-motion"; +import { useToast } from "@/hooks/useToast"; +import type { MyIdea } from "@/components/BlindboxMyIdeas"; +import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; +import type { RoomInfo } from "@/hooks/useBlindboxRoom"; +import type { UserProfile } from "@/types"; + +const IDEA_INSPIRATIONS = [ + "去城市最高楼看日落", + "挑战一人做一道菜", + "找一家从没去过的店", + "逛一个从没去过的街区", + "去公园野餐", + "看一场午夜电影", + "在家做一顿异国料理", + "骑车去郊外探险", + "去二手市场淘宝", + "去博物馆逛半天", + "试一项没玩过的运动", + "随机坐公交到终点站", + "一起画画或做手工", + "找个天台看星星", + "去一家评分最低的餐厅", + "穿最好看的衣服去拍照", + "交换手机玩一个小时", + "一起去做志愿者", +]; + +function pickRandom(arr: T[], n: number): T[] { + const shuffled = [...arr].sort(() => Math.random() - 0.5); + return shuffled.slice(0, n); +} + +export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) { + const toast = useToast(); + const boxControls = useAnimation(); + + const [input, setInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4)); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static"); + const [poolCount, setPoolCount] = useState(0); + const [myIdeas, setMyIdeas] = useState([]); + const [drawnHistory, setDrawnHistory] = useState([]); + const [submitFlash, setSubmitFlash] = useState(false); + const [error, setError] = useState(""); + const inputRef = useRef(null); + const timersRef = useRef[]>([]); + + const fetchIdeas = useCallback(async () => { + if (!room || !profile) return; + try { + const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${profile.id}`); + if (res.ok) { + const data = await res.json(); + setPoolCount(data.poolCount ?? 0); + setMyIdeas(data.myIdeas ?? []); + setDrawnHistory(data.drawn ?? []); + } + } catch (e) { console.error("fetchIdeas failed:", e); } + }, [room, profile]); + + const fetchSuggestions = useCallback(async () => { + if (!room || !profile) return; + setSuggestionsLoading(true); + try { + const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${profile.id}`); + if (res.ok) { + const data = await res.json(); + if (data.suggestions?.length > 0) { + setSuggestions(data.suggestions); + setSuggestionsSource("ai"); + setSuggestionsLoading(false); + return; + } + } + } catch (e) { console.error("fetchSuggestions failed:", e); } + setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); + setSuggestionsSource("static"); + setSuggestionsLoading(false); + }, [room, profile]); + + const refreshSuggestions = useCallback(() => { + if (suggestionsSource === "ai") { + fetchSuggestions(); + } else { + setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); + } + }, [suggestionsSource, fetchSuggestions]); + + const handleSubmit = async () => { + const text = input.trim(); + if (!text || submitting || !profile || !room) return; + setSubmitting(true); + setError(""); + try { + const res = await fetch("/api/blindbox", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "提交失败"); + } + const data = await res.json(); + setInput(""); + setPoolCount((c) => c + 1); + setMyIdeas((prev) => [{ + id: data.id, + content: text, + createdAt: new Date().toISOString(), + ...data.tags && { + category: data.tags.category, + timeSlot: data.tags.timeSlot, + estimatedMinutes: data.tags.estimatedMinutes, + costLevel: data.tags.costLevel, + intensity: data.tags.intensity, + needsBooking: data.tags.needsBooking, + searchQuery: data.tags.searchQuery, + searchType: data.tags.searchType, + }, + }, ...prev]); + setSubmitFlash(true); + timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600)); + boxControls.start({ + scale: [1, 1.08, 1], + rotate: [0, -3, 3, 0], + transition: { duration: 0.5 }, + }); + fetchSuggestions(); + } catch (e) { + setError(e instanceof Error ? e.message : "提交失败"); + } finally { + setSubmitting(false); + } + }; + + const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => { + if (!profile) return; + const trimmed = newContent.trim(); + if (!trimmed || trimmed.length > 200) return; + try { + const res = await fetch("/api/blindbox", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "编辑失败"); + } + const data = await res.json(); + setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { + ...i, + content: trimmed, + ...data.tags && { + category: data.tags.category, + timeSlot: data.tags.timeSlot, + estimatedMinutes: data.tags.estimatedMinutes, + costLevel: data.tags.costLevel, + intensity: data.tags.intensity, + needsBooking: data.tags.needsBooking, + searchQuery: data.tags.searchQuery, + searchType: data.tags.searchType, + }, + } : i))); + } catch (e) { + toast.show(e instanceof Error ? e.message : "编辑失败"); + } + }, [profile, toast]); + + const handleDeleteIdea = useCallback(async (ideaId: string) => { + if (!profile) return; + try { + const res = await fetch("/api/blindbox", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ideaId, userId: profile.id }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "删除失败"); + } + setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); + setPoolCount((c) => Math.max(0, c - 1)); + } catch (e) { + toast.show(e instanceof Error ? e.message : "删除失败"); + } + }, [profile, toast]); + + return { + input, + setInput, + submitting, + suggestions, + suggestionsLoading, + suggestionsSource, + poolCount, + setPoolCount, + myIdeas, + drawnHistory, + setDrawnHistory, + submitFlash, + error, + setError, + inputRef, + boxControls, + fetchIdeas, + fetchSuggestions, + refreshSuggestions, + handleSubmit, + handleEditIdea, + handleDeleteIdea, + }; +} diff --git a/src/hooks/useBlindboxPlan.ts b/src/hooks/useBlindboxPlan.ts new file mode 100644 index 0000000..0793d40 --- /dev/null +++ b/src/hooks/useBlindboxPlan.ts @@ -0,0 +1,284 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { getCachedProfile } from "@/lib/userId"; +import { useToast } from "@/hooks/useToast"; +import type { RoomInfo } from "@/hooks/useBlindboxRoom"; +import type { WeekendPlanData, UserProfile } from "@/types"; +import type { PendingContract } from "@/components/ContractCompletionModal"; + +type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; + +const PLAN_STATUS_STEPS = [ + "正在分析你们的想法...", + "正在搜索地点...", + "正在规划路线...", + "快好了...", +]; + +export function useBlindboxPlan( + room: RoomInfo | null, + profile: UserProfile | null, + phase: Phase, + setPhase: (p: Phase) => void, + fireConfetti: () => void, +) { + const toast = useToast(); + const [planId, setPlanId] = useState(null); + const [planDays, setPlanDays] = useState([]); + const [planAccepted, setPlanAccepted] = useState(false); + const [generating, setGenerating] = useState(false); + const [planStatusMessages, setPlanStatusMessages] = useState([]); + const [showPlanShareCard, setShowPlanShareCard] = useState(false); + const [activeContract, setActiveContract] = useState<{ + id: string; + days: WeekendPlanData[]; + endTime: string | null; + } | null>(null); + const [pendingContracts, setPendingContracts] = useState([]); + const planLogRef = useRef(null); + + useEffect(() => { + if (planLogRef.current) { + planLogRef.current.scrollTop = planLogRef.current.scrollHeight; + } + }, [planStatusMessages]); + + const fetchAcceptedPlan = useCallback(async () => { + if (!room || !profile) return; + try { + const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${profile.id}`); + if (!res.ok) return; + const data = await res.json(); + if (data.plan) { + setActiveContract({ + id: data.plan.id, + days: data.plan.days, + endTime: data.plan.endTime ?? null, + }); + } + } catch (e) { console.error("fetchAcceptedPlan failed:", e); } + }, [room, profile]); + + // Check for expired contracts on load + useEffect(() => { + if (!profile) return; + (async () => { + try { + const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${profile.id}`); + if (!res.ok) return; + const data = await res.json(); + if (data.pending?.length) setPendingContracts(data.pending); + } catch (e) { console.error("fetchPendingContracts failed:", e); } + })(); + }, [profile]); + + // Browser notification timer for active contract + useEffect(() => { + if (!activeContract?.endTime) return; + const end = new Date(activeContract.endTime).getTime(); + const now = Date.now(); + const ms = end - now; + if (ms <= 0) return; + + if (typeof Notification !== "undefined" && Notification.permission === "default") { + Notification.requestPermission(); + } + + const timer = setTimeout(() => { + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + const n = new Notification("周末契约到期", { + body: "你的周末契约已结束,完成了吗?", + icon: "/icon-192x192.png", + }); + n.onclick = () => { window.focus(); n.close(); }; + } + const p = getCachedProfile(); + if (p) { + fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) + .then((r) => r.json()) + .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) + .catch((e) => { console.error("refreshPendingContracts failed:", e); }); + } + }, ms); + + return () => clearTimeout(timer); + }, [activeContract?.endTime]); + + const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => { + if (generating || !profile || !room) return; + setGenerating(true); + setPhase("planning"); + setPlanStatusMessages([PLAN_STATUS_STEPS[0]]); + const payload = { roomId: room.id, userId: profile.id, availableTime: timeConfig }; + const stepRef = { current: 0 }; + const fallbackTimer = setInterval(() => { + stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length; + setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]); + }, 2800); + try { + const res = await fetch("/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "生成失败"); + } + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) throw new Error("无法读取响应"); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + for (const block of blocks) { + let eventType = ""; + let data = ""; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) eventType = line.slice(6).trim(); + else if (line.startsWith("data:")) data = line.slice(5).trim(); + } + if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]); + else if (eventType === "plan") { + const parsed = JSON.parse(data); + setPlanId(parsed.id); + setPlanDays(parsed.days); + setPlanAccepted(false); + setPhase("plan_reveal"); + fireConfetti(); + } else if (eventType === "error") { + toast.show(data || "生成计划失败"); + setPhase("pool"); + } + } + } + } catch { + try { + const res = await fetch("/api/blindbox/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "生成失败"); + } + const data = await res.json(); + setPlanId(data.id); + setPlanDays(data.days); + setPlanAccepted(false); + setPhase("plan_reveal"); + fireConfetti(); + } catch (fallbackErr) { + toast.show(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败"); + setPhase("pool"); + } + } finally { + clearInterval(fallbackTimer); + setGenerating(false); + } + }, [generating, profile, room, setPhase, fireConfetti, toast]); + + const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => { + if (!planId || !profile) return; + const prevDays = planDays; + setPlanDays(newDays); + if (planAccepted) { + setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev); + } + try { + const res = await fetch("/api/blindbox/plan", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败"); + } catch (e) { + setPlanDays(prevDays); + if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev); + toast.show(e instanceof Error ? e.message : "保存失败"); + } + }, [planId, profile, planDays, planAccepted, toast]); + + const handleRefine = useCallback(async (instruction: string) => { + if (!profile || !planDays.length) return; + const prevDays = planDays; + try { + const res = await fetch("/api/blindbox/plan/refine", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, instruction, days: planDays }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败"); + const data = await res.json(); + await handlePlanDaysChange(data.days); + } catch (e) { + setPlanDays(prevDays); + toast.show(e instanceof Error ? e.message : "AI 调整失败"); + } + }, [profile, planDays, handlePlanDaysChange, toast]); + + const handleAcceptPlan = useCallback(async () => { + setPlanAccepted(true); + fireConfetti(); + if (planId && profile) { + try { + const res = await fetch("/api/blindbox/plan", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ planId, userId: profile.id, action: "accept" }), + }); + const data = await res.json(); + setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null }); + } catch (e) { console.error("acceptPlan failed:", e); } + } + toast.show("契约已接受!"); + }, [planId, profile, planDays, fireConfetti, toast]); + + const handleRegenerate = useCallback(() => { + setPlanId(null); + setPlanDays([]); + setPlanAccepted(false); + setPhase("time_select"); + }, [setPhase]); + + const showActiveContract = useCallback(() => { + if (!activeContract) return; + setPlanId(activeContract.id); + setPlanDays(activeContract.days); + setPlanAccepted(true); + setPhase("plan_reveal"); + }, [activeContract, setPhase]); + + const clearPendingContracts = useCallback(() => { + setPendingContracts([]); + setActiveContract(null); + }, []); + + return { + planId, + planDays, + setPlanDays, + planAccepted, + generating, + planStatusMessages, + showPlanShareCard, + setShowPlanShareCard, + activeContract, + pendingContracts, + planLogRef, + fetchAcceptedPlan, + handleGeneratePlan, + handlePlanDaysChange, + handleRefine, + handleAcceptPlan, + handleRegenerate, + showActiveContract, + clearPendingContracts, + }; +} diff --git a/src/hooks/useBlindboxRoom.ts b/src/hooks/useBlindboxRoom.ts new file mode 100644 index 0000000..8080e97 --- /dev/null +++ b/src/hooks/useBlindboxRoom.ts @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { getCachedProfile, isRegistered } from "@/lib/userId"; +import { useToast } from "@/hooks/useToast"; +import { useShare } from "@/hooks/useShare"; +import type { UserProfile } from "@/types"; + +export interface RoomInfo { + id: string; + code: string; + name: string; + creatorId: string; + city: string | null; + address: string | null; + lat: number | null; + lng: number | null; + poolCount: number; + members: { id: string; username: string; avatar: string }[]; +} + +export function useBlindboxRoom(code: string) { + const router = useRouter(); + const toast = useToast(); + const { share, copyToClipboard } = useShare(); + + const [profile, setProfile] = useState(null); + const [room, setRoom] = useState(null); + const [isMember, setIsMember] = useState(false); + const [joiningRoom, setJoiningRoom] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + const [locating, setLocating] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); + const [leaving, setLeaving] = useState(false); + const [showInvite, setShowInvite] = useState(false); + const timersRef = useRef[]>([]); + + useEffect(() => { + return () => { timersRef.current.forEach(clearTimeout); }; + }, []); + + useEffect(() => { + if (!isRegistered()) { + router.replace("/blindbox"); + return; + } + setProfile(getCachedProfile()); + }, [router]); + + const fetchRoom = useCallback(async () => { + if (!code) return; + try { + const res = await fetch(`/api/blindbox/room/${code}`); + if (!res.ok) { router.replace("/blindbox"); return; } + const data: RoomInfo = await res.json(); + setRoom(data); + const p = getCachedProfile(); + setIsMember(data.members.some((m) => m.id === p?.id)); + } catch { + router.replace("/blindbox"); + } finally { + setPageLoading(false); + } + }, [code, router]); + + useEffect(() => { fetchRoom(); }, [fetchRoom]); + + const handleJoinRoom = async () => { + if (joiningRoom || !profile || !room) return; + setJoiningRoom(true); + try { + const res = await fetch("/api/blindbox/room/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, code }), + }); + if (res.ok) { setIsMember(true); fetchRoom(); } + } catch (e) { console.error("handleJoinRoom failed:", e); } + finally { setJoiningRoom(false); } + }; + + const handleSetLocation = useCallback(async () => { + if (locating || !profile || !room) return; + setLocating(true); + try { + const pos = await new Promise((resolve, reject) => + navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), + ); + const { latitude: lat, longitude: lng } = pos.coords; + const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); + const regeo = regeoRes.ok ? await regeoRes.json() : {}; + const cityName = regeo.name || "未知位置"; + const addressLabel = regeo.formatted || cityName; + const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }), + }); + if (!patchRes.ok) throw new Error("保存位置失败"); + setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev); + toast.show("位置已设置"); + } catch { + toast.show("获取位置失败,请允许定位权限"); + } finally { + setLocating(false); + } + }, [locating, profile, room, toast]); + + const handleLeaveOrDelete = async () => { + if (!confirmLeave) { + setConfirmLeave(true); + timersRef.current.push(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.push("/blindbox"); + router.refresh(); + } catch (e) { + toast.show(e instanceof Error ? e.message : "操作失败"); + setConfirmLeave(false); + } finally { + setLeaving(false); + } + }; + + const handleCopyCode = useCallback( + () => room ? copyToClipboard(room.code, "房间号已复制") : undefined, + [room, copyToClipboard], + ); + + const handleShareRoom = useCallback(() => { + if (!room) return; + const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : ""; + share( + { title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url }, + handleCopyCode, + ); + }, [room, share, handleCopyCode]); + + const handleBackToLobby = useCallback(() => { + router.push("/blindbox"); + router.refresh(); + }, [router]); + + const isCreator = profile?.id === room?.creatorId; + + return { + profile, + room, + isMember, + joiningRoom, + pageLoading, + locating, + confirmLeave, + leaving, + showInvite, + setShowInvite, + isCreator, + handleJoinRoom, + handleSetLocation, + handleLeaveOrDelete, + handleCopyCode, + handleShareRoom, + handleBackToLobby, + fetchRoom, + }; +} diff --git a/src/hooks/useBlindboxRooms.ts b/src/hooks/useBlindboxRooms.ts new file mode 100644 index 0000000..d6d37e4 --- /dev/null +++ b/src/hooks/useBlindboxRooms.ts @@ -0,0 +1,40 @@ +"use client"; + +import useSWR from "swr"; +import { fetcher, FetchError } from "@/lib/fetcher"; + +interface RoomSummary { + id: string; + code: string; + name: string; + creatorId: string; + memberCount: number; + poolCount: number; + members: { id: string; username: string; avatar: string }[]; + lastDrawn: { content: string; createdAt: string } | null; +} + +interface RoomsResponse { + rooms: RoomSummary[]; +} + +export function useBlindboxRooms(userId: string | undefined) { + const { data, error, isLoading, mutate } = useSWR( + userId ? `/api/blindbox/rooms?userId=${userId}` : null, + fetcher, + { + revalidateOnFocus: true, + revalidateOnReconnect: true, + shouldRetryOnError: (err) => + !(err instanceof FetchError && (err.status === 401 || err.status === 403)), + }, + ); + + return { + rooms: data?.rooms ?? [], + isLoading, + isUnauthorized: error instanceof FetchError && error.status === 401, + error, + mutate, + }; +} diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts new file mode 100644 index 0000000..d4a5528 --- /dev/null +++ b/src/hooks/useFavorites.ts @@ -0,0 +1,18 @@ +"use client"; + +import useSWR from "swr"; +import { fetcher } from "@/lib/fetcher"; +import type { FavoriteRecord } from "@/types"; + +export function useFavorites(userId: string | undefined) { + const { data, isLoading, mutate } = useSWR( + userId ? `/api/user/favorite?userId=${userId}` : null, + fetcher, + ); + + return { + favorites: data ?? [], + isLoading, + mutate, + }; +} diff --git a/src/hooks/useRoomPolling.ts b/src/hooks/useRoomPolling.ts index 7f119cc..115eb3f 100644 --- a/src/hooks/useRoomPolling.ts +++ b/src/hooks/useRoomPolling.ts @@ -2,24 +2,19 @@ import useSWR from "swr"; import { useEffect, useRef } from "react"; +import { fetcher, FetchError } from "@/lib/fetcher"; import { RoomStatus } from "@/types"; -async function fetcher(url: string) { - const r = await fetch(url); - if (!r.ok) { - const err = new Error(r.status === 404 ? "NOT_FOUND" : "FETCH_ERROR"); - throw err; - } - return r.json(); -} - export function useRoomPolling(roomId: string | undefined) { const { data, error, isLoading, mutate } = useSWR( roomId ? `/api/room/${roomId}` : null, fetcher, { revalidateOnFocus: true, - shouldRetryOnError: (err) => err?.message !== "NOT_FOUND", + shouldRetryOnError: (err) => { + if (err instanceof FetchError && (err.status === 401 || err.status === 404)) return false; + return err?.message !== "NOT_FOUND"; + }, }, ); diff --git a/src/lib/amap.ts b/src/lib/amap.ts index bb72826..c97c425 100644 --- a/src/lib/amap.ts +++ b/src/lib/amap.ts @@ -1,7 +1,331 @@ +/** + * Unified Amap (高德地图) API client. + * All external calls go through amapFetch() with consistent timeout + error handling. + */ import { ApiError } from "@/lib/api"; +const AMAP_TIMEOUT_MS = 8000; + export function requireAmapApiKey(): string { const key = process.env.AMAP_API_KEY; if (!key) throw new ApiError("服务配置异常,请稍后重试", 500); return key; } + +async function amapFetch(url: URL): Promise> { + let res: Response; + try { + res = await fetch(url.toString(), { + signal: AbortSignal.timeout(AMAP_TIMEOUT_MS), + }); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); + } + const data = await res.json(); + return data as Record; +} + +// --------------------------------------------------------------------------- +// Shared POI mapper +// --------------------------------------------------------------------------- + +interface RawPoi { + id?: string; + name: string; + address?: string; + location?: string; + type?: string; + business?: { rating?: string; cost?: string; tel?: string }; +} + +export interface PoiResult { + id?: string; + name: string; + address: string; + lat: number; + lng: number; + rating?: number | null; + cost?: number | null; +} + +function mapPois(pois: RawPoi[], opts?: { includeCost?: boolean }): PoiResult[] { + return pois + .filter((p) => p.location) + .map((p) => { + const [lng, lat] = (p.location ?? "0,0").split(",").map(Number); + const ratingStr = p.business?.rating; + const rating = + ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null; + const result: PoiResult = { + id: p.id, + name: p.name, + address: p.address || "", + lat, + lng, + rating, + }; + if (opts?.includeCost) { + const costStr = p.business?.cost; + result.cost = + costStr && costStr !== "[]" && costStr !== "0" + ? Number(costStr) + : null; + } + return result; + }); +} + +// --------------------------------------------------------------------------- +// POI search — text (v5) +// --------------------------------------------------------------------------- + +export async function searchPlaceText(params: { + keywords: string; + city?: string; + types?: string; + location?: string; + pageSize?: number; +}): Promise { + const url = new URL("https://restapi.amap.com/v5/place/text"); + url.searchParams.set("key", requireAmapApiKey()); + url.searchParams.set("keywords", params.keywords); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", String(params.pageSize ?? 10)); + if (params.city) url.searchParams.set("region", params.city); + if (params.types) url.searchParams.set("types", params.types); + if (params.location) url.searchParams.set("location", params.location); + + const data = await amapFetch(url); + if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) { + return []; + } + return mapPois(data.pois as RawPoi[], { includeCost: true }); +} + +// --------------------------------------------------------------------------- +// POI search — around (v5) +// --------------------------------------------------------------------------- + +export async function searchPlaceAround(params: { + keywords: string; + location: string; + radius?: number; + pageSize?: number; +}): Promise { + const url = new URL("https://restapi.amap.com/v5/place/around"); + url.searchParams.set("key", requireAmapApiKey()); + url.searchParams.set("location", params.location); + url.searchParams.set("keywords", params.keywords); + url.searchParams.set("radius", String(params.radius ?? 5000)); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", String(params.pageSize ?? 8)); + + const data = await amapFetch(url); + if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) { + return []; + } + return mapPois(data.pois as RawPoi[]); +} + +// --------------------------------------------------------------------------- +// Input tips (v3) +// --------------------------------------------------------------------------- + +export interface InputTipResult { + id: string; + name: string; + district: string; + address: string; + lat: number; + lng: number; +} + +export async function getInputTips(params: { + keywords: string; + location?: string; +}): Promise { + const url = new URL("https://restapi.amap.com/v3/assistant/inputtips"); + url.searchParams.set("key", requireAmapApiKey()); + url.searchParams.set("keywords", params.keywords); + url.searchParams.set("datatype", "poi"); + if (params.location) url.searchParams.set("location", params.location); + + const data = await amapFetch(url); + if (data.status !== "1" || !data.tips) return []; + + return ( + data.tips as { + id: string; + name: string; + district?: string; + address?: string; + location?: string; + }[] + ) + .filter((t) => t.location && t.location !== "") + .slice(0, 8) + .map((t) => { + const [lng, lat] = t.location!.split(",").map(Number); + return { + id: t.id, + name: t.name, + district: t.district || "", + address: t.address || "", + lat, + lng, + }; + }); +} + +// --------------------------------------------------------------------------- +// Reverse geocode (v3) +// --------------------------------------------------------------------------- + +export interface RegeoResult { + name: string | null; + formatted: string | null; +} + +export async function reverseGeocode(params: { + lat: number; + lng: number; +}): Promise { + const url = new URL("https://restapi.amap.com/v3/geocode/regeo"); + url.searchParams.set("key", requireAmapApiKey()); + url.searchParams.set("location", `${params.lng},${params.lat}`); + url.searchParams.set("extensions", "base"); + + const data = await amapFetch(url); + if (data.status !== "1" || !data.regeocode) { + return { name: null, formatted: null }; + } + + const regeocode = data.regeocode as Record; + const comp = regeocode.addressComponent as Record | undefined; + const district = (comp?.district || comp?.city || "") as string; + const township = (comp?.township || "") as string; + const neighborhood = ( + (comp?.neighborhood as Record | undefined)?.name || "" + ) as string; + + const name = [district, township, neighborhood] + .filter(Boolean) + .join(" ") + .trim(); + + return { + name: name || (regeocode.formatted_address as string) || null, + formatted: (regeocode.formatted_address as string) || null, + }; +} + +// --------------------------------------------------------------------------- +// Transit direction (v3) +// --------------------------------------------------------------------------- + +export interface TransitResult { + durationMin: number; + distanceKm: number; + description: string; + mode: string; +} + +export function parseTransitSegments( + segments: Record[], +): { description: string; mode: string } { + const parts: string[] = []; + let hasSubway = false; + let hasBus = false; + + for (const seg of segments) { + const bus = seg.bus as + | { buslines?: Record[] } + | undefined; + if (!bus?.buslines?.length) continue; + for (const line of bus.buslines) { + const rawName = String(line.name ?? ""); + const name = rawName.replace(/\(.*?\)$/, "").trim(); + const viaNum = Number(line.via_num ?? 0); + const isSubway = + String(line.type ?? "").includes("地铁") || + String(line.type ?? "").includes("轨道"); + if (isSubway) hasSubway = true; + else hasBus = true; + const dep = (line.departure_stop as Record | undefined) + ?.name; + const arr = (line.arrival_stop as Record | undefined) + ?.name; + const stops = dep && arr ? ` ${dep}→${arr}` : ""; + parts.push( + viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`, + ); + } + } + + const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行"; + const description = parts.length > 0 ? parts.join(" → ") : mode; + return { description, mode }; +} + +export async function getTransitDirection(params: { + originLat: number; + originLng: number; + destLat: number; + destLng: number; + city: string; + cityDest?: string; +}): Promise { + const url = new URL( + "https://restapi.amap.com/v3/direction/transit/integrated", + ); + url.searchParams.set("key", requireAmapApiKey()); + url.searchParams.set("origin", `${params.originLng},${params.originLat}`); + url.searchParams.set( + "destination", + `${params.destLng},${params.destLat}`, + ); + url.searchParams.set("city", params.city); + url.searchParams.set("cityd", params.cityDest ?? params.city); + url.searchParams.set("output", "json"); + + const data = await amapFetch(url); + if (data.status !== "1") return null; + + const route = data.route as Record | undefined; + const transits = route?.transits as Record[] | undefined; + if (!transits?.length) return null; + + const transit = transits[0]; + const durationMin = Math.ceil(Number(transit.duration) / 60); + const distanceKm = + Math.round(Number(route!.distance) / 100) / 10; + const { description, mode } = parseTransitSegments( + (transit.segments as Record[]) ?? [], + ); + return { durationMin, distanceKm, description, mode }; +} + +// --------------------------------------------------------------------------- +// Convenience: POI search used by plan generation (dispatches text vs around) +// --------------------------------------------------------------------------- + +export async function searchPois( + query: string, + searchType: string, + anchorLat: number, + anchorLng: number, +): Promise { + const location = `${anchorLng},${anchorLat}`; + if (searchType === "category") { + return searchPlaceAround({ + keywords: query, + location, + pageSize: 8, + }); + } + return searchPlaceText({ + keywords: query, + location, + pageSize: 8, + }); +} diff --git a/src/lib/blindboxPlanGen.ts b/src/lib/blindboxPlanGen.ts index 5df4cbc..0f678aa 100644 --- a/src/lib/blindboxPlanGen.ts +++ b/src/lib/blindboxPlanGen.ts @@ -6,7 +6,7 @@ * Fallback path: legacy pipeline (runLegacyPlanGeneration) */ import { prisma } from "@/lib/prisma"; -import { requireAmapApiKey } from "@/lib/amap"; +import { searchPois, getTransitDirection } from "@/lib/amap"; import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai"; import { ApiError } from "@/lib/api"; @@ -110,93 +110,6 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge return selected; } -// --------------------------------------------------------------------------- -// Transit segment parser -// --------------------------------------------------------------------------- - -function parseTransitSegments( - segments: Record[], -): { description: string; mode: string } { - const parts: string[] = []; - let hasSubway = false; - let hasBus = false; - - for (const seg of segments) { - const bus = seg.bus as { buslines?: Record[] } | undefined; - if (!bus?.buslines?.length) continue; - for (const line of bus.buslines) { - const rawName = String(line.name ?? ""); - const name = rawName.replace(/\(.*?\)$/, "").trim(); // strip terminal info like "(上海南站--江杨北路)" - const viaNum = Number(line.via_num ?? 0); - const isSubway = String(line.type ?? "").includes("地铁") || String(line.type ?? "").includes("轨道"); - if (isSubway) hasSubway = true; else hasBus = true; - const dep = (line.departure_stop as Record | undefined)?.name; - const arr = (line.arrival_stop as Record | undefined)?.name; - const stops = dep && arr ? ` ${dep}→${arr}` : ""; - parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`); - } - } - - const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行"; - const description = parts.length > 0 ? parts.join(" → ") : mode; - return { description, mode }; -} - -// --------------------------------------------------------------------------- -// POI search (shared by both paths) -// --------------------------------------------------------------------------- - -export async function searchPois( - query: string, - searchType: string, - anchorLat: number, - anchorLng: number, -): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> { - const apiKey = requireAmapApiKey(); - - if (searchType === "category") { - const url = new URL("https://restapi.amap.com/v5/place/around"); - url.searchParams.set("key", apiKey); - url.searchParams.set("location", `${anchorLng},${anchorLat}`); - url.searchParams.set("keywords", query); - url.searchParams.set("radius", "5000"); - url.searchParams.set("show_fields", "business"); - url.searchParams.set("page_size", "8"); - const res = await fetch(url.toString()); - const data = await res.json(); - if (data.status !== "1" || !data.pois?.length) return []; - return mapPois(data.pois); - } - - const url = new URL("https://restapi.amap.com/v5/place/text"); - url.searchParams.set("key", apiKey); - url.searchParams.set("keywords", query); - url.searchParams.set("location", `${anchorLng},${anchorLat}`); - url.searchParams.set("show_fields", "business"); - url.searchParams.set("page_size", "8"); - const res = await fetch(url.toString()); - const data = await res.json(); - if (data.status !== "1" || !data.pois?.length) return []; - return mapPois(data.pois); -} - -function mapPois( - pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[], -) { - return pois - .filter((p) => p.location) - .map((p) => { - const [lng, lat] = (p.location ?? "0,0").split(",").map(Number); - const ratingStr = p.business?.rating; - return { - name: p.name, - address: p.address || "", - lat, - lng, - rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined, - }; - }); -} // --------------------------------------------------------------------------- // Progress messages (kept for legacy path) @@ -347,29 +260,16 @@ function buildAgentTools( required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"], }, execute: async (args) => { - const oLat = Number(args.origin_lat); - const oLng = Number(args.origin_lng); - const dLat = Number(args.dest_lat); - const dLng = Number(args.dest_lng); - const apiKey = requireAmapApiKey(); - const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated"); - url.searchParams.set("key", apiKey); - url.searchParams.set("origin", `${oLng},${oLat}`); - url.searchParams.set("destination", `${dLng},${dLat}`); - url.searchParams.set("city", city); - url.searchParams.set("cityd", city); - url.searchParams.set("output", "json"); try { - const res = await fetch(url.toString()); - const data = await res.json(); - if (data.status !== "1" || !data.route?.transits?.length) { - return JSON.stringify({ error: "未找到公交路线" }); - } - const transit = data.route.transits[0]; - const durationMin = Math.ceil(Number(transit.duration) / 60); - const distanceKm = Math.round(Number(data.route.distance) / 100) / 10; - const { description, mode } = parseTransitSegments(transit.segments ?? []); - return JSON.stringify({ durationMin, distanceKm, description, mode }); + const result = await getTransitDirection({ + originLat: Number(args.origin_lat), + originLng: Number(args.origin_lng), + destLat: Number(args.dest_lat), + destLng: Number(args.dest_lng), + city, + }); + if (!result) return JSON.stringify({ error: "未找到公交路线" }); + return JSON.stringify(result); } catch (e) { console.error("getTravelTimeTool failed:", e); return JSON.stringify({ error: "路线查询失败" }); @@ -544,9 +444,12 @@ async function runLegacyPlanGeneration( }), ); + const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) => + pois.map((p) => ({ ...p, rating: p.rating ?? undefined })); + const candidates: ScheduleContext["candidates"] = {}; for (const result of searchResults) { - candidates[result.query] = result.pois; + candidates[result.query] = toCandidates(result.pois); } const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category"); @@ -570,7 +473,7 @@ async function runLegacyPlanGeneration( }), ); for (const result of catResults) { - candidates[result.query] = result.pois; + candidates[result.query] = toCandidates(result.pois); } } @@ -626,7 +529,6 @@ async function runLegacyPlanGeneration( // --------------------------------------------------------------------------- async function queryTransit( - apiKey: string, oLng: number, oLat: number, dLng: number, @@ -634,19 +536,15 @@ async function queryTransit( city: string, ): Promise<{ durationMin: number; description: string } | null> { try { - const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated"); - url.searchParams.set("key", apiKey); - url.searchParams.set("origin", `${oLng},${oLat}`); - url.searchParams.set("destination", `${dLng},${dLat}`); - url.searchParams.set("city", city); - url.searchParams.set("cityd", city); - url.searchParams.set("output", "json"); - const res = await fetch(url.toString()); - const data = await res.json(); - if (data.status !== "1" || !data.route?.transits?.length) return null; - const transit = data.route.transits[0]; - const { description } = parseTransitSegments(transit.segments ?? []); - return { durationMin: Math.ceil(Number(transit.duration) / 60), description }; + const result = await getTransitDirection({ + originLat: oLat, + originLng: oLng, + destLat: dLat, + destLng: dLng, + city, + }); + if (!result) return null; + return { durationMin: result.durationMin, description: result.description }; } catch (e) { console.error("queryTransit failed:", e); return null; @@ -669,7 +567,6 @@ async function enrichTransitInfo( homeLat: number, homeLng: number, ): Promise { - const apiKey = requireAmapApiKey(); const cityParam = city || "上海"; for (const day of days) { @@ -680,7 +577,7 @@ async function enrichTransitInfo( const dLat = Number(items[0].lat); const dLng = Number(items[0].lng); if (dLat && dLng) { - const result = await queryTransit(apiKey, homeLng, homeLat, dLng, dLat, cityParam); + const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam); if (result) { day.transitFromStart = result.durationMin; day.transitFromStartDescription = result.description; @@ -695,7 +592,7 @@ async function enrichTransitInfo( const dLat = Number(items[i + 1].lat); const dLng = Number(items[i + 1].lng); if (!oLat || !oLng || !dLat || !dLng) continue; - const result = await queryTransit(apiKey, oLng, oLat, dLng, dLat, cityParam); + const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam); if (result) { items[i].transitToNext = result.durationMin; items[i].transitDescription = result.description; @@ -708,7 +605,7 @@ async function enrichTransitInfo( const oLat = Number(last.lat); const oLng = Number(last.lng); if (oLat && oLng) { - const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam); + const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam); if (result) { day.transitToEnd = result.durationMin; day.transitToEndDescription = result.description; diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts new file mode 100644 index 0000000..d2fcb9e --- /dev/null +++ b/src/lib/fetcher.ts @@ -0,0 +1,24 @@ +/** + * Shared SWR fetcher with standard error handling. + */ + +export class FetchError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "FetchError"; + } +} + +export async function fetcher(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new FetchError( + res.status === 404 ? "NOT_FOUND" : res.status === 401 ? "UNAUTHORIZED" : "FETCH_ERROR", + res.status, + ); + } + return res.json(); +} diff --git a/src/lib/planUtils.ts b/src/lib/planUtils.ts new file mode 100644 index 0000000..f8c0d4d --- /dev/null +++ b/src/lib/planUtils.ts @@ -0,0 +1,22 @@ +export function guessCategory(activity: string): string | null { + const lower = activity.toLowerCase(); + if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining"; + if (/手作|工坊|烘焙|插花|陶艺|DIY|体验/.test(lower)) return "experience"; + if (/露营|徒步|赶海|农场|自然|野|营地/.test(lower)) return "nature"; + if (/公园|山|湖|海|户外|骑/.test(lower)) return "outdoor"; + if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment"; + if (/逛街|购物|商场|买/.test(lower)) return "shopping"; + if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports"; + if (/博物馆|展览|美术|书/.test(lower)) return "culture"; + if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation"; + return null; +} + +export function formatDuration(minutes: number): string { + if (minutes >= 60) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h${m}min` : `${h}h`; + } + return `${minutes}min`; +}