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
+6 -30
View File
@@ -19,17 +19,11 @@ import ContractHistoryItem from "@/components/ContractHistoryItem";
import EmptyState from "@/components/EmptyState"; import EmptyState from "@/components/EmptyState";
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton"; import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation"; import { buildNavUrl } from "@/lib/navigation";
import type { DecisionRecord, ContractRecord, Restaurant } from "@/types"; import { useAchievements } from "@/hooks/useAchievements";
import type { Restaurant } from "@/types";
type Tab = "decisions" | "contracts"; type Tab = "decisions" | "contracts";
interface Stats {
totalDecisions: number;
totalContracts: number;
completedContracts: number;
completionRate: number;
}
function firstImage(r: Restaurant): string { function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0]; if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image; const legacy = (r as unknown as Record<string, unknown>).image;
@@ -38,16 +32,8 @@ function firstImage(r: Restaurant): string {
export default function AchievementsPage() { export default function AchievementsPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("decisions"); const [tab, setTab] = useState<Tab>("decisions");
const [stats, setStats] = useState<Stats>({ const [userId, setUserId] = useState<string | undefined>(undefined);
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
});
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
const [contracts, setContracts] = useState<ContractRecord[]>([]);
useEffect(() => { useEffect(() => {
if (!isRegistered()) { if (!isRegistered()) {
@@ -55,21 +41,11 @@ export default function AchievementsPage() {
return; return;
} }
const p = getCachedProfile(); const p = getCachedProfile();
if (!p) return; if (p) setUserId(p.id);
(async () => {
try {
const res = await fetch(`/api/user/achievements?userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
setStats(data.stats);
setDecisions(data.decisions);
setContracts(data.contracts);
} catch (e) { console.error("AchievementsPage: fetch failed:", e); }
finally { setLoading(false); }
})();
}, [router]); }, [router]);
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
const statCards = [ const statCards = [
{ {
label: "决策记录", label: "决策记录",
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler, ApiError } from "@/lib/api";
import { suggestAlternativeItems } from "@/lib/ai"; import { suggestAlternativeItems } from "@/lib/ai";
import { searchPois } from "@/lib/blindboxPlanGen"; import { searchPois } from "@/lib/amap";
export const POST = apiHandler(async (req) => { export const POST = apiHandler(async (req) => {
const { activity, time, location } = await req.json(); const { activity, time, location } = await req.json();
+11 -63
View File
@@ -4,7 +4,7 @@
* Only available in development. * Only available in development.
*/ */
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requireAmapApiKey } from "@/lib/amap"; import { getTransitDirection } from "@/lib/amap";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (process.env.NODE_ENV !== "development") { if (process.env.NODE_ENV !== "development") {
@@ -22,69 +22,17 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 }); return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 });
} }
const apiKey = requireAmapApiKey(); const result = await getTransitDirection({
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated"); originLat: oLat,
url.searchParams.set("key", apiKey); originLng: oLng,
url.searchParams.set("origin", `${oLng},${oLat}`); destLat: dLat,
url.searchParams.set("destination", `${dLng},${dLat}`); destLng: dLng,
url.searchParams.set("city", city); city,
url.searchParams.set("cityd", city);
url.searchParams.set("output", "json");
const res = await fetch(url.toString());
const raw = await res.json();
if (raw.status !== "1" || !raw.route?.transits?.length) {
return NextResponse.json({ error: "未找到路线", raw });
}
const transit = raw.route.transits[0];
const durationMin = Math.ceil(Number(transit.duration) / 60);
const distanceKm = Math.round(Number(raw.route.distance) / 100) / 10;
// Parse segments the same way as production code
const parts: string[] = [];
const segmentDebug: unknown[] = [];
for (const seg of (transit.segments ?? []) as Record<string, unknown>[]) {
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
segmentDebug.push({
hasWalking: !!seg.walking,
hasBus: !!seg.bus,
buslines: bus?.buslines?.map((l) => ({
name: l.name,
type: l.type,
via_num: l.via_num,
departure: (l.departure_stop as Record<string, unknown> | undefined)?.name,
arrival: (l.arrival_stop as Record<string, unknown> | undefined)?.name,
})),
}); });
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) { if (!result) {
const name = String(line.name ?? ""); return NextResponse.json({ error: "未找到路线" });
const viaNum = Number(line.via_num ?? 0);
parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name);
}
} }
return NextResponse.json({ return NextResponse.json({ parsed: result });
parsed: {
durationMin,
distanceKm,
description: parts.join(" → ") || "步行",
},
segmentDebug,
rawTransit: {
duration: transit.duration,
nightflag: transit.nightflag,
segmentCount: transit.segments?.length,
},
// First 3 routes for comparison
allRoutes: raw.route.transits.slice(0, 3).map((t: Record<string, unknown>) => ({
durationMin: Math.ceil(Number(t.duration) / 60),
segments: (t.segments as Record<string, unknown>[] ?? []).map((s) => {
const b = s.bus as { buslines?: Record<string, unknown>[] } | undefined;
return b?.buslines?.map((l) => `${l.name}(${l.via_num}站)`) ?? ["步行"];
}),
})),
});
} }
+8 -21
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockReverseGeocode = vi.fn();
vi.mock("@/lib/amap", () => ({ vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"), reverseGeocode: (...args: unknown[]) => mockReverseGeocode(...args),
})); }));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route"; import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
@@ -20,19 +18,9 @@ beforeEach(() => {
describe("GET /api/location/regeo", () => { describe("GET /api/location/regeo", () => {
it("returns reverse geocoded location", async () => { it("returns reverse geocoded location", async () => {
mockFetch.mockResolvedValue({ mockReverseGeocode.mockResolvedValue({
json: () => name: "黄浦区 南京东路街道 人民广场",
Promise.resolve({ formatted: "上海市黄浦区人民大道",
status: "1",
regeocode: {
formatted_address: "上海市黄浦区人民大道",
addressComponent: {
district: "黄浦区",
township: "南京东路街道",
neighborhood: { name: "人民广场" },
},
},
}),
}); });
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
@@ -45,9 +33,7 @@ describe("GET /api/location/regeo", () => {
}); });
it("returns null name when API returns no result", async () => { it("returns null name when API returns no result", async () => {
mockFetch.mockResolvedValue({ mockReverseGeocode.mockResolvedValue({ name: null, formatted: null });
json: () => Promise.resolve({ status: "0" }),
});
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
@@ -62,7 +48,8 @@ describe("GET /api/location/regeo", () => {
}); });
it("returns 503 when API unavailable", async () => { it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network")); const { ApiError } = await import("@/lib/api");
mockReverseGeocode.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
+3 -34
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap"; import { reverseGeocode } from "@/lib/amap";
export const GET = apiHandler(async (req) => { export const GET = apiHandler(async (req) => {
const lat = req.nextUrl.searchParams.get("lat"); const lat = req.nextUrl.searchParams.get("lat");
@@ -8,37 +8,6 @@ export const GET = apiHandler(async (req) => {
if (!lat || !lng) throw new ApiError("lat and lng are required"); if (!lat || !lng) throw new ApiError("lat and lng are required");
const apiKey = requireAmapApiKey(); const result = await reverseGeocode({ lat: Number(lat), lng: Number(lng) });
return NextResponse.json(result);
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${lng},${lat}`);
url.searchParams.set("extensions", "base");
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.regeocode) {
return NextResponse.json({ name: null });
}
const comp = data.regeocode.addressComponent;
const district = comp?.district || comp?.city || "";
const township = comp?.township || "";
const neighborhood = comp?.neighborhood?.name || "";
const name = [district, township, neighborhood]
.filter(Boolean)
.join(" ")
.trim();
return NextResponse.json({
name: name || data.regeocode.formatted_address || null,
formatted: data.regeocode.formatted_address || null,
});
}); });
+11 -18
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockSearchPlaceText = vi.fn();
vi.mock("@/lib/amap", () => ({ vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"), searchPlaceText: (...args: unknown[]) => mockSearchPlaceText(...args),
})); }));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route"; import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
@@ -20,21 +18,17 @@ beforeEach(() => {
describe("GET /api/location/search", () => { describe("GET /api/location/search", () => {
it("returns search results", async () => { it("returns search results", async () => {
mockFetch.mockResolvedValue({ mockSearchPlaceText.mockResolvedValue([
json: () =>
Promise.resolve({
status: "1",
pois: [
{ {
id: "poi-1", id: "poi-1",
name: "星巴克", name: "星巴克",
address: "南京路1号", address: "南京路1号",
location: "121.4,31.2", lat: 31.2,
business: { rating: "4.5", cost: "40" }, lng: 121.4,
rating: 4.5,
cost: 40,
}, },
], ]);
}),
});
const req = createRequest("/api/location/search?keywords=星巴克"); const req = createRequest("/api/location/search?keywords=星巴克");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
@@ -48,9 +42,7 @@ describe("GET /api/location/search", () => {
}); });
it("returns empty when no results", async () => { it("returns empty when no results", async () => {
mockFetch.mockResolvedValue({ mockSearchPlaceText.mockResolvedValue([]);
json: () => Promise.resolve({ status: "1", pois: [] }),
});
const req = createRequest("/api/location/search?keywords=不存在的地方"); const req = createRequest("/api/location/search?keywords=不存在的地方");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
@@ -65,7 +57,8 @@ describe("GET /api/location/search", () => {
}); });
it("returns 503 when API unavailable", async () => { it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network error")); const { ApiError } = await import("@/lib/api");
mockSearchPlaceText.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/search?keywords=test"); const req = createRequest("/api/location/search?keywords=test");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
+2 -57
View File
@@ -1,19 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap"; import { searchPlaceText } from "@/lib/amap";
interface AmapPoiV5 {
id: string;
name: string;
address?: string;
location?: string;
type?: string;
business?: {
rating?: string;
cost?: string;
tel?: string;
};
}
export const GET = apiHandler(async (req) => { export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
@@ -22,48 +9,6 @@ export const GET = apiHandler(async (req) => {
const city = req.nextUrl.searchParams.get("city")?.trim(); const city = req.nextUrl.searchParams.get("city")?.trim();
const types = req.nextUrl.searchParams.get("types")?.trim(); const types = req.nextUrl.searchParams.get("types")?.trim();
const apiKey = requireAmapApiKey(); const results = await searchPlaceText({ keywords, city: city || undefined, types: types || undefined });
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "10");
if (city) url.searchParams.set("region", city);
if (types) url.searchParams.set("types", types);
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.pois?.length) {
return NextResponse.json([]);
}
const results = data.pois
.filter((poi: AmapPoiV5) => poi.location)
.map((poi: AmapPoiV5) => {
const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number);
const ratingStr = poi.business?.rating;
const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
const costStr = poi.business?.cost;
const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null;
return {
id: poi.id,
name: poi.name,
address: poi.address || "",
lat,
lng,
rating,
cost,
};
});
return NextResponse.json(results); return NextResponse.json(results);
}); });
+11 -24
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockGetInputTips = vi.fn();
vi.mock("@/lib/amap", () => ({ vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"), getInputTips: (...args: unknown[]) => mockGetInputTips(...args),
})); }));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route"; import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
@@ -20,21 +18,16 @@ beforeEach(() => {
describe("GET /api/location/suggest", () => { describe("GET /api/location/suggest", () => {
it("returns suggestions", async () => { it("returns suggestions", async () => {
mockFetch.mockResolvedValue({ mockGetInputTips.mockResolvedValue([
json: () =>
Promise.resolve({
status: "1",
tips: [
{ {
id: "tip-1", id: "tip-1",
name: "人民广场", name: "人民广场",
district: "黄浦区", district: "黄浦区",
address: "人民大道", address: "人民大道",
location: "121.4737,31.2304", lat: 31.2304,
lng: 121.4737,
}, },
], ]);
}),
});
const req = createRequest("/api/location/suggest?keywords=人民广场"); const req = createRequest("/api/location/suggest?keywords=人民广场");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
@@ -53,16 +46,9 @@ describe("GET /api/location/suggest", () => {
}); });
it("filters tips without location", async () => { it("filters tips without location", async () => {
mockFetch.mockResolvedValue({ mockGetInputTips.mockResolvedValue([
json: () => { id: "tip-1", name: "有位置", district: "", address: "", lat: 31.2, lng: 121.4 },
Promise.resolve({ ]);
status: "1",
tips: [
{ id: "tip-1", name: "有位置", location: "121.4,31.2" },
{ id: "tip-2", name: "无位置", location: "" },
],
}),
});
const req = createRequest("/api/location/suggest?keywords=test"); const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
@@ -72,7 +58,8 @@ describe("GET /api/location/suggest", () => {
}); });
it("returns 503 when API fails", async () => { it("returns 503 when API fails", async () => {
mockFetch.mockRejectedValue(new Error("network")); const { ApiError } = await import("@/lib/api");
mockGetInputTips.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/suggest?keywords=test"); const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
+4 -39
View File
@@ -1,47 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap"; import { getInputTips } from "@/lib/amap";
export const GET = apiHandler(async (req) => { export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
if (!keywords) return NextResponse.json([]); if (!keywords) return NextResponse.json([]);
const apiKey = requireAmapApiKey(); const location = req.nextUrl.searchParams.get("location") || undefined;
const suggestions = await getInputTips({ keywords, location });
const location = req.nextUrl.searchParams.get("location");
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("datatype", "poi");
if (location) {
url.searchParams.set("location", location);
}
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.tips) return NextResponse.json([]);
const suggestions = data.tips
.filter((t: { location?: string }) => t.location && t.location !== "")
.slice(0, 8)
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
const [lng, lat] = t.location.split(",").map(Number);
return {
id: t.id,
name: t.name,
district: t.district || "",
address: t.address || "",
lat,
lng,
};
});
return NextResponse.json(suggestions); return NextResponse.json(suggestions);
}); });
File diff suppressed because it is too large Load Diff
+23 -51
View File
@@ -23,6 +23,7 @@ import Button from "@/components/Button";
import Input from "@/components/Input"; import Input from "@/components/Input";
import { BlindboxListSkeleton } from "@/components/Skeleton"; import { BlindboxListSkeleton } from "@/components/Skeleton";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { useBlindboxRooms } from "@/hooks/useBlindboxRooms";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
interface RoomSummary { interface RoomSummary {
@@ -169,77 +170,48 @@ export default function BlindboxLobbyPage() {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null);
const [showAuth, setShowAuth] = useState(false); const [showAuth, setShowAuth] = useState(false);
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
const toast = useToast(); const toast = useToast();
const { rooms: swrRooms, isLoading: swrLoading, isUnauthorized, error: swrError, mutate: mutateRooms } = useBlindboxRooms(
loggedIn && profile ? profile.id : undefined,
);
const rooms = swrRooms;
const loading = !loggedIn ? false : swrLoading;
// JWT 过期时,重置登录状态让用户重新登录
useEffect(() => {
if (isUnauthorized && loggedIn) {
setLoggedIn(false);
setProfile(null);
setShowAuth(true);
}
}, [isUnauthorized, loggedIn]);
const createNameRef = useRef<HTMLInputElement>(null); const createNameRef = useRef<HTMLInputElement>(null);
const joinCodeRef = useRef<HTMLInputElement>(null); const joinCodeRef = useRef<HTMLInputElement>(null);
const [joinCodeLength, setJoinCodeLength] = useState(0); const [joinCodeLength, setJoinCodeLength] = useState(0);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loadError, setLoadError] = useState<string | false>(false); const loadError = swrError ? (swrError instanceof Error ? swrError.message : "未知错误") : false;
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchRooms = useCallback(async (silent = false) => {
const p = getCachedProfile();
if (!p) return;
if (!silent) {
setLoading(true);
setLoadError(false);
}
try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`, { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
const data = await res.json();
setRooms(Array.isArray(data.rooms) ? data.rooms : []);
setLoadError(false);
} catch (e) {
if (!silent) setLoadError(e instanceof Error ? e.message : "未知错误");
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
const registered = isRegistered(); const registered = isRegistered();
setLoggedIn(registered); setLoggedIn(registered);
if (registered) { if (registered) setProfile(getCachedProfile());
setProfile(getCachedProfile());
fetchRooms();
} else {
setLoading(false);
}
setHydrated(true); setHydrated(true);
}, [fetchRooms]); }, []);
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
const registered = isRegistered(); const registered = isRegistered();
setLoggedIn(registered); setLoggedIn(registered);
setProfile(registered ? getCachedProfile() : null); setProfile(registered ? getCachedProfile() : null);
if (registered) fetchRooms();
}; };
window.addEventListener("nowhatever_auth", handler); window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler); return () => window.removeEventListener("nowhatever_auth", handler);
}, [fetchRooms]); }, []);
useEffect(() => {
if (!loggedIn) return;
const refresh = () => fetchRooms(true);
window.addEventListener("focus", refresh);
window.addEventListener("pageshow", refresh);
return () => {
window.removeEventListener("focus", refresh);
window.removeEventListener("pageshow", refresh);
};
}, [loggedIn, fetchRooms]);
useEffect(() => { useEffect(() => {
if (rooms.length > 0) setJoinCodeLength(0); if (rooms.length > 0) setJoinCodeLength(0);
@@ -249,8 +221,8 @@ export default function BlindboxLobbyPage() {
setProfile(p); setProfile(p);
setLoggedIn(true); setLoggedIn(true);
setShowAuth(false); setShowAuth(false);
fetchRooms(); mutateRooms();
}, [fetchRooms]); }, [mutateRooms]);
const handleCreate = async () => { const handleCreate = async () => {
if (creating || !profile) return; if (creating || !profile) return;
@@ -307,7 +279,7 @@ export default function BlindboxLobbyPage() {
const data = await res.json(); const data = await res.json();
throw new Error(data.error || "操作失败"); throw new Error(data.error || "操作失败");
} }
setRooms((prev) => prev.filter((r) => r.id !== room.id)); mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false);
setConfirmDeleteId(null); setConfirmDeleteId(null);
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间"); toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
} catch (e) { } catch (e) {
@@ -441,7 +413,7 @@ export default function BlindboxLobbyPage() {
<p className="max-w-xs text-center text-[11px] text-muted/60 break-all">{loadError}</p> <p className="max-w-xs text-center text-[11px] text-muted/60 break-all">{loadError}</p>
)} )}
<button <button
onClick={() => fetchRooms()} onClick={() => mutateRooms()}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300" className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
> >
+5 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
@@ -22,10 +22,11 @@ import Card from "@/components/Card";
import Input from "@/components/Input"; import Input from "@/components/Input";
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard"; import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { useFavorites } from "@/hooks/useFavorites";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton"; import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars"; import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, FavoriteRecord } from "@/types"; import type { UserProfile, UserPreferences } from "@/types";
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
@@ -33,8 +34,7 @@ export default function ProfilePage() {
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null); const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]); const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
const [favLoading, setFavLoading] = useState(false);
const [editingUsername, setEditingUsername] = useState(false); const [editingUsername, setEditingUsername] = useState(false);
const [newUsername, setNewUsername] = useState(""); const [newUsername, setNewUsername] = useState("");
@@ -87,17 +87,6 @@ export default function ProfilePage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [router]); }, [router]);
useEffect(() => {
if (!userId) return;
setFavLoading(true);
fetch(`/api/user/favorite?userId=${userId}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => setFavorites(Array.isArray(data) ? data : []))
.catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); })
.finally(() => setFavLoading(false));
}, [userId]);
const handleSaveUsername = async () => { const handleSaveUsername = async () => {
const trimmed = newUsername.trim(); const trimmed = newUsername.trim();
if (trimmed.length < 2 || trimmed.length > 16) { if (trimmed.length < 2 || trimmed.length > 16) {
@@ -220,7 +209,7 @@ export default function ProfilePage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, favoriteId: favId }), body: JSON.stringify({ userId, favoriteId: favId }),
}); });
setFavorites((f) => f.filter((x) => x.id !== favId)); mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
toast.show("已取消收藏"); toast.show("已取消收藏");
} catch { } catch {
toast.show("操作失败"); toast.show("操作失败");
+22 -393
View File
@@ -3,8 +3,6 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
MapPin,
Clock,
Navigation, Navigation,
Share2, Share2,
RefreshCw, RefreshCw,
@@ -12,9 +10,6 @@ import {
ChevronRight, ChevronRight,
ChevronLeft, ChevronLeft,
CornerDownLeft, CornerDownLeft,
GripVertical,
Pencil,
X,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -27,26 +22,15 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { import {
SortableContext, SortableContext,
useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
arrayMove, arrayMove,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
import Button from "@/components/Button"; 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"; import type { WeekendPlanData, PlanItem } from "@/types";
interface AltSuggestion {
activity: string;
poi: string;
address: string;
lat: number;
lng: number;
reason: string;
}
interface BlindboxPlanProps { interface BlindboxPlanProps {
days: WeekendPlanData[]; days: WeekendPlanData[];
onAccept: () => void; onAccept: () => void;
@@ -56,217 +40,11 @@ interface BlindboxPlanProps {
accepted?: boolean; accepted?: boolean;
regenerating?: boolean; regenerating?: boolean;
onDaysChange?: (newDays: WeekendPlanData[]) => void; onDaysChange?: (newDays: WeekendPlanData[]) => void;
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
location?: string; location?: string;
/** 出发地名称,用于显示在出发/返回连接器上 */
startLocationLabel?: string; startLocationLabel?: string;
onRefine?: (instruction: string) => Promise<void>; 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({ export default function BlindboxPlan({
days, days,
onAccept, onAccept,
@@ -291,8 +69,6 @@ export default function BlindboxPlan({
const [draft, setDraft] = useState<PlanItem | null>(null); const [draft, setDraft] = useState<PlanItem | null>(null);
const [refineInput, setRefineInput] = useState(""); const [refineInput, setRefineInput] = useState("");
const [refining, setRefining] = useState(false); const [refining, setRefining] = useState(false);
const [suggestingAlt, setSuggestingAlt] = useState(false);
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
@@ -332,20 +108,14 @@ export default function BlindboxPlan({
onDaysChange(newDays); onDaysChange(newDays);
setEditingItem(null); setEditingItem(null);
setDraft(null); setDraft(null);
setAltSuggestions([]);
} }
// Cross-day move: immediately call onDaysChange
function handleMoveToDayIndex(targetDayIndex: number) { function handleMoveToDayIndex(targetDayIndex: number) {
if (!draft || !editingItem || !onDaysChange) return; if (!draft || !editingItem || !onDaysChange) return;
if (targetDayIndex === editingItem.dayIndex) return; if (targetDayIndex === editingItem.dayIndex) return;
const newDays = days.map((day, di) => { const newDays = days.map((day, di) => {
if (di === editingItem.dayIndex) { if (di === editingItem.dayIndex) {
return { return { ...day, items: day.items.filter((_, ii) => ii !== editingItem.itemIndex) };
...day,
items: day.items.filter((_, ii) => ii !== editingItem.itemIndex),
};
} }
if (di === targetDayIndex) { if (di === targetDayIndex) {
return { ...day, items: [...day.items, draft] }; 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; if (!currentDay) return null;
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{/* Day header — sticky top */} {/* Day header */}
<div className="shrink-0 pb-3 text-center"> <div className="shrink-0 pb-3 text-center">
<motion.div <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" 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> </div>
{/* Scrollable timeline */} {/* Scrollable timeline */}
<div <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-none">
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={dayIndex} key={dayIndex}
@@ -455,7 +205,7 @@ export default function BlindboxPlan({
items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)} items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{/* Transit from home to first activity */} {/* Transit from home */}
{currentDay.transitFromStart != null && ( {currentDay.transitFromStart != null && (
<div className="flex items-start gap-1.5 py-2 pl-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" /> <Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -480,14 +230,13 @@ export default function BlindboxPlan({
<SortablePlanItem <SortablePlanItem
id={`${dayIndex}-${i}`} id={`${dayIndex}-${i}`}
item={item} item={item}
index={i}
canEdit={canEdit} canEdit={canEdit}
onEdit={() => { onEdit={() => {
setEditingItem({ dayIndex, itemIndex: i }); setEditingItem({ dayIndex, itemIndex: i });
setDraft({ ...item }); setDraft({ ...item });
}} }}
/> />
{/* Transit connector to next activity */} {/* Transit connector */}
{item.transitToNext != null && i < currentDay.items.length - 1 && ( {item.transitToNext != null && i < currentDay.items.length - 1 && (
<div className="flex items-start gap-1.5 py-2 pl-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" /> <Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -501,7 +250,7 @@ export default function BlindboxPlan({
</div> </div>
</div> </div>
)} )}
{/* Transit back home after last activity */} {/* Transit back home */}
{i === currentDay.items.length - 1 && currentDay.transitToEnd != null && ( {i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
<div className="flex items-start gap-1.5 py-2 pl-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" /> <Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
@@ -523,7 +272,7 @@ export default function BlindboxPlan({
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* Back to pool — at end of scroll content */} {/* Back to pool */}
<div className="mt-6 flex justify-center pb-4"> <div className="mt-6 flex justify-center pb-4">
<button <button
onClick={onBack} onClick={onBack}
@@ -535,9 +284,8 @@ export default function BlindboxPlan({
</div> </div>
</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"> <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 && ( {onRefine && (
<div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2"> <div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2">
<input <input
@@ -558,7 +306,6 @@ export default function BlindboxPlan({
</div> </div>
)} )}
{/* Day navigation */}
{days.length > 1 && ( {days.length > 1 && (
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4"> <div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
{hasPrev && ( {hasPrev && (
@@ -589,34 +336,17 @@ export default function BlindboxPlan({
</div> </div>
)} )}
{/* Action buttons */}
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4"> <div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
{accepted ? ( {accepted ? (
<Button <Button onClick={onShare} variant="purple" shape="pill" icon={<Share2 size={14} />}>
onClick={onShare}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button> </Button>
) : ( ) : (
<> <>
<Button <Button onClick={onAccept} variant="purple" shape="pill" icon={<Sparkles size={14} />}>
onClick={onAccept}
variant="purple"
shape="pill"
icon={<Sparkles size={14} />}
>
</Button> </Button>
<Button <Button onClick={onRegenerate} variant="secondary" shape="pill" loading={regenerating} icon={<RefreshCw size={14} />}>
onClick={onRegenerate}
variant="secondary"
shape="pill"
loading={regenerating}
icon={<RefreshCw size={14} />}
>
</Button> </Button>
</> </>
@@ -625,118 +355,17 @@ export default function BlindboxPlan({
</div> </div>
{/* Edit item modal */} {/* Edit item modal */}
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet"> <PlanItemEditModal
{draft && editingItem && ( open={!!editingItem && !!draft}
<div className="flex flex-col gap-4"> editingItem={editingItem}
<div className="flex items-center justify-between"> draft={draft}
<h3 className="text-sm font-bold text-heading"></h3> days={days}
<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} location={location}
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })} onDraftChange={setDraft}
onSave={handleSaveDraft}
onClose={() => { setEditingItem(null); setDraft(null); }}
onMoveToDayIndex={handleMoveToDayIndex}
/> />
{/* 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>
</div> </div>
); );
} }
+16 -134
View File
@@ -1,5 +1,6 @@
import { QRCodeSVG } from "qrcode.react";
import type { WeekendPlanData } from "@/types"; import type { WeekendPlanData } from "@/types";
import ShareCardShell from "./ShareCardShell";
import type { ShareCardTheme } from "./ShareCardShell";
export interface PlanShareData { export interface PlanShareData {
type: "plan"; type: "plan";
@@ -7,6 +8,18 @@ export interface PlanShareData {
roomName: string; 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({ export default function BlindboxPlanShareCard({
data, data,
cardRef, cardRef,
@@ -17,103 +30,9 @@ export default function BlindboxPlanShareCard({
bgDataUrl?: string | null; bgDataUrl?: string | null;
}) { }) {
const { days, roomName } = data; const { days, roomName } = data;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return ( return (
<div <ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
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>
<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,
}}
>
便 · 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 */} {/* Each day */}
{days.map((day, dayIdx) => ( {days.map((day, dayIdx) => (
<div key={day.date}> <div key={day.date}>
@@ -235,43 +154,6 @@ export default function BlindboxPlanShareCard({
> >
</div> </div>
</ShareCardShell>
{/* 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>
); );
} }
+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>
);
}
+27 -195
View File
@@ -1,4 +1,5 @@
import { QRCodeSVG } from "qrcode.react"; import ShareCardShell from "./ShareCardShell";
import type { ShareCardTheme } from "./ShareCardShell";
export interface BlindboxShareData { export interface BlindboxShareData {
type: "blindbox"; type: "blindbox";
@@ -8,6 +9,19 @@ export interface BlindboxShareData {
roomName: string; 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({ export default function BlindboxShareCard({
data, data,
cardRef, cardRef,
@@ -18,57 +32,10 @@ export default function BlindboxShareCard({
bgDataUrl?: string | null; bgDataUrl?: string | null;
}) { }) {
const { idea, submitter, drawer, roomName } = data; const { idea, submitter, drawer, roomName } = data;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return ( return (
<div <ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
ref={cardRef} {/* Bottom-left glow */}
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%)",
}}
/>
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -81,51 +48,6 @@ export default function BlindboxShareCard({
}} }}
/> />
{/* 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 */} {/* Room name badge */}
<div <div
style={{ style={{
@@ -158,54 +80,14 @@ export default function BlindboxShareCard({
}} }}
> >
{/* Corner decorations */} {/* Corner decorations */}
<div {[
style={{ { top: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopLeftRadius: 3 },
position: "absolute", { top: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopRightRadius: 3 },
top: 10, { bottom: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomLeftRadius: 3 },
left: 10, { bottom: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomRightRadius: 3 },
width: 14, ].map((style, i) => (
height: 14, <div key={i} style={{ position: "absolute", width: 14, height: 14, ...style }} />
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 <div
style={{ style={{
@@ -225,8 +107,7 @@ export default function BlindboxShareCard({
width: 48, width: 48,
height: 1, height: 1,
margin: "16px auto", margin: "16px auto",
background: background: "linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
}} }}
/> />
@@ -287,55 +168,6 @@ export default function BlindboxShareCard({
)} )}
</div> </div>
)} )}
</ShareCardShell>
{/* 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>
); );
} }
+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>
);
}
+33 -171
View File
@@ -1,7 +1,8 @@
import { Star, MapPin, Zap } from "lucide-react"; import { Star, MapPin, Zap } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import type { Restaurant, MatchType, SceneType } from "@/types"; import type { Restaurant, MatchType, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import ShareCardShell from "./ShareCardShell";
import type { ShareCardTheme } from "./ShareCardShell";
export interface RestaurantShareData { export interface RestaurantShareData {
type: "restaurant"; type: "restaurant";
@@ -12,6 +13,25 @@ export interface RestaurantShareData {
scene?: SceneType; 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({ export default function RestaurantShareCard({
data, data,
cardRef, cardRef,
@@ -26,63 +46,16 @@ export default function RestaurantShareCard({
const { restaurant, matchType, matchLikes, userCount, scene } = data; const { restaurant, matchType, matchLikes, userCount, scene } = data;
const isUnanimous = matchType === "unanimous"; const isUnanimous = matchType === "unanimous";
const verb = getSceneConfig(scene ?? "eat").verb; const verb = getSceneConfig(scene ?? "eat").verb;
const shareUrl = const theme = buildTheme(isUnanimous);
typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; const { accentText, accentBg } = theme;
const accentFrom = isUnanimous ? "#059669" : "#b45309";
const accentTo = isUnanimous ? "#34d399" : "#fbbf24"; const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
const accentBg = isUnanimous // Fix the second glow position (bottom-left, not the placeholder)
? "rgba(16, 185, 129, 0.12)" theme.glows[1] = { top: 9999, right: 9999, width: 120, height: 120, color: `${accentTo}12` };
: "rgba(245, 158, 11, 0.12)";
return ( return (
<div <ShareCardShell theme={theme} cardRef={cardRef} bgDataUrl={bgDataUrl}>
ref={cardRef} {/* Second glow override (bottom-left) */}
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',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#08080a",
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: -40,
right: -30,
width: 160,
height: 160,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
}}
/>
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -95,53 +68,6 @@ export default function RestaurantShareCard({
}} }}
/> />
{/* 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 }}></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,
}}
>
便 · PANIC MODE
</div>
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
}}
/>
{/* Hero section */} {/* Hero section */}
<div <div
style={{ style={{
@@ -177,27 +103,15 @@ export default function RestaurantShareCard({
}} }}
> >
{isUnanimous && ( {isUnanimous && (
<Zap <Zap size={12} style={{ color: accentText, fill: accentText }} />
size={12}
style={{ color: accentText, fill: accentText }}
/>
)} )}
<span <span style={{ fontSize: 11, fontWeight: 700, color: accentText }}>
style={{
fontSize: 11,
fontWeight: 700,
color: accentText,
}}
>
{isUnanimous {isUnanimous
? `默契度 100% · ${userCount}人全员一致` ? `默契度 100% · ${userCount}人全员一致`
: `${matchLikes}/${userCount} 人选了这家`} : `${matchLikes}/${userCount} 人选了这家`}
</span> </span>
{isUnanimous && ( {isUnanimous && (
<Zap <Zap size={12} style={{ color: accentText, fill: accentText }} />
size={12}
style={{ color: accentText, fill: accentText }}
/>
)} )}
</div> </div>
</div> </div>
@@ -280,10 +194,7 @@ export default function RestaurantShareCard({
fontWeight: 600, fontWeight: 600,
}} }}
> >
<Star <Star size={13} style={{ color: "#fbbf24", fill: "#fbbf24" }} />
size={13}
style={{ color: "#fbbf24", fill: "#fbbf24" }}
/>
{restaurant.rating} {restaurant.rating}
</span> </span>
)} )}
@@ -352,55 +263,6 @@ export default function RestaurantShareCard({
</div> </div>
</div> </div>
</div> </div>
</ShareCardShell>
{/* 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>
</div>
</div>
</div>
</div>
); );
} }
+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>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import type { DecisionRecord, ContractRecord } from "@/types";
interface Stats {
totalDecisions: number;
totalContracts: number;
completedContracts: number;
completionRate: number;
}
interface AchievementsResponse {
stats: Stats;
decisions: DecisionRecord[];
contracts: ContractRecord[];
}
export function useAchievements(userId: string | undefined) {
const { data, isLoading, error } = useSWR<AchievementsResponse>(
userId ? `/api/user/achievements?userId=${userId}` : null,
fetcher,
);
return {
stats: data?.stats ?? {
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
},
decisions: data?.decisions ?? [],
contracts: data?.contracts ?? [],
isLoading,
error,
};
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useAnimation } from "framer-motion";
import confetti from "canvas-confetti";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
import type { UserProfile } from "@/types";
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
export function useBlindboxDraw(
room: RoomInfo | null,
profile: UserProfile | null,
poolCount: number,
setPoolCount: React.Dispatch<React.SetStateAction<number>>,
setDrawnHistory: React.Dispatch<React.SetStateAction<DrawnIdea[]>>,
setError: (e: string) => void,
setPhase: (p: Phase) => void,
) {
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
const [showShareCard, setShowShareCard] = useState(false);
const boxControls = useAnimation();
const confettiAliveRef = useRef(false);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const fireConfetti = useCallback(() => {
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
confettiAliveRef.current = true;
const end = Date.now() + 3000;
const frame = () => {
if (Date.now() > end || !confettiAliveRef.current) return;
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
requestAnimationFrame(frame);
};
timersRef.current.push(setTimeout(frame, 200));
}, []);
const handleDraw = async () => {
if (poolCount === 0 || !profile || !room) {
setError("盒子是空的,先往里面塞点想法吧!");
return;
}
setPhase("shaking");
setError("");
await boxControls.start({
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
transition: { duration: 2.5, ease: "easeInOut" },
});
try {
const res = await fetch("/api/blindbox/draw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "抽取失败");
}
const idea = await res.json();
setRevealedIdea(idea);
setPhase("reveal");
setPoolCount((c) => Math.max(0, c - 1));
setDrawnHistory((prev) => [idea, ...prev]);
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "抽取失败");
setPhase("pool");
}
};
const handleContinue = useCallback(() => {
setPhase("pool");
setRevealedIdea(null);
setShowShareCard(false);
}, [setPhase]);
return {
revealedIdea,
showShareCard,
setShowShareCard,
boxControls,
fireConfetti,
handleDraw,
handleContinue,
};
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useAnimation } from "framer-motion";
import { useToast } from "@/hooks/useToast";
import type { MyIdea } from "@/components/BlindboxMyIdeas";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
import type { UserProfile } from "@/types";
const IDEA_INSPIRATIONS = [
"去城市最高楼看日落",
"挑战一人做一道菜",
"找一家从没去过的店",
"逛一个从没去过的街区",
"去公园野餐",
"看一场午夜电影",
"在家做一顿异国料理",
"骑车去郊外探险",
"去二手市场淘宝",
"去博物馆逛半天",
"试一项没玩过的运动",
"随机坐公交到终点站",
"一起画画或做手工",
"找个天台看星星",
"去一家评分最低的餐厅",
"穿最好看的衣服去拍照",
"交换手机玩一个小时",
"一起去做志愿者",
];
function pickRandom<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => Math.random() - 0.5);
return shuffled.slice(0, n);
}
export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) {
const toast = useToast();
const boxControls = useAnimation();
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4));
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static");
const [poolCount, setPoolCount] = useState(0);
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [submitFlash, setSubmitFlash] = useState(false);
const [error, setError] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const fetchIdeas = useCallback(async () => {
if (!room || !profile) return;
try {
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${profile.id}`);
if (res.ok) {
const data = await res.json();
setPoolCount(data.poolCount ?? 0);
setMyIdeas(data.myIdeas ?? []);
setDrawnHistory(data.drawn ?? []);
}
} catch (e) { console.error("fetchIdeas failed:", e); }
}, [room, profile]);
const fetchSuggestions = useCallback(async () => {
if (!room || !profile) return;
setSuggestionsLoading(true);
try {
const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${profile.id}`);
if (res.ok) {
const data = await res.json();
if (data.suggestions?.length > 0) {
setSuggestions(data.suggestions);
setSuggestionsSource("ai");
setSuggestionsLoading(false);
return;
}
}
} catch (e) { console.error("fetchSuggestions failed:", e); }
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
setSuggestionsSource("static");
setSuggestionsLoading(false);
}, [room, profile]);
const refreshSuggestions = useCallback(() => {
if (suggestionsSource === "ai") {
fetchSuggestions();
} else {
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
}
}, [suggestionsSource, fetchSuggestions]);
const handleSubmit = async () => {
const text = input.trim();
if (!text || submitting || !profile || !room) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/blindbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
const data = await res.json();
setInput("");
setPoolCount((c) => c + 1);
setMyIdeas((prev) => [{
id: data.id,
content: text,
createdAt: new Date().toISOString(),
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
costLevel: data.tags.costLevel,
intensity: data.tags.intensity,
needsBooking: data.tags.needsBooking,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
}, ...prev]);
setSubmitFlash(true);
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
boxControls.start({
scale: [1, 1.08, 1],
rotate: [0, -3, 3, 0],
transition: { duration: 0.5 },
});
fetchSuggestions();
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
};
const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => {
if (!profile) return;
const trimmed = newContent.trim();
if (!trimmed || trimmed.length > 200) return;
try {
const res = await fetch("/api/blindbox", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "编辑失败");
}
const data = await res.json();
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
...i,
content: trimmed,
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
costLevel: data.tags.costLevel,
intensity: data.tags.intensity,
needsBooking: data.tags.needsBooking,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
} : i)));
} catch (e) {
toast.show(e instanceof Error ? e.message : "编辑失败");
}
}, [profile, toast]);
const handleDeleteIdea = useCallback(async (ideaId: string) => {
if (!profile) return;
try {
const res = await fetch("/api/blindbox", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId, userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "删除失败");
}
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
setPoolCount((c) => Math.max(0, c - 1));
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败");
}
}, [profile, toast]);
return {
input,
setInput,
submitting,
suggestions,
suggestionsLoading,
suggestionsSource,
poolCount,
setPoolCount,
myIdeas,
drawnHistory,
setDrawnHistory,
submitFlash,
error,
setError,
inputRef,
boxControls,
fetchIdeas,
fetchSuggestions,
refreshSuggestions,
handleSubmit,
handleEditIdea,
handleDeleteIdea,
};
}
+284
View File
@@ -0,0 +1,284 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { getCachedProfile } from "@/lib/userId";
import { useToast } from "@/hooks/useToast";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
import type { WeekendPlanData, UserProfile } from "@/types";
import type { PendingContract } from "@/components/ContractCompletionModal";
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
const PLAN_STATUS_STEPS = [
"正在分析你们的想法...",
"正在搜索地点...",
"正在规划路线...",
"快好了...",
];
export function useBlindboxPlan(
room: RoomInfo | null,
profile: UserProfile | null,
phase: Phase,
setPhase: (p: Phase) => void,
fireConfetti: () => void,
) {
const toast = useToast();
const [planId, setPlanId] = useState<string | null>(null);
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false);
const [planStatusMessages, setPlanStatusMessages] = useState<string[]>([]);
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
const [activeContract, setActiveContract] = useState<{
id: string;
days: WeekendPlanData[];
endTime: string | null;
} | null>(null);
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
const planLogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (planLogRef.current) {
planLogRef.current.scrollTop = planLogRef.current.scrollHeight;
}
}, [planStatusMessages]);
const fetchAcceptedPlan = useCallback(async () => {
if (!room || !profile) return;
try {
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${profile.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.plan) {
setActiveContract({
id: data.plan.id,
days: data.plan.days,
endTime: data.plan.endTime ?? null,
});
}
} catch (e) { console.error("fetchAcceptedPlan failed:", e); }
}, [room, profile]);
// Check for expired contracts on load
useEffect(() => {
if (!profile) return;
(async () => {
try {
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${profile.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.pending?.length) setPendingContracts(data.pending);
} catch (e) { console.error("fetchPendingContracts failed:", e); }
})();
}, [profile]);
// Browser notification timer for active contract
useEffect(() => {
if (!activeContract?.endTime) return;
const end = new Date(activeContract.endTime).getTime();
const now = Date.now();
const ms = end - now;
if (ms <= 0) return;
if (typeof Notification !== "undefined" && Notification.permission === "default") {
Notification.requestPermission();
}
const timer = setTimeout(() => {
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
const n = new Notification("周末契约到期", {
body: "你的周末契约已结束,完成了吗?",
icon: "/icon-192x192.png",
});
n.onclick = () => { window.focus(); n.close(); };
}
const p = getCachedProfile();
if (p) {
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
.then((r) => r.json())
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
}
}, ms);
return () => clearTimeout(timer);
}, [activeContract?.endTime]);
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
if (generating || !profile || !room) return;
setGenerating(true);
setPhase("planning");
setPlanStatusMessages([PLAN_STATUS_STEPS[0]]);
const payload = { roomId: room.id, userId: profile.id, availableTime: timeConfig };
const stepRef = { current: 0 };
const fallbackTimer = setInterval(() => {
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]);
}, 2800);
try {
const res = await fetch("/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "生成失败");
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("无法读取响应");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
let eventType = "";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) eventType = line.slice(6).trim();
else if (line.startsWith("data:")) data = line.slice(5).trim();
}
if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]);
else if (eventType === "plan") {
const parsed = JSON.parse(data);
setPlanId(parsed.id);
setPlanDays(parsed.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} else if (eventType === "error") {
toast.show(data || "生成计划失败");
setPhase("pool");
}
}
}
} catch {
try {
const res = await fetch("/api/blindbox/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "生成失败");
}
const data = await res.json();
setPlanId(data.id);
setPlanDays(data.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} catch (fallbackErr) {
toast.show(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败");
setPhase("pool");
}
} finally {
clearInterval(fallbackTimer);
setGenerating(false);
}
}, [generating, profile, room, setPhase, fireConfetti, toast]);
const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => {
if (!planId || !profile) return;
const prevDays = planDays;
setPlanDays(newDays);
if (planAccepted) {
setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev);
}
try {
const res = await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败");
} catch (e) {
setPlanDays(prevDays);
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
toast.show(e instanceof Error ? e.message : "保存失败");
}
}, [planId, profile, planDays, planAccepted, toast]);
const handleRefine = useCallback(async (instruction: string) => {
if (!profile || !planDays.length) return;
const prevDays = planDays;
try {
const res = await fetch("/api/blindbox/plan/refine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, instruction, days: planDays }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
const data = await res.json();
await handlePlanDaysChange(data.days);
} catch (e) {
setPlanDays(prevDays);
toast.show(e instanceof Error ? e.message : "AI 调整失败");
}
}, [profile, planDays, handlePlanDaysChange, toast]);
const handleAcceptPlan = useCallback(async () => {
setPlanAccepted(true);
fireConfetti();
if (planId && profile) {
try {
const res = await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
});
const data = await res.json();
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
} catch (e) { console.error("acceptPlan failed:", e); }
}
toast.show("契约已接受!");
}, [planId, profile, planDays, fireConfetti, toast]);
const handleRegenerate = useCallback(() => {
setPlanId(null);
setPlanDays([]);
setPlanAccepted(false);
setPhase("time_select");
}, [setPhase]);
const showActiveContract = useCallback(() => {
if (!activeContract) return;
setPlanId(activeContract.id);
setPlanDays(activeContract.days);
setPlanAccepted(true);
setPhase("plan_reveal");
}, [activeContract, setPhase]);
const clearPendingContracts = useCallback(() => {
setPendingContracts([]);
setActiveContract(null);
}, []);
return {
planId,
planDays,
setPlanDays,
planAccepted,
generating,
planStatusMessages,
showPlanShareCard,
setShowPlanShareCard,
activeContract,
pendingContracts,
planLogRef,
fetchAcceptedPlan,
handleGeneratePlan,
handlePlanDaysChange,
handleRefine,
handleAcceptPlan,
handleRegenerate,
showActiveContract,
clearPendingContracts,
};
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
import type { UserProfile } from "@/types";
export interface RoomInfo {
id: string;
code: string;
name: string;
creatorId: string;
city: string | null;
address: string | null;
lat: number | null;
lng: number | null;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
}
export function useBlindboxRoom(code: string) {
const router = useRouter();
const toast = useToast();
const { share, copyToClipboard } = useShare();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(null);
const [isMember, setIsMember] = useState(false);
const [joiningRoom, setJoiningRoom] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [locating, setLocating] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false);
const [showInvite, setShowInvite] = useState(false);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
return () => { timersRef.current.forEach(clearTimeout); };
}, []);
useEffect(() => {
if (!isRegistered()) {
router.replace("/blindbox");
return;
}
setProfile(getCachedProfile());
}, [router]);
const fetchRoom = useCallback(async () => {
if (!code) return;
try {
const res = await fetch(`/api/blindbox/room/${code}`);
if (!res.ok) { router.replace("/blindbox"); return; }
const data: RoomInfo = await res.json();
setRoom(data);
const p = getCachedProfile();
setIsMember(data.members.some((m) => m.id === p?.id));
} catch {
router.replace("/blindbox");
} finally {
setPageLoading(false);
}
}, [code, router]);
useEffect(() => { fetchRoom(); }, [fetchRoom]);
const handleJoinRoom = async () => {
if (joiningRoom || !profile || !room) return;
setJoiningRoom(true);
try {
const res = await fetch("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, code }),
});
if (res.ok) { setIsMember(true); fetchRoom(); }
} catch (e) { console.error("handleJoinRoom failed:", e); }
finally { setJoiningRoom(false); }
};
const handleSetLocation = useCallback(async () => {
if (locating || !profile || !room) return;
setLocating(true);
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
);
const { latitude: lat, longitude: lng } = pos.coords;
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const regeo = regeoRes.ok ? await regeoRes.json() : {};
const cityName = regeo.name || "未知位置";
const addressLabel = regeo.formatted || cityName;
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }),
});
if (!patchRes.ok) throw new Error("保存位置失败");
setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev);
toast.show("位置已设置");
} catch {
toast.show("获取位置失败,请允许定位权限");
} finally {
setLocating(false);
}
}, [locating, profile, room, toast]);
const handleLeaveOrDelete = async () => {
if (!confirmLeave) {
setConfirmLeave(true);
timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000));
return;
}
if (leaving || !profile || !room) return;
setLeaving(true);
try {
const res = await fetch(`/api/blindbox/room/${room.code}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "操作失败");
}
router.push("/blindbox");
router.refresh();
} catch (e) {
toast.show(e instanceof Error ? e.message : "操作失败");
setConfirmLeave(false);
} finally {
setLeaving(false);
}
};
const handleCopyCode = useCallback(
() => room ? copyToClipboard(room.code, "房间号已复制") : undefined,
[room, copyToClipboard],
);
const handleShareRoom = useCallback(() => {
if (!room) return;
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
share(
{ title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url },
handleCopyCode,
);
}, [room, share, handleCopyCode]);
const handleBackToLobby = useCallback(() => {
router.push("/blindbox");
router.refresh();
}, [router]);
const isCreator = profile?.id === room?.creatorId;
return {
profile,
room,
isMember,
joiningRoom,
pageLoading,
locating,
confirmLeave,
leaving,
showInvite,
setShowInvite,
isCreator,
handleJoinRoom,
handleSetLocation,
handleLeaveOrDelete,
handleCopyCode,
handleShareRoom,
handleBackToLobby,
fetchRoom,
};
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import useSWR from "swr";
import { fetcher, FetchError } from "@/lib/fetcher";
interface RoomSummary {
id: string;
code: string;
name: string;
creatorId: string;
memberCount: number;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
lastDrawn: { content: string; createdAt: string } | null;
}
interface RoomsResponse {
rooms: RoomSummary[];
}
export function useBlindboxRooms(userId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<RoomsResponse>(
userId ? `/api/blindbox/rooms?userId=${userId}` : null,
fetcher,
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
shouldRetryOnError: (err) =>
!(err instanceof FetchError && (err.status === 401 || err.status === 403)),
},
);
return {
rooms: data?.rooms ?? [],
isLoading,
isUnauthorized: error instanceof FetchError && error.status === 401,
error,
mutate,
};
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
import type { FavoriteRecord } from "@/types";
export function useFavorites(userId: string | undefined) {
const { data, isLoading, mutate } = useSWR<FavoriteRecord[]>(
userId ? `/api/user/favorite?userId=${userId}` : null,
fetcher,
);
return {
favorites: data ?? [],
isLoading,
mutate,
};
}
+5 -10
View File
@@ -2,24 +2,19 @@
import useSWR from "swr"; import useSWR from "swr";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { fetcher, FetchError } from "@/lib/fetcher";
import { RoomStatus } from "@/types"; import { RoomStatus } from "@/types";
async function fetcher(url: string) {
const r = await fetch(url);
if (!r.ok) {
const err = new Error(r.status === 404 ? "NOT_FOUND" : "FETCH_ERROR");
throw err;
}
return r.json();
}
export function useRoomPolling(roomId: string | undefined) { export function useRoomPolling(roomId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<RoomStatus>( const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
roomId ? `/api/room/${roomId}` : null, roomId ? `/api/room/${roomId}` : null,
fetcher, fetcher,
{ {
revalidateOnFocus: true, revalidateOnFocus: true,
shouldRetryOnError: (err) => err?.message !== "NOT_FOUND", shouldRetryOnError: (err) => {
if (err instanceof FetchError && (err.status === 401 || err.status === 404)) return false;
return err?.message !== "NOT_FOUND";
},
}, },
); );
+324
View File
@@ -1,7 +1,331 @@
/**
* Unified Amap (高德地图) API client.
* All external calls go through amapFetch() with consistent timeout + error handling.
*/
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
const AMAP_TIMEOUT_MS = 8000;
export function requireAmapApiKey(): string { export function requireAmapApiKey(): string {
const key = process.env.AMAP_API_KEY; const key = process.env.AMAP_API_KEY;
if (!key) throw new ApiError("服务配置异常,请稍后重试", 500); if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
return key; return key;
} }
async function amapFetch(url: URL): Promise<Record<string, unknown>> {
let res: Response;
try {
res = await fetch(url.toString(), {
signal: AbortSignal.timeout(AMAP_TIMEOUT_MS),
});
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
const data = await res.json();
return data as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Shared POI mapper
// ---------------------------------------------------------------------------
interface RawPoi {
id?: string;
name: string;
address?: string;
location?: string;
type?: string;
business?: { rating?: string; cost?: string; tel?: string };
}
export interface PoiResult {
id?: string;
name: string;
address: string;
lat: number;
lng: number;
rating?: number | null;
cost?: number | null;
}
function mapPois(pois: RawPoi[], opts?: { includeCost?: boolean }): PoiResult[] {
return pois
.filter((p) => p.location)
.map((p) => {
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
const ratingStr = p.business?.rating;
const rating =
ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
const result: PoiResult = {
id: p.id,
name: p.name,
address: p.address || "",
lat,
lng,
rating,
};
if (opts?.includeCost) {
const costStr = p.business?.cost;
result.cost =
costStr && costStr !== "[]" && costStr !== "0"
? Number(costStr)
: null;
}
return result;
});
}
// ---------------------------------------------------------------------------
// POI search — text (v5)
// ---------------------------------------------------------------------------
export async function searchPlaceText(params: {
keywords: string;
city?: string;
types?: string;
location?: string;
pageSize?: number;
}): Promise<PoiResult[]> {
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", requireAmapApiKey());
url.searchParams.set("keywords", params.keywords);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", String(params.pageSize ?? 10));
if (params.city) url.searchParams.set("region", params.city);
if (params.types) url.searchParams.set("types", params.types);
if (params.location) url.searchParams.set("location", params.location);
const data = await amapFetch(url);
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
return [];
}
return mapPois(data.pois as RawPoi[], { includeCost: true });
}
// ---------------------------------------------------------------------------
// POI search — around (v5)
// ---------------------------------------------------------------------------
export async function searchPlaceAround(params: {
keywords: string;
location: string;
radius?: number;
pageSize?: number;
}): Promise<PoiResult[]> {
const url = new URL("https://restapi.amap.com/v5/place/around");
url.searchParams.set("key", requireAmapApiKey());
url.searchParams.set("location", params.location);
url.searchParams.set("keywords", params.keywords);
url.searchParams.set("radius", String(params.radius ?? 5000));
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", String(params.pageSize ?? 8));
const data = await amapFetch(url);
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
return [];
}
return mapPois(data.pois as RawPoi[]);
}
// ---------------------------------------------------------------------------
// Input tips (v3)
// ---------------------------------------------------------------------------
export interface InputTipResult {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
export async function getInputTips(params: {
keywords: string;
location?: string;
}): Promise<InputTipResult[]> {
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", requireAmapApiKey());
url.searchParams.set("keywords", params.keywords);
url.searchParams.set("datatype", "poi");
if (params.location) url.searchParams.set("location", params.location);
const data = await amapFetch(url);
if (data.status !== "1" || !data.tips) return [];
return (
data.tips as {
id: string;
name: string;
district?: string;
address?: string;
location?: string;
}[]
)
.filter((t) => t.location && t.location !== "")
.slice(0, 8)
.map((t) => {
const [lng, lat] = t.location!.split(",").map(Number);
return {
id: t.id,
name: t.name,
district: t.district || "",
address: t.address || "",
lat,
lng,
};
});
}
// ---------------------------------------------------------------------------
// Reverse geocode (v3)
// ---------------------------------------------------------------------------
export interface RegeoResult {
name: string | null;
formatted: string | null;
}
export async function reverseGeocode(params: {
lat: number;
lng: number;
}): Promise<RegeoResult> {
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
url.searchParams.set("key", requireAmapApiKey());
url.searchParams.set("location", `${params.lng},${params.lat}`);
url.searchParams.set("extensions", "base");
const data = await amapFetch(url);
if (data.status !== "1" || !data.regeocode) {
return { name: null, formatted: null };
}
const regeocode = data.regeocode as Record<string, unknown>;
const comp = regeocode.addressComponent as Record<string, unknown> | undefined;
const district = (comp?.district || comp?.city || "") as string;
const township = (comp?.township || "") as string;
const neighborhood = (
(comp?.neighborhood as Record<string, unknown> | undefined)?.name || ""
) as string;
const name = [district, township, neighborhood]
.filter(Boolean)
.join(" ")
.trim();
return {
name: name || (regeocode.formatted_address as string) || null,
formatted: (regeocode.formatted_address as string) || null,
};
}
// ---------------------------------------------------------------------------
// Transit direction (v3)
// ---------------------------------------------------------------------------
export interface TransitResult {
durationMin: number;
distanceKm: number;
description: string;
mode: string;
}
export function parseTransitSegments(
segments: Record<string, unknown>[],
): { description: string; mode: string } {
const parts: string[] = [];
let hasSubway = false;
let hasBus = false;
for (const seg of segments) {
const bus = seg.bus as
| { buslines?: Record<string, unknown>[] }
| undefined;
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) {
const rawName = String(line.name ?? "");
const name = rawName.replace(/\(.*?\)$/, "").trim();
const viaNum = Number(line.via_num ?? 0);
const isSubway =
String(line.type ?? "").includes("地铁") ||
String(line.type ?? "").includes("轨道");
if (isSubway) hasSubway = true;
else hasBus = true;
const dep = (line.departure_stop as Record<string, unknown> | undefined)
?.name;
const arr = (line.arrival_stop as Record<string, unknown> | undefined)
?.name;
const stops = dep && arr ? ` ${dep}${arr}` : "";
parts.push(
viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`,
);
}
}
const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行";
const description = parts.length > 0 ? parts.join(" → ") : mode;
return { description, mode };
}
export async function getTransitDirection(params: {
originLat: number;
originLng: number;
destLat: number;
destLng: number;
city: string;
cityDest?: string;
}): Promise<TransitResult | null> {
const url = new URL(
"https://restapi.amap.com/v3/direction/transit/integrated",
);
url.searchParams.set("key", requireAmapApiKey());
url.searchParams.set("origin", `${params.originLng},${params.originLat}`);
url.searchParams.set(
"destination",
`${params.destLng},${params.destLat}`,
);
url.searchParams.set("city", params.city);
url.searchParams.set("cityd", params.cityDest ?? params.city);
url.searchParams.set("output", "json");
const data = await amapFetch(url);
if (data.status !== "1") return null;
const route = data.route as Record<string, unknown> | undefined;
const transits = route?.transits as Record<string, unknown>[] | undefined;
if (!transits?.length) return null;
const transit = transits[0];
const durationMin = Math.ceil(Number(transit.duration) / 60);
const distanceKm =
Math.round(Number(route!.distance) / 100) / 10;
const { description, mode } = parseTransitSegments(
(transit.segments as Record<string, unknown>[]) ?? [],
);
return { durationMin, distanceKm, description, mode };
}
// ---------------------------------------------------------------------------
// Convenience: POI search used by plan generation (dispatches text vs around)
// ---------------------------------------------------------------------------
export async function searchPois(
query: string,
searchType: string,
anchorLat: number,
anchorLng: number,
): Promise<PoiResult[]> {
const location = `${anchorLng},${anchorLat}`;
if (searchType === "category") {
return searchPlaceAround({
keywords: query,
location,
pageSize: 8,
});
}
return searchPlaceText({
keywords: query,
location,
pageSize: 8,
});
}
+27 -130
View File
@@ -6,7 +6,7 @@
* Fallback path: legacy pipeline (runLegacyPlanGeneration) * Fallback path: legacy pipeline (runLegacyPlanGeneration)
*/ */
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAmapApiKey } from "@/lib/amap"; import { searchPois, getTransitDirection } from "@/lib/amap";
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai"; import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
@@ -110,93 +110,6 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
return selected; return selected;
} }
// ---------------------------------------------------------------------------
// Transit segment parser
// ---------------------------------------------------------------------------
function parseTransitSegments(
segments: Record<string, unknown>[],
): { description: string; mode: string } {
const parts: string[] = [];
let hasSubway = false;
let hasBus = false;
for (const seg of segments) {
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) {
const rawName = String(line.name ?? "");
const name = rawName.replace(/\(.*?\)$/, "").trim(); // strip terminal info like "(上海南站--江杨北路)"
const viaNum = Number(line.via_num ?? 0);
const isSubway = String(line.type ?? "").includes("地铁") || String(line.type ?? "").includes("轨道");
if (isSubway) hasSubway = true; else hasBus = true;
const dep = (line.departure_stop as Record<string, unknown> | undefined)?.name;
const arr = (line.arrival_stop as Record<string, unknown> | undefined)?.name;
const stops = dep && arr ? ` ${dep}${arr}` : "";
parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`);
}
}
const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行";
const description = parts.length > 0 ? parts.join(" → ") : mode;
return { description, mode };
}
// ---------------------------------------------------------------------------
// POI search (shared by both paths)
// ---------------------------------------------------------------------------
export async function searchPois(
query: string,
searchType: string,
anchorLat: number,
anchorLng: number,
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
const apiKey = requireAmapApiKey();
if (searchType === "category") {
const url = new URL("https://restapi.amap.com/v5/place/around");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("keywords", query);
url.searchParams.set("radius", "5000");
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", query);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
function mapPois(
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
) {
return pois
.filter((p) => p.location)
.map((p) => {
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
const ratingStr = p.business?.rating;
return {
name: p.name,
address: p.address || "",
lat,
lng,
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
};
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Progress messages (kept for legacy path) // Progress messages (kept for legacy path)
@@ -347,29 +260,16 @@ function buildAgentTools(
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"], required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
}, },
execute: async (args) => { execute: async (args) => {
const oLat = Number(args.origin_lat);
const oLng = Number(args.origin_lng);
const dLat = Number(args.dest_lat);
const dLng = Number(args.dest_lng);
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
url.searchParams.set("key", apiKey);
url.searchParams.set("origin", `${oLng},${oLat}`);
url.searchParams.set("destination", `${dLng},${dLat}`);
url.searchParams.set("city", city);
url.searchParams.set("cityd", city);
url.searchParams.set("output", "json");
try { try {
const res = await fetch(url.toString()); const result = await getTransitDirection({
const data = await res.json(); originLat: Number(args.origin_lat),
if (data.status !== "1" || !data.route?.transits?.length) { originLng: Number(args.origin_lng),
return JSON.stringify({ error: "未找到公交路线" }); destLat: Number(args.dest_lat),
} destLng: Number(args.dest_lng),
const transit = data.route.transits[0]; city,
const durationMin = Math.ceil(Number(transit.duration) / 60); });
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10; if (!result) return JSON.stringify({ error: "未找到公交路线" });
const { description, mode } = parseTransitSegments(transit.segments ?? []); return JSON.stringify(result);
return JSON.stringify({ durationMin, distanceKm, description, mode });
} catch (e) { } catch (e) {
console.error("getTravelTimeTool failed:", e); console.error("getTravelTimeTool failed:", e);
return JSON.stringify({ error: "路线查询失败" }); return JSON.stringify({ error: "路线查询失败" });
@@ -544,9 +444,12 @@ async function runLegacyPlanGeneration(
}), }),
); );
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
const candidates: ScheduleContext["candidates"] = {}; const candidates: ScheduleContext["candidates"] = {};
for (const result of searchResults) { for (const result of searchResults) {
candidates[result.query] = result.pois; candidates[result.query] = toCandidates(result.pois);
} }
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category"); const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
@@ -570,7 +473,7 @@ async function runLegacyPlanGeneration(
}), }),
); );
for (const result of catResults) { for (const result of catResults) {
candidates[result.query] = result.pois; candidates[result.query] = toCandidates(result.pois);
} }
} }
@@ -626,7 +529,6 @@ async function runLegacyPlanGeneration(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function queryTransit( async function queryTransit(
apiKey: string,
oLng: number, oLng: number,
oLat: number, oLat: number,
dLng: number, dLng: number,
@@ -634,19 +536,15 @@ async function queryTransit(
city: string, city: string,
): Promise<{ durationMin: number; description: string } | null> { ): Promise<{ durationMin: number; description: string } | null> {
try { try {
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated"); const result = await getTransitDirection({
url.searchParams.set("key", apiKey); originLat: oLat,
url.searchParams.set("origin", `${oLng},${oLat}`); originLng: oLng,
url.searchParams.set("destination", `${dLng},${dLat}`); destLat: dLat,
url.searchParams.set("city", city); destLng: dLng,
url.searchParams.set("cityd", city); city,
url.searchParams.set("output", "json"); });
const res = await fetch(url.toString()); if (!result) return null;
const data = await res.json(); return { durationMin: result.durationMin, description: result.description };
if (data.status !== "1" || !data.route?.transits?.length) return null;
const transit = data.route.transits[0];
const { description } = parseTransitSegments(transit.segments ?? []);
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
} catch (e) { } catch (e) {
console.error("queryTransit failed:", e); console.error("queryTransit failed:", e);
return null; return null;
@@ -669,7 +567,6 @@ async function enrichTransitInfo(
homeLat: number, homeLat: number,
homeLng: number, homeLng: number,
): Promise<void> { ): Promise<void> {
const apiKey = requireAmapApiKey();
const cityParam = city || "上海"; const cityParam = city || "上海";
for (const day of days) { for (const day of days) {
@@ -680,7 +577,7 @@ async function enrichTransitInfo(
const dLat = Number(items[0].lat); const dLat = Number(items[0].lat);
const dLng = Number(items[0].lng); const dLng = Number(items[0].lng);
if (dLat && dLng) { if (dLat && dLng) {
const result = await queryTransit(apiKey, homeLng, homeLat, dLng, dLat, cityParam); const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
if (result) { if (result) {
day.transitFromStart = result.durationMin; day.transitFromStart = result.durationMin;
day.transitFromStartDescription = result.description; day.transitFromStartDescription = result.description;
@@ -695,7 +592,7 @@ async function enrichTransitInfo(
const dLat = Number(items[i + 1].lat); const dLat = Number(items[i + 1].lat);
const dLng = Number(items[i + 1].lng); const dLng = Number(items[i + 1].lng);
if (!oLat || !oLng || !dLat || !dLng) continue; if (!oLat || !oLng || !dLat || !dLng) continue;
const result = await queryTransit(apiKey, oLng, oLat, dLng, dLat, cityParam); const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
if (result) { if (result) {
items[i].transitToNext = result.durationMin; items[i].transitToNext = result.durationMin;
items[i].transitDescription = result.description; items[i].transitDescription = result.description;
@@ -708,7 +605,7 @@ async function enrichTransitInfo(
const oLat = Number(last.lat); const oLat = Number(last.lat);
const oLng = Number(last.lng); const oLng = Number(last.lng);
if (oLat && oLng) { if (oLat && oLng) {
const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam); const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
if (result) { if (result) {
day.transitToEnd = result.durationMin; day.transitToEnd = result.durationMin;
day.transitToEndDescription = result.description; day.transitToEndDescription = result.description;
+24
View File
@@ -0,0 +1,24 @@
/**
* Shared SWR fetcher with standard error handling.
*/
export class FetchError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "FetchError";
}
}
export async function fetcher<T = unknown>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new FetchError(
res.status === 404 ? "NOT_FOUND" : res.status === 401 ? "UNAUTHORIZED" : "FETCH_ERROR",
res.status,
);
}
return res.json();
}
+22
View File
@@ -0,0 +1,22 @@
export function guessCategory(activity: string): string | null {
const lower = activity.toLowerCase();
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
if (/手作|工坊|烘焙|插花|陶艺|DIY|体验/.test(lower)) return "experience";
if (/露营|徒步|赶海|农场|自然|野|营地/.test(lower)) return "nature";
if (/公园|山|湖|海|户外|骑/.test(lower)) return "outdoor";
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
return null;
}
export function formatDuration(minutes: number): string {
if (minutes >= 60) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h${m}min` : `${h}h`;
}
return `${minutes}min`;
}