refactor(P1): 5 项代码质量改进 — 消除重复、拆分巨型组件、统一基础设施

Task 4: 统一 amap.ts 为完整 API 客户端
- 扩展 amap.ts 为统一客户端(amapFetch 8s 超时 + 错误处理)
- 导出 searchPlaceText/searchPlaceAround/getInputTips/reverseGeocode/getTransitDirection
- 精简 4 个 location route 为单行调用,blindboxPlanGen 删除 ~80 行内联 API 代码

Task 2: 抽取 ShareCardShell 消除三兄弟重复
- 新建 ShareCardShell.tsx 共享外框/背景/品牌头/QR 底部
- RestaurantShareCard 406→268 行,BlindboxShareCard 341→173 行,BlindboxPlanShareCard 277→159 行

Task 3: 拆分 BlindboxPlan.tsx (742→371 行)
- 提取 planUtils.ts (guessCategory + formatDuration)
- 提取 PoiSearchField / SortablePlanItem / PlanItemEditModal 三个独立组件

Task 1: 拆分 blindbox/[code]/page.tsx 上帝组件 (1300→509 行)
- 提取 useBlindboxRoom / useBlindboxIdeas / useBlindboxPlan / useBlindboxDraw 四个 hooks
- 提取 BlindboxPoolPhase / BlindboxRevealPhase 两个子组件
- 主页面仅保留 phase 协调 + hook 组装 + 子组件渲染

Task 5: 统一 SWR 数据获取层
- 新建 fetcher.ts (FetchError 携带 status,401 不重试)
- 新建 useBlindboxRooms / useAchievements / useFavorites SWR hooks
- useRoomPolling 改用共享 fetcher
- blindbox 大厅/成就/个人中心页面删除手写 fetch 样板代码
- JWT 过期时自动弹出登录框而非反复重试
This commit is contained in:
2026-03-02 18:05:06 +08:00
parent ce76980fe5
commit 6bb0e65d4c
34 changed files with 2759 additions and 2669 deletions
+24 -395
View File
@@ -3,8 +3,6 @@
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MapPin,
Clock,
Navigation,
Share2,
RefreshCw,
@@ -12,9 +10,6 @@ import {
ChevronRight,
ChevronLeft,
CornerDownLeft,
GripVertical,
Pencil,
X,
Loader2,
} from "lucide-react";
import {
@@ -27,26 +22,15 @@ import {
} 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 SortablePlanItem from "@/components/SortablePlanItem";
import PlanItemEditModal from "@/components/PlanItemEditModal";
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;
@@ -56,217 +40,11 @@ interface BlindboxPlanProps {
accepted?: boolean;
regenerating?: boolean;
onDaysChange?: (newDays: WeekendPlanData[]) => void;
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
location?: string;
/** 出发地名称,用于显示在出发/返回连接器上 */
startLocationLabel?: string;
onRefine?: (instruction: string) => Promise<void>;
}
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<PoiSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const lastSelectedRef = useRef(poi);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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]);
return (
<div className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => 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 && (
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
)}
</div>
{open && suggestions.length > 0 && (
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
{suggestions.map((s) => (
<button
key={s.id}
type="button"
onClick={() => {
lastSelectedRef.current = s.name;
setQuery(s.name);
setOpen(false);
setSuggestions([]);
onSelect(s);
}}
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
>
<span className="text-xs font-medium text-foreground">{s.name}</span>
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
</button>
))}
</div>
)}
{address && !open && (
<p className="flex items-center gap-1 text-[10px] text-dim">
<MapPin size={9} className="shrink-0" />
{address}
</p>
)}
</div>
);
}
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 (
<div ref={setNodeRef} style={style} className="relative mb-5">
<div className="absolute -left-6 top-4 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
<div className="h-2 w-2 rounded-full bg-purple-400" />
</div>
<div className="rounded-2xl bg-surface/80 p-4 ring-1 ring-border/80">
<div className="flex items-start gap-2.5">
{canEdit && (
<button
{...attributes}
{...listeners}
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
aria-label="拖拽排序"
>
<GripVertical size={14} />
</button>
)}
<span className="mt-0.5 min-w-[38px] text-sm font-black text-purple-400">{item.time}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<CategoryBadge category={guessCategory(item.activity)} />
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
</div>
<div className="mt-2 flex items-center gap-1 text-[11px] text-muted">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{item.poi}</span>
</div>
{item.address && (
<p className="mt-1 truncate text-[10px] text-dim">{item.address}</p>
)}
<div className="mt-2.5 flex items-center gap-3">
<span className="flex items-center gap-1 text-[10px] text-dim">
<Clock size={9} />
{formatDuration(item.duration)}
</span>
{item.lat !== 0 && item.lng !== 0 && (
<a
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
>
<Navigation size={9} />
</a>
)}
</div>
{item.reason && (
<p className="mt-2.5 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-dim italic">
{item.reason}
</p>
)}
</div>
{canEdit && (
<button
onClick={onEdit}
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
aria-label="编辑"
>
<Pencil size={13} />
</button>
)}
</div>
</div>
</div>
);
}
export default function BlindboxPlan({
days,
onAccept,
@@ -291,8 +69,6 @@ export default function BlindboxPlan({
const [draft, setDraft] = useState<PlanItem | null>(null);
const [refineInput, setRefineInput] = useState("");
const [refining, setRefining] = useState(false);
const [suggestingAlt, setSuggestingAlt] = useState(false);
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
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 (
<div className="flex min-h-0 flex-1 flex-col">
{/* Day header — sticky top */}
{/* Day header */}
<div className="shrink-0 pb-3 text-center">
<motion.div
className="inline-flex items-center gap-1.5 rounded-full bg-purple-600/15 px-3 py-1 text-xs font-bold text-purple-400"
@@ -430,10 +183,7 @@ export default function BlindboxPlan({
</div>
{/* Scrollable timeline */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-none">
<AnimatePresence mode="wait">
<motion.div
key={dayIndex}
@@ -455,7 +205,7 @@ export default function BlindboxPlan({
items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)}
strategy={verticalListSortingStrategy}
>
{/* Transit from home to first activity */}
{/* Transit from home */}
{currentDay.transitFromStart != null && (
<div className="flex items-start gap-1.5 py-2 pl-1">
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -480,14 +230,13 @@ export default function BlindboxPlan({
<SortablePlanItem
id={`${dayIndex}-${i}`}
item={item}
index={i}
canEdit={canEdit}
onEdit={() => {
setEditingItem({ dayIndex, itemIndex: i });
setDraft({ ...item });
}}
/>
{/* Transit connector to next activity */}
{/* Transit connector */}
{item.transitToNext != null && i < currentDay.items.length - 1 && (
<div className="flex items-start gap-1.5 py-2 pl-1">
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -501,7 +250,7 @@ export default function BlindboxPlan({
</div>
</div>
)}
{/* Transit back home after last activity */}
{/* Transit back home */}
{i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
<div className="flex items-start gap-1.5 py-2 pl-1">
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -523,7 +272,7 @@ export default function BlindboxPlan({
</motion.div>
</AnimatePresence>
{/* Back to pool — at end of scroll content */}
{/* Back to pool */}
<div className="mt-6 flex justify-center pb-4">
<button
onClick={onBack}
@@ -535,9 +284,8 @@ export default function BlindboxPlan({
</div>
</div>
{/* Fixed bottom bar — actions + day navigation */}
{/* Fixed bottom bar */}
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
{/* Refine input (Plan A) */}
{onRefine && (
<div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2">
<input
@@ -558,7 +306,6 @@ export default function BlindboxPlan({
</div>
)}
{/* Day navigation */}
{days.length > 1 && (
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
{hasPrev && (
@@ -589,34 +336,17 @@ export default function BlindboxPlan({
</div>
)}
{/* Action buttons */}
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
{accepted ? (
<Button
onClick={onShare}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
<Button onClick={onShare} variant="purple" shape="pill" icon={<Share2 size={14} />}>
</Button>
) : (
<>
<Button
onClick={onAccept}
variant="purple"
shape="pill"
icon={<Sparkles size={14} />}
>
<Button onClick={onAccept} variant="purple" shape="pill" icon={<Sparkles size={14} />}>
</Button>
<Button
onClick={onRegenerate}
variant="secondary"
shape="pill"
loading={regenerating}
icon={<RefreshCw size={14} />}
>
<Button onClick={onRegenerate} variant="secondary" shape="pill" loading={regenerating} icon={<RefreshCw size={14} />}>
</Button>
</>
@@ -625,118 +355,17 @@ export default function BlindboxPlan({
</div>
{/* Edit item modal */}
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet">
{draft && editingItem && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-heading"></h3>
<button
onClick={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }}
className="text-muted active:text-foreground"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="text"
value={draft.activity}
onChange={(e) => setDraft({ ...draft, activity: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<div className="flex gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="time"
value={draft.time}
onChange={(e) => setDraft({ ...draft, time: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="number"
step={15}
min={15}
value={draft.duration}
onChange={(e) => setDraft({ ...draft, duration: Number(e.target.value) })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
</div>
<PoiSearchField
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
poi={draft.poi}
address={draft.address}
location={location}
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
/>
{/* AI 推荐替代 (Plan B) */}
{altSuggestions.length === 0 ? (
<button
onClick={handleSuggestAlt}
disabled={suggestingAlt}
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
>
{suggestingAlt
? <><Loader2 size={12} className="animate-spin" /> ...</>
: <><Sparkles size={12} /> AI </>}
</button>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-muted"></span>
{altSuggestions.map((alt, i) => (
<button
key={i}
onClick={() => {
setDraft({ ...draft, ...alt });
setAltSuggestions([]);
}}
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
>
<p className="text-xs font-bold text-heading">{alt.activity}</p>
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
<p className="text-[10px] text-dim italic">{alt.reason}</p>
</button>
))}
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim"></button>
</div>
)}
{days.length > 1 && (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<select
value={editingItem.dayIndex}
onChange={(e) => handleMoveToDayIndex(Number(e.target.value))}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
>
{days.map((day, i) => (
<option key={day.date} value={i}>
{day.date}
{i === editingItem.dayIndex ? "(当前)" : ""}
</option>
))}
</select>
</label>
)}
</div>
<Button onClick={handleSaveDraft} variant="purple" shape="pill">
</Button>
</div>
)}
</Modal>
<PlanItemEditModal
open={!!editingItem && !!draft}
editingItem={editingItem}
draft={draft}
days={days}
location={location}
onDraftChange={setDraft}
onSave={handleSaveDraft}
onClose={() => { setEditingItem(null); setDraft(null); }}
onMoveToDayIndex={handleMoveToDayIndex}
/>
</div>
);
}
+120 -238
View File
@@ -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 (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#0a0810",
position: "relative",
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -30,
right: -20,
width: 140,
height: 140,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
gap: 8,
position: "relative",
}}
>
<span style={{ fontSize: 18 }}>📋</span>
<div>
<ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
{/* Each day */}
{days.map((day, dayIdx) => (
<div key={day.date}>
{/* Room + date badge */}
<div style={{ textAlign: "center", padding: "16px 20px 8px" }}>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
}}
>
NoWhatever
{roomName} · {day.date}
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · WEEKEND PLAN
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
}}
/>
{/* Each day */}
{days.map((day, dayIdx) => (
<div key={day.date}>
{/* Room + date badge */}
<div style={{ textAlign: "center", padding: "16px 20px 8px" }}>
{day.summary && (
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
fontSize: 11,
color: "rgba(255,255,255,0.5)",
marginTop: 6,
lineHeight: 1.5,
}}
>
{roomName} · {day.date}
{day.summary}
</div>
{day.summary && (
<div
style={{
fontSize: 11,
color: "rgba(255,255,255,0.5)",
marginTop: 6,
lineHeight: 1.5,
}}
>
{day.summary}
</div>
)}
</div>
{/* Timeline items */}
<div style={{ padding: "12px 20px 20px" }}>
{day.items.map((item, i) => (
<div
key={i}
style={{
display: "flex",
gap: 10,
marginBottom: i < day.items.length - 1 ? 12 : 0,
position: "relative",
}}
>
<div
style={{
width: 40,
flexShrink: 0,
fontSize: 12,
fontWeight: 900,
color: "#a78bfa",
textAlign: "right",
paddingTop: 2,
}}
>
{item.time}
</div>
<div style={{ width: 16, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center" }}>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#7c3aed",
marginTop: 5,
flexShrink: 0,
}}
/>
{i < day.items.length - 1 && (
<div style={{ width: 1, flex: 1, background: "rgba(124,58,237,0.2)", marginTop: 4 }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#ffffff" }}>
{item.activity}
</div>
<div style={{ fontSize: 10, color: "rgba(167,139,250,0.5)", marginTop: 3 }}>
📍 {item.poi}
</div>
{item.reason && (
<div
style={{
fontSize: 9,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
fontStyle: "italic",
lineHeight: 1.4,
}}
>
{item.reason}
</div>
)}
</div>
</div>
))}
</div>
{/* Separator between days */}
{dayIdx < days.length - 1 && (
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.15), transparent)",
}}
/>
)}
</div>
))}
{/* Contract stamp */}
<div
style={{
textAlign: "center",
padding: "0 20px 16px",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.3)",
}}
>
</div>
{/* Timeline items */}
<div style={{ padding: "12px 20px 20px" }}>
{day.items.map((item, i) => (
<div
key={i}
style={{
display: "flex",
gap: 10,
marginBottom: i < day.items.length - 1 ? 12 : 0,
position: "relative",
}}
>
<div
style={{
width: 40,
flexShrink: 0,
fontSize: 12,
fontWeight: 900,
color: "#a78bfa",
textAlign: "right",
paddingTop: 2,
}}
>
{item.time}
</div>
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#0a0810"
<div style={{ width: 16, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center" }}>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#7c3aed",
marginTop: 5,
flexShrink: 0,
}}
/>
{i < day.items.length - 1 && (
<div style={{ width: 1, flex: 1, background: "rgba(124,58,237,0.2)", marginTop: 4 }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#ffffff" }}>
{item.activity}
</div>
<div style={{ fontSize: 10, color: "rgba(167,139,250,0.5)", marginTop: 3 }}>
📍 {item.poi}
</div>
{item.reason && (
<div
style={{
fontSize: 9,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
fontStyle: "italic",
lineHeight: 1.4,
}}
>
{item.reason}
</div>
)}
</div>
</div>
))}
</div>
{/* Separator between days */}
{dayIdx < days.length - 1 && (
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.15), transparent)",
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "rgba(255,255,255,0.7)" }}>
便
</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", marginTop: 3 }}>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
)}
</div>
))}
{/* Contract stamp */}
<div
style={{
textAlign: "center",
padding: "0 20px 16px",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.3)",
}}
>
</div>
</div>
</ShareCardShell>
);
}
+177
View File
@@ -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<HTMLInputElement | null>;
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 (
<motion.div
key="pool"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex w-full gap-2">
<input
ref={inputRef}
type="text"
placeholder="塞入一个疯狂的周末想法..."
value={input}
onChange={(e) => { 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"
/>
<button
onClick={onSubmit}
disabled={!input.trim() || submitting}
aria-label="提交想法"
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
>
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
{!input && (
<div className="flex w-full flex-wrap items-center gap-1.5">
{suggestionsSource === "ai" ? (
<Sparkles size={13} className="mr-0.5 shrink-0 text-purple-400/80" />
) : (
<Lightbulb size={13} className="mr-0.5 shrink-0 text-amber-500/80" />
)}
{suggestionsLoading ? (
<>
{[1, 2, 3].map((i) => (
<div key={i} className="h-6 w-20 animate-pulse rounded-full bg-surface/60 ring-1 ring-border/40" />
))}
</>
) : (
suggestions.map((s) => (
<button
key={s}
onClick={() => { setInput(s); inputRef.current?.focus(); }}
className="rounded-full bg-surface/80 px-2.5 py-1 text-xs text-secondary ring-1 ring-border/60 transition-all hover:bg-purple-600/10 hover:text-purple-400 hover:ring-purple-600/30 active:scale-95"
>
{s}
</button>
))
)}
<button
onClick={onRefreshSuggestions}
disabled={suggestionsLoading}
aria-label="换一批灵感"
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-secondary disabled:opacity-30"
>
{suggestionsLoading ? <Loader2 size={12} className="animate-spin" /> : <Shuffle size={12} />}
</button>
</div>
)}
<div className="flex w-full gap-2">
<motion.button
onClick={onDraw}
disabled={poolCount === 0}
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={18} />
</motion.button>
<motion.button
onClick={() => {
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 }}
>
<Calendar size={18} />
</motion.button>
</div>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
{planDays.length > 0 && !planAccepted && (
<motion.button
onClick={onShowPlan}
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 }}
>
<div className="flex items-center gap-2">
<Sparkles size={14} className="text-purple-400" />
<span className="text-xs font-bold text-purple-300"></span>
</div>
<ChevronRight size={14} className="text-purple-400" />
</motion.button>
)}
</motion.div>
);
}
+80
View File
@@ -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 (
<motion.div
key="reveal"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
>
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
<div className="relative z-10 text-center">
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
</p>
<motion.p
className="mt-4 text-xl font-black leading-relaxed text-white"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{idea.content}
</motion.p>
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
{idea.user && (
<span>{idea.user.avatar} {idea.user.username} </span>
)}
{idea.drawnBy && (
<>
<span>·</span>
<span>{idea.drawnBy.avatar} {idea.drawnBy.username} </span>
</>
)}
</div>
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={onShare}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button>
<Button
onClick={onContinue}
variant="secondary"
shape="pill"
>
</Button>
</div>
</motion.div>
);
}
+133 -301
View File
@@ -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 (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
{/* Bottom-left glow */}
<div
style={{
borderRadius: 18.5,
background: "#0a0810",
position: "absolute",
bottom: 80,
left: -30,
width: 120,
height: 120,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
}}
/>
{/* Room name badge */}
<div
style={{
textAlign: "center",
padding: "20px 20px 8px",
position: "relative",
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
}}
>
{roomName}
</div>
</div>
{/* Idea card */}
<div style={{ padding: "0 16px 20px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(167,139,250,0.1)",
padding: "28px 24px",
position: "relative",
}}
>
{/* 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) => (
<div key={i} style={{ position: "absolute", width: 14, height: 14, ...style }} />
))}
<div
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
fontSize: 22,
fontWeight: 900,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.6,
letterSpacing: "0.02em",
}}
>
{idea}
</div>
<div
style={{
width: 48,
height: 1,
margin: "16px auto",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
}}
/>
)}
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -30,
right: -20,
width: 140,
height: 140,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
}}
/>
<div
style={{
position: "absolute",
bottom: 80,
left: -30,
width: 120,
height: 120,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
gap: 8,
position: "relative",
}}
>
<span style={{ fontSize: 18 }}>🎁</span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · ADVENTURE ROULETTE
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
}}
/>
{/* Room name badge */}
<div
style={{
textAlign: "center",
padding: "20px 20px 8px",
position: "relative",
}}
>
<div
style={{
textAlign: "center",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
fontWeight: 500,
color: "rgba(167,139,250,0.35)",
}}
>
{roomName}
</div>
</div>
{/* Idea card */}
<div style={{ padding: "0 16px 20px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(167,139,250,0.1)",
padding: "28px 24px",
position: "relative",
}}
>
{/* Corner decorations */}
<div
style={{
position: "absolute",
top: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
top: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopRightRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomRightRadius: 3,
}}
/>
<div
style={{
fontSize: 22,
fontWeight: 900,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.6,
letterSpacing: "0.02em",
}}
>
{idea}
</div>
<div
style={{
width: 48,
height: 1,
margin: "16px auto",
background:
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
}}
/>
<div
style={{
textAlign: "center",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.35)",
}}
>
</div>
</div>
</div>
{/* Attribution */}
{(submitter || drawer) && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "0 20px 16px",
}}
>
{submitter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
<span>{submitter.username} </span>
</div>
)}
{submitter && drawer && (
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
)}
{drawer && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
<span>{drawer.username} </span>
</div>
)}
</div>
)}
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#0a0810"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.2)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
{/* Attribution */}
{(submitter || drawer) && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "0 20px 16px",
}}
>
{submitter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
<span>{submitter.username} </span>
</div>
)}
{submitter && drawer && (
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
)}
{drawer && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
<span>{drawer.username} </span>
</div>
)}
</div>
)}
</ShareCardShell>
);
}
+178
View File
@@ -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<AltSuggestion[]>([]);
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 (
<Modal open={open} onClose={handleClose} variant="sheet">
{draft && editingItem && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-heading"></h3>
<button onClick={handleClose} className="text-muted active:text-foreground">
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="text"
value={draft.activity}
onChange={(e) => onDraftChange({ ...draft, activity: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<div className="flex gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="time"
value={draft.time}
onChange={(e) => onDraftChange({ ...draft, time: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="number"
step={15}
min={15}
value={draft.duration}
onChange={(e) => onDraftChange({ ...draft, duration: Number(e.target.value) })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
</div>
<PoiSearchField
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
poi={draft.poi}
address={draft.address}
location={location}
onSelect={(s) => onDraftChange({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
/>
{/* AI alternative suggestions */}
{altSuggestions.length === 0 ? (
<button
onClick={handleSuggestAlt}
disabled={suggestingAlt}
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
>
{suggestingAlt
? <><Loader2 size={12} className="animate-spin" /> ...</>
: <><Sparkles size={12} /> AI </>}
</button>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-muted"></span>
{altSuggestions.map((alt, i) => (
<button
key={i}
onClick={() => {
onDraftChange({ ...draft, ...alt });
setAltSuggestions([]);
}}
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
>
<p className="text-xs font-bold text-heading">{alt.activity}</p>
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
<p className="text-[10px] text-dim italic">{alt.reason}</p>
</button>
))}
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim"></button>
</div>
)}
{days.length > 1 && (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<select
value={editingItem.dayIndex}
onChange={(e) => onMoveToDayIndex(Number(e.target.value))}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
>
{days.map((day, i) => (
<option key={day.date} value={i}>
{day.date}
{i === editingItem.dayIndex ? "(当前)" : ""}
</option>
))}
</select>
</label>
)}
</div>
<Button onClick={onSave} variant="purple" shape="pill">
</Button>
</div>
)}
</Modal>
);
}
+98
View File
@@ -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<PoiSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const lastSelectedRef = useRef(poi);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => 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 && (
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
)}
</div>
{open && suggestions.length > 0 && (
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
{suggestions.map((s) => (
<button
key={s.id}
type="button"
onClick={() => {
lastSelectedRef.current = s.name;
setQuery(s.name);
setOpen(false);
setSuggestions([]);
onSelect(s);
}}
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
>
<span className="text-xs font-medium text-foreground">{s.name}</span>
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
</button>
))}
</div>
)}
{address && !open && (
<p className="flex items-center gap-1 text-[10px] text-dim">
<MapPin size={9} className="shrink-0" />
{address}
</p>
)}
</div>
);
}
+205 -343
View File
@@ -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 (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<ShareCardShell theme={theme} cardRef={cardRef} bgDataUrl={bgDataUrl}>
{/* Second glow override (bottom-left) */}
<div
style={{
borderRadius: 18.5,
background: "#08080a",
position: "absolute",
bottom: 60,
left: -40,
width: 120,
height: 120,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
}}
/>
{/* Hero section */}
<div
style={{
padding: "24px 20px 20px",
textAlign: "center",
position: "relative",
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
<div style={{ fontSize: 40, lineHeight: 1 }}>
{isUnanimous ? "🎉" : "🏆"}
</div>
<div
style={{
position: "absolute",
top: -40,
right: -30,
width: 160,
height: 160,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
}}
/>
<div
style={{
position: "absolute",
bottom: 60,
left: -40,
width: 120,
height: 120,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "relative",
fontSize: 30,
fontWeight: 900,
color: "#ffffff",
marginTop: 12,
letterSpacing: "0.08em",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}></span>
<div>
{verb}
</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 12,
padding: "6px 16px",
borderRadius: 100,
background: accentBg,
border: `1px solid ${accentTo}20`,
}}
>
{isUnanimous && (
<Zap size={12} style={{ color: accentText, fill: accentText }} />
)}
<span style={{ fontSize: 11, fontWeight: 700, color: accentText }}>
{isUnanimous
? `默契度 100% · ${userCount}人全员一致`
: `${matchLikes}/${userCount} 人选了这家`}
</span>
{isUnanimous && (
<Zap size={12} style={{ color: accentText, fill: accentText }} />
)}
</div>
</div>
{/* Restaurant card */}
<div style={{ padding: "0 16px 16px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)",
overflow: "hidden",
}}
>
{imageDataUrl && (
<img
src={imageDataUrl}
alt={restaurant.name}
style={{
width: "100%",
height: 150,
objectFit: "cover",
display: "block",
}}
/>
)}
<div style={{ padding: "14px 16px 16px" }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 13,
fontSize: 17,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
lineHeight: 1.3,
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · PANIC MODE
{restaurant.name}
</div>
{restaurant.category && (
<span
style={{
flexShrink: 0,
padding: "3px 8px",
borderRadius: 100,
background: accentBg,
fontSize: 10,
fontWeight: 600,
color: accentText,
whiteSpace: "nowrap",
}}
>
{restaurant.category}
</span>
)}
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
}}
/>
{/* Hero section */}
<div
style={{
padding: "24px 20px 20px",
textAlign: "center",
position: "relative",
}}
>
<div style={{ fontSize: 40, lineHeight: 1 }}>
{isUnanimous ? "🎉" : "🏆"}
</div>
<div
style={{
fontSize: 30,
fontWeight: 900,
color: "#ffffff",
marginTop: 12,
letterSpacing: "0.08em",
}}
>
{verb}
</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 12,
padding: "6px 16px",
borderRadius: 100,
background: accentBg,
border: `1px solid ${accentTo}20`,
}}
>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
<span
<div
style={{
fontSize: 11,
fontWeight: 700,
color: accentText,
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 10,
fontSize: 12,
}}
>
{isUnanimous
? `默契度 100% · ${userCount}人全员一致`
: `${matchLikes}/${userCount} 人选了这家`}
</span>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
</div>
</div>
{/* Restaurant card */}
<div style={{ padding: "0 16px 16px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)",
overflow: "hidden",
}}
>
{imageDataUrl && (
<img
src={imageDataUrl}
alt={restaurant.name}
style={{
width: "100%",
height: 150,
objectFit: "cover",
display: "block",
}}
/>
)}
<div style={{ padding: "14px 16px 16px" }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 17,
fontWeight: 800,
color: "#ffffff",
lineHeight: 1.3,
}}
>
{restaurant.name}
</div>
{restaurant.category && (
<span
style={{
flexShrink: 0,
padding: "3px 8px",
borderRadius: 100,
background: accentBg,
fontSize: 10,
fontWeight: 600,
color: accentText,
whiteSpace: "nowrap",
}}
>
{restaurant.category}
</span>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 10,
fontSize: 12,
}}
>
{restaurant.rating > 0 && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#e5e7eb",
fontWeight: 600,
}}
>
<Star
size={13}
style={{ color: "#fbbf24", fill: "#fbbf24" }}
/>
{restaurant.rating}
</span>
)}
{restaurant.price && restaurant.price !== "未知" && (
<span style={{ fontWeight: 700, color: accentText }}>
{restaurant.price}
</span>
)}
{restaurant.distance && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#9ca3af",
}}
>
<MapPin size={12} style={{ color: "#6b7280" }} />
{restaurant.distance}
</span>
)}
</div>
{restaurant.address && (
<div
style={{
marginTop: 8,
fontSize: 11,
color: "#6b7280",
lineHeight: 1.5,
}}
>
📍 {restaurant.address}
</div>
)}
{restaurant.tag && (
<div
{restaurant.rating > 0 && (
<span
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 10,
alignItems: "center",
gap: 3,
color: "#e5e7eb",
fontWeight: 600,
}}
>
{restaurant.tag
.split(",")
.slice(0, 4)
.map((t) => (
<span
key={t}
style={{
padding: "2px 8px",
borderRadius: 4,
background: "rgba(255,255,255,0.05)",
fontSize: 10,
fontWeight: 500,
color: "#9ca3af",
}}
>
{t.trim()}
</span>
))}
</div>
<Star size={13} style={{ color: "#fbbf24", fill: "#fbbf24" }} />
{restaurant.rating}
</span>
)}
{restaurant.price && restaurant.price !== "未知" && (
<span style={{ fontWeight: 700, color: accentText }}>
{restaurant.price}
</span>
)}
{restaurant.distance && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#9ca3af",
}}
>
<MapPin size={12} style={{ color: "#6b7280" }} />
{restaurant.distance}
</span>
)}
</div>
</div>
</div>
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#08080a"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
{restaurant.address && (
<div
style={{
marginTop: 8,
fontSize: 11,
color: "#6b7280",
lineHeight: 1.5,
}}
>
📍 {restaurant.address}
</div>
)}
{restaurant.tag && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 10,
}}
>
{restaurant.tag
.split(",")
.slice(0, 4)
.map((t) => (
<span
key={t}
style={{
padding: "2px 8px",
borderRadius: 4,
background: "rgba(255,255,255,0.05)",
fontSize: 10,
fontWeight: 500,
color: "#9ca3af",
}}
>
{t.trim()}
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>
</ShareCardShell>
);
}
+180
View File
@@ -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<HTMLDivElement | null>;
bgDataUrl?: string | null;
children: ReactNode;
}) {
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: theme.gradientBorder,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: theme.bgColor,
position: "relative",
overflow: "hidden",
}}
>
{/* Background image */}
{bgDataUrl && (
<img
src={bgDataUrl}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
}}
/>
)}
{/* Decorative glows */}
{theme.glows.map((g, i) => (
<div
key={i}
style={{
position: "absolute",
top: g.top,
right: g.right,
width: g.width,
height: g.height,
borderRadius: "50%",
background: `radial-gradient(circle, ${g.color}, transparent 70%)`,
}}
/>
))}
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "relative",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}>{theme.emoji}</span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
{theme.tagline}
</div>
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: theme.accentLine,
}}
/>
{/* Card-specific content */}
{children}
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor={theme.qrFgColor}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
+94
View File
@@ -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 (
<div ref={setNodeRef} style={style} className="relative mb-5">
<div className="absolute -left-6 top-4 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
<div className="h-2 w-2 rounded-full bg-purple-400" />
</div>
<div className="rounded-2xl bg-surface/80 p-4 ring-1 ring-border/80">
<div className="flex items-start gap-2.5">
{canEdit && (
<button
{...attributes}
{...listeners}
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
aria-label="拖拽排序"
>
<GripVertical size={14} />
</button>
)}
<span className="mt-0.5 min-w-[38px] text-sm font-black text-purple-400">{item.time}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<CategoryBadge category={guessCategory(item.activity)} />
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
</div>
<div className="mt-2 flex items-center gap-1 text-[11px] text-muted">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{item.poi}</span>
</div>
{item.address && (
<p className="mt-1 truncate text-[10px] text-dim">{item.address}</p>
)}
<div className="mt-2.5 flex items-center gap-3">
<span className="flex items-center gap-1 text-[10px] text-dim">
<Clock size={9} />
{formatDuration(item.duration)}
</span>
{item.lat !== 0 && item.lng !== 0 && (
<a
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
>
<Navigation size={9} />
</a>
)}
</div>
{item.reason && (
<p className="mt-2.5 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-dim italic">
{item.reason}
</p>
)}
</div>
{canEdit && (
<button
onClick={onEdit}
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
aria-label="编辑"
>
<Pencil size={13} />
</button>
)}
</div>
</div>
</div>
);
}