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:
+24
-395
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user