"use client"; import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { MapPin, Clock, Navigation, Share2, RefreshCw, Sparkles, ChevronRight, ChevronLeft, CornerDownLeft, GripVertical, Pencil, X, Loader2, } from "lucide-react"; import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CategoryBadge } from "@/components/BlindboxMyIdeas"; import Button from "@/components/Button"; import Modal from "@/components/Modal"; import type { WeekendPlanData, PlanItem } from "@/types"; interface AltSuggestion { activity: string; poi: string; address: string; lat: number; lng: number; reason: string; } interface BlindboxPlanProps { days: WeekendPlanData[]; onAccept: () => void; onRegenerate: () => void; onShare: () => void; onBack: () => void; accepted?: boolean; regenerating?: boolean; onDaysChange?: (newDays: WeekendPlanData[]) => void; /** "lng,lat" 格式,用于 POI 搜索附近优先 */ location?: string; /** 出发地名称,用于显示在出发/返回连接器上 */ startLocationLabel?: string; onRefine?: (instruction: string) => Promise; } 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; } 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`; } 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; } 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 { /* ignore */ } finally { setLoading(false); } }, 400); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [query]); 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}

)}
); } 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, onRegenerate, onShare, onBack, accepted, regenerating, onDaysChange, location, startLocationLabel, onRefine, }: BlindboxPlanProps) { const [dayIndex, setDayIndex] = useState(0); const scrollRef = useRef(null); const currentDay = days[dayIndex]; const hasNext = dayIndex < days.length - 1; const hasPrev = dayIndex > 0; const canEdit = !!onDaysChange; const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null); 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" }); }, [dayIndex]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), ); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id || !onDaysChange) return; const items = days[dayIndex].items; const ids = items.map((_, i) => `${dayIndex}-${i}`); const oldIndex = ids.indexOf(String(active.id)); const newIndex = ids.indexOf(String(over.id)); if (oldIndex === -1 || newIndex === -1) return; const newDays = days.map((day, di) => di === dayIndex ? { ...day, items: arrayMove(day.items, oldIndex, newIndex) } : day, ); onDaysChange(newDays); } function handleSaveDraft() { if (!draft || !editingItem || !onDaysChange) return; const newDays = days.map((day, di) => di !== editingItem.dayIndex ? day : { ...day, items: day.items.map((item, ii) => ii === editingItem.itemIndex ? draft : item, ), }, ); 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), }; } if (di === targetDayIndex) { return { ...day, items: [...day.items, draft] }; } return day; }); onDaysChange(newDays); setEditingItem(null); setDraft(null); } async function handleRefine() { if (!refineInput.trim() || refining || !onRefine) return; setRefining(true); try { await onRefine(refineInput.trim()); setRefineInput(""); } finally { setRefining(false); } } 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 */}
{currentDay.date} · 行程规划 {days.length > 1 && (
{days.map((day, i) => (
)} {currentDay.summary && ( {currentDay.summary} )}
{/* Scrollable timeline */}
`${dayIndex}-${i}`)} strategy={verticalListSortingStrategy} > {/* Transit from home to first activity */} {currentDay.transitFromStart != null && (
从{startLocationLabel ?? "出发地"}出发 {currentDay.transitFromStartDescription && ( {currentDay.transitFromStartDescription} )} 约 {currentDay.transitFromStart} 分钟
)} {currentDay.items.map((item, i) => ( { setEditingItem({ dayIndex, itemIndex: i }); setDraft({ ...item }); }} /> {/* Transit connector to next activity */} {item.transitToNext != null && i < currentDay.items.length - 1 && (
{item.transitDescription && ( {item.transitDescription} )} 约 {item.transitToNext} 分钟
)} {/* Transit back home after last activity */} {i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
返回{startLocationLabel ?? "出发地"} {currentDay.transitToEndDescription && ( {currentDay.transitToEndDescription} )} 约 {currentDay.transitToEnd} 分钟
)}
))}
{/* Back to pool — at end of scroll content */}
{/* Fixed bottom bar — actions + day navigation */}
{/* Refine input (Plan A) */} {onRefine && (
setRefineInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleRefine(); }} placeholder="告诉 AI 你想怎么改..." disabled={refining} className="h-9 flex-1 rounded-xl bg-surface px-3 text-sm outline-none ring-1 ring-border focus:ring-purple-600 disabled:opacity-50" />
)} {/* Day navigation */} {days.length > 1 && (
{hasPrev && ( setDayIndex(dayIndex - 1)} className="flex items-center gap-1 rounded-full bg-surface px-3 py-1.5 text-[11px] font-bold text-purple-400 ring-1 ring-border/60 active:bg-elevated" > {days[dayIndex - 1].date} )} {dayIndex + 1} / {days.length} {hasNext && ( setDayIndex(dayIndex + 1)} className="flex items-center gap-1 rounded-full bg-purple-600/15 px-3 py-1.5 text-[11px] font-bold text-purple-400 active:bg-purple-600/25" > {days[dayIndex + 1].date} )}
)} {/* Action buttons */}
{accepted ? ( ) : ( <> )}
{/* 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 && ( )}
)}
); }