-
-
+
+
-
各自滑卡
-
+ 各自滑卡
+
右滑喜欢的店
-
+
-
-
+
+
-
匹配结果
-
+ 匹配结果
+
滑中同一家就去
@@ -178,20 +188,24 @@ export default function InvitePage() {
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
-
);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 216ae40..e622b2d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
+import GlobalUserBadge from "@/components/GlobalUserBadge";
+
+import PageTransition from "@/components/PageTransition";
+import ToastProvider from "@/components/ToastProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -18,17 +22,28 @@ export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
userScalable: false,
+ viewportFit: "cover",
+ themeColor: "#10b981",
};
+const themeScript = `(function(){try{var t=localStorage.getItem("nowhatever-theme")||"system";var r=t;if(t==="system")r=window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",r)}catch(e){}})()`;
+
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
-
+
+
+
+
+
- {children}
+
+ {children}
+
+
);
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..885ea36
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,32 @@
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "NoWhatever — 别说随便",
+ short_name: "NoWhatever",
+ description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#030712",
+ theme_color: "#10b981",
+ orientation: "portrait",
+ icons: [
+ {
+ src: "/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ {
+ src: "/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ };
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 095461a..6f27208 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,629 +1,178 @@
"use client";
-import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
-import { motion, AnimatePresence } from "framer-motion";
-import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react";
-import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
-import { getAvatarBg } from "@/lib/avatars";
-import AuthModal from "@/components/AuthModal";
+import { motion } from "framer-motion";
+import { Zap, Gift, Clock, ChevronRight, Trophy } from "lucide-react";
import BrandLogo from "@/components/BrandLogo";
-import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
-import type { UserProfile, SceneType } from "@/types";
-
-interface LocationSuggestion {
- id: string;
- name: string;
- district: string;
- address: string;
- lat: number;
- lng: number;
-}
-
-type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
-
-const DISTANCE_OPTIONS = [
- { label: "1km", value: 1000 },
- { label: "3km", value: 3000 },
- { label: "5km", value: 5000 },
-] as const;
-
-type GpsResult =
- | { ok: true; lat: number; lng: number }
- | { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
-
-function requestGps(): Promise
{
- return new Promise((resolve) => {
- if (!navigator.geolocation) {
- resolve({ ok: false, reason: "unsupported" });
- return;
- }
-
- navigator.geolocation.getCurrentPosition(
- (pos) =>
- resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
- (err) => {
- const reason =
- err.code === err.PERMISSION_DENIED
- ? "denied"
- : err.code === err.TIMEOUT
- ? "timeout"
- : "unknown";
- resolve({ ok: false, reason });
- },
- { timeout: 8000, enableHighAccuracy: false },
- );
- });
-}
-
-async function reverseGeocode(lat: number, lng: number): Promise {
- try {
- const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
- const data = await res.json();
- return data.name || data.formatted || null;
- } catch {
- return null;
- }
-}
export default function LandingPage() {
const router = useRouter();
- const [roomCode, setRoomCode] = useState("");
- const [loading, setLoading] = useState(false);
- const [loadingText, setLoadingText] = useState("");
- const [error, setError] = useState("");
-
- const [locationQuery, setLocationQuery] = useState("");
- const [suggestions, setSuggestions] = useState([]);
- const [showSuggestions, setShowSuggestions] = useState(false);
- const [selectedLocation, setSelectedLocation] = useState(null);
- const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
- const [radius, setRadius] = useState(3000);
- const [priceRange, setPriceRange] = useState("any");
- const [cuisine, setCuisine] = useState("");
- const suggestRef = useRef(null);
- const debounceRef = useRef>(null);
-
- const [gpsStatus, setGpsStatus] = useState("idle");
- const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null);
- const [gpsLocationName, setGpsLocationName] = useState(null);
-
- const [scene, setScene] = useState("eat");
- const sceneConfig = getSceneConfig(scene);
-
- const [profile, setProfile] = useState(null);
- const [authModalOpen, setAuthModalOpen] = useState(false);
-
- useEffect(() => {
- const cached = getCachedProfile();
- if (cached) setProfile(cached);
-
- const prefs = getCachedPreferences();
- if (prefs.cuisine) setCuisine(prefs.cuisine);
- if (prefs.priceRange) setPriceRange(prefs.priceRange);
- if (prefs.radius) setRadius(prefs.radius);
- }, []);
-
- const handleSceneChange = useCallback((s: SceneType) => {
- setScene(s);
- setCuisine("");
- setPriceRange("any");
- }, []);
-
- const doGpsLocate = useCallback(async () => {
- setGpsStatus("locating");
- const result = await requestGps();
- if (result.ok) {
- setGpsCoords({ lat: result.lat, lng: result.lng });
- setGpsStatus("success");
- const name = await reverseGeocode(result.lat, result.lng);
- if (name) setGpsLocationName(name);
- } else {
- setGpsCoords(null);
- setGpsLocationName(null);
- setGpsStatus(result.reason === "denied" ? "denied" : "failed");
- }
- }, []);
-
- useEffect(() => {
- doGpsLocate();
- }, [doGpsLocate]);
-
- const fetchSuggestions = useCallback(async (query: string) => {
- if (query.length < 1) {
- setSuggestions([]);
- setShowSuggestions(false);
- return;
- }
- setFetchingSuggestions(true);
- try {
- const res = await fetch(`/api/location/suggest?keywords=${encodeURIComponent(query)}`);
- const data: LocationSuggestion[] = await res.json();
- setSuggestions(data);
- setShowSuggestions(data.length > 0);
- } catch {
- setSuggestions([]);
- } finally {
- setFetchingSuggestions(false);
- }
- }, []);
-
- const handleLocationInput = (val: string) => {
- setLocationQuery(val);
- setSelectedLocation(null);
- if (debounceRef.current) clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
- };
-
- const handleSelectLocation = (loc: LocationSuggestion) => {
- setSelectedLocation(loc);
- setLocationQuery(loc.name);
- setShowSuggestions(false);
- setSuggestions([]);
- };
-
- const clearLocation = () => {
- setSelectedLocation(null);
- setLocationQuery("");
- setSuggestions([]);
- setShowSuggestions(false);
- };
-
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
- setShowSuggestions(false);
- }
- };
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
-
- const joinRoom = async (roomId: string) => {
- const userId = getUserId();
- const res = await fetch(`/api/room/${roomId}/join`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId }),
- });
- if (!res.ok) throw new Error("房间不存在");
- return roomId;
- };
-
- const handleCreate = async () => {
- setError("");
-
- let coords: { lat: number; lng: number };
-
- if (selectedLocation) {
- coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
- } else if (gpsCoords) {
- coords = gpsCoords;
- } else if (gpsStatus === "locating") {
- setError("正在定位中,请稍候...");
- return;
- } else {
- setError("无法获取位置,请在上方搜索并选择一个地点");
- return;
- }
-
- setLoading(true);
-
- try {
- setLoadingText(sceneConfig.loadingText);
-
- const res = await fetch("/api/room/create", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId(), scene }),
- });
-
- const data = await res.json();
-
- if (!res.ok) {
- throw new Error(data.error || "创建房间失败");
- }
-
- if (!data.roomId) {
- throw new Error("创建房间失败");
- }
-
- setLoadingText("正在进入房间...");
- await joinRoom(data.roomId);
- router.push(`/room/${data.roomId}`);
- } catch (e) {
- setError(e instanceof Error ? e.message : "创建失败,请重试");
- setLoading(false);
- setLoadingText("");
- }
- };
-
- const handleJoin = async (e: React.FormEvent) => {
- e.preventDefault();
- if (roomCode.length !== 4) {
- setError("请输入 4 位房间号");
- return;
- }
- setLoading(true);
- setError("");
- try {
- await joinRoom(roomCode);
- router.push(`/room/${roomCode}`);
- } catch {
- setError("房间不存在,请检查房间号");
- setLoading(false);
- }
- };
return (
-
- {/* Profile / Auth button */}
-
- {profile ? (
- router.push("/profile")}
- className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors active:opacity-80 ${getAvatarBg(profile.avatar)}`}
- >
- {profile.avatar}
- {profile.username}
-
- ) : (
- setAuthModalOpen(true)}
- className="flex h-8 items-center gap-1.5 rounded-full bg-zinc-100 px-3 text-xs font-medium text-zinc-500 transition-colors active:bg-zinc-200"
- >
-
- 登录
-
- )}
-
+
+ {/* Ambient glow */}
+
+
+ {/* Header */}
-
-
-
+
+
+
NoWhatever
-
- 别说随便
+
+ 别说随便 · 亲密关系决策引擎
- {sceneConfig.subtitle}
+ 别再说"随便"了。
+
+ 两个模式,覆盖你们所有的选择困难症。
-
-
-
-
-
-
-
-
-
-
-
-
-
- {SCENES.map((s) => {
- const cfg = getSceneConfig(s);
- const active = scene === s;
- return (
- handleSceneChange(s)}
- disabled={loading}
- className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
- active
- ? "bg-emerald-500 text-white shadow-md shadow-emerald-200"
- : "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
- }`}
- >
- {cfg.emoji}
- {cfg.label}
-
- );
- })}
-
-
-
-
-
-
- handleLocationInput(e.target.value)}
- onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
- disabled={loading}
- className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
- />
- {(selectedLocation || locationQuery) && !loading && (
-
-
-
- )}
- {fetchingSuggestions && (
-
- )}
-
-
- {selectedLocation && (
-
-
-
- {selectedLocation.district} {selectedLocation.address || selectedLocation.name}
-
-
- )}
-
- {!selectedLocation && !locationQuery && gpsStatus === "locating" && (
-
-
- 正在获取当前位置...
-
- )}
-
- {!selectedLocation && !locationQuery && gpsStatus === "success" && (
-
-
-
- 当前位置:{gpsLocationName || "已定位"}
-
-
- )}
-
- {!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && (
-
-
-
-
- {gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置
-
-
-
- 重试
-
-
- )}
-
- {!selectedLocation && !locationQuery && gpsStatus === "idle" && (
-
-
- 将使用当前定位
-
- )}
-
-
- {showSuggestions && (
-
- {suggestions.map((s) => (
-
- handleSelectLocation(s)}
- className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
- >
-
-
-
{s.name}
-
{s.district} {s.address}
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
{sceneConfig.tagLabel}
-
- setCuisine(e.target.value)}
- disabled={loading}
- className="h-7 w-full rounded-full border-none bg-white pl-3 pr-7 text-xs text-zinc-700 outline-none ring-1 ring-zinc-200 transition-colors placeholder:text-zinc-300 focus:ring-2 focus:ring-emerald-300 disabled:opacity-50"
- />
- {cuisine && !loading && (
- setCuisine("")}
- className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
- >
-
-
- )}
-
-
-
-
-
-
-
- {sceneConfig.hotTags.map((tag) => (
- setCuisine(tag)}
- disabled={loading}
- className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
- cuisine === tag
- ? "bg-emerald-500 text-white shadow-sm"
- : "bg-white text-zinc-500 hover:bg-zinc-200"
- }`}
- >
- {tag}
-
- ))}
-
-
-
-
-
距离
-
- {DISTANCE_OPTIONS.map((opt) => (
- setRadius(opt.value)}
- disabled={loading}
- className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
- radius === opt.value
- ? "bg-emerald-500 text-white shadow-sm"
- : "bg-white text-zinc-500 hover:bg-zinc-200"
- }`}
- >
- {opt.label}
-
- ))}
-
-
-
-
-
人均
-
- {sceneConfig.priceOptions.map((opt) => (
- setPriceRange(opt.value)}
- disabled={loading}
- className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
- priceRange === opt.value
- ? "bg-emerald-500 text-white shadow-sm"
- : "bg-white text-zinc-500 hover:bg-zinc-200"
- }`}
- >
- {opt.label}
-
- ))}
-
-
-
-
-
-
+ {/* Card A: Panic Mode */}
+ router.push("/panic")}
+ className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-5 pb-4 text-left shadow-xl shadow-orange-500/25 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-orange-500/35"
+ initial={{ x: -40, opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ transition={{ duration: 0.5, delay: 0.3 }}
+ whileHover={{ scale: 1.02, rotate: -0.5 }}
+ whileTap={{ scale: 0.97 }}
>
- {loading && loadingText ? (
- <>
-
- {loadingText}
- >
- ) : (
- <>
-
- 创建新房间
- >
- )}
-
+
+
-
+
+
+
+
+
+
+
⚡️ 极速救场
+
+ PANIC MODE
+
+
+
+
+ 10秒内出结果,立刻闭嘴,听天由命
+
+
+
+
+ 即时决策 · 转盘匹配
+
+
+ 进入
+
+
+
+
-
+
- {error && (
-
- {error}
-
- )}
-
+ {/* Card B: Adventure Roulette */}
+ router.push("/blindbox")}
+ className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-5 pb-4 text-left shadow-xl shadow-purple-600/20 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-purple-500/30"
+ initial={{ x: 40, opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ transition={{ duration: 0.5, delay: 0.4 }}
+ whileHover={{ scale: 1.02, rotate: 0.5 }}
+ whileTap={{ scale: 0.97 }}
+ >
+
+
+
+
+
+
+
+
+
+
🎁 周末契约
+
+ ADVENTURE ROULETTE
+
+
+
+
+ 丢入疯狂想法,周末盲盒开奖,绝不反悔
+
+
+
+
+ 盲盒蓄水 · 仪式开奖
+
+
+ 进入
+
+
+
+
+
+
+
+
+
+ {/* Achievements entry */}
+ router.push("/achievements")}
+ className="mt-6 flex w-full max-w-sm items-center gap-3 rounded-xl bg-surface px-4 py-3 ring-1 ring-border transition-colors active:bg-elevated"
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.5 }}
+ whileTap={{ scale: 0.98 }}
+ >
+
+
+
+
+
+
+
+ {/* Footer */}
+
+ NOWHATEVER — 拒绝随便,从今天开始
+
- setAuthModalOpen(false)}
- onAuth={(p) => setProfile(p)}
- />
);
}
diff --git a/src/app/panic/page.tsx b/src/app/panic/page.tsx
new file mode 100644
index 0000000..a3ea0e8
--- /dev/null
+++ b/src/app/panic/page.tsx
@@ -0,0 +1,589 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { motion, AnimatePresence } from "framer-motion";
+import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, ArrowLeft } from "lucide-react";
+import { getUserId, getCachedPreferences } from "@/lib/userId";
+import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
+import { useGeolocation } from "@/hooks/useGeolocation";
+import { joinRoom } from "@/lib/room";
+import type { SceneType } from "@/types";
+
+interface LocationSuggestion {
+ id: string;
+ name: string;
+ district: string;
+ address: string;
+ lat: number;
+ lng: number;
+}
+
+const DISTANCE_OPTIONS = [
+ { label: "1km", value: 1000 },
+ { label: "3km", value: 3000 },
+ { label: "5km", value: 5000 },
+] as const;
+
+export default function PanicPage() {
+ const router = useRouter();
+ const [roomCode, setRoomCode] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [loadingText, setLoadingText] = useState("");
+ const [error, setError] = useState("");
+
+ const [locationQuery, setLocationQuery] = useState("");
+ const [suggestions, setSuggestions] = useState
([]);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [selectedLocation, setSelectedLocation] = useState(null);
+ const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
+ const [radius, setRadius] = useState(3000);
+ const [priceRange, setPriceRange] = useState("any");
+ const [cuisines, setCuisines] = useState([]);
+ const [cuisineInput, setCuisineInput] = useState("");
+ const suggestRef = useRef(null);
+ const debounceRef = useRef>(null);
+
+ const geo = useGeolocation();
+
+ const [scene, setScene] = useState("eat");
+ const sceneConfig = getSceneConfig(scene);
+
+ useEffect(() => {
+ const prefs = getCachedPreferences();
+ if (prefs.cuisines) setCuisines(prefs.cuisines);
+ else if (prefs.cuisine) setCuisines([prefs.cuisine]);
+ if (prefs.priceRange) setPriceRange(prefs.priceRange);
+ if (prefs.radius) setRadius(prefs.radius);
+ }, []);
+
+ const handleSceneChange = useCallback((s: SceneType) => {
+ setScene(s);
+ setCuisines([]);
+ setPriceRange("any");
+ }, []);
+
+ const fetchSuggestions = useCallback(async (query: string) => {
+ if (query.length < 1) {
+ setSuggestions([]);
+ setShowSuggestions(false);
+ return;
+ }
+ setFetchingSuggestions(true);
+ try {
+ const params = new URLSearchParams({ keywords: query });
+ if (geo.coords) {
+ params.set("location", `${geo.coords.lng},${geo.coords.lat}`);
+ }
+ const res = await fetch(`/api/location/suggest?${params.toString()}`);
+ if (!res.ok) { setSuggestions([]); setShowSuggestions(false); return; }
+ const data: LocationSuggestion[] = await res.json();
+ setSuggestions(Array.isArray(data) ? data : []);
+ setShowSuggestions(Array.isArray(data) && data.length > 0);
+ } catch {
+ setSuggestions([]);
+ } finally {
+ setFetchingSuggestions(false);
+ }
+ }, [geo.coords]);
+
+ const handleLocationInput = (val: string) => {
+ setLocationQuery(val);
+ setSelectedLocation(null);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
+ };
+
+ const handleSelectLocation = (loc: LocationSuggestion) => {
+ setSelectedLocation(loc);
+ setLocationQuery(loc.name);
+ setShowSuggestions(false);
+ setSuggestions([]);
+ };
+
+ const clearLocation = () => {
+ setSelectedLocation(null);
+ setLocationQuery("");
+ setSuggestions([]);
+ setShowSuggestions(false);
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
+ setShowSuggestions(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+
+ const handleCreate = async () => {
+ setError("");
+
+ let coords: { lat: number; lng: number };
+
+ if (selectedLocation) {
+ coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
+ } else if (geo.coords) {
+ coords = geo.coords;
+ } else if (geo.status === "locating") {
+ setError("正在定位中,请稍候...");
+ return;
+ } else {
+ setError("无法获取位置,请在上方搜索并选择一个地点");
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ setLoadingText(sceneConfig.loadingText);
+
+ const res = await fetch("/api/room/create", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...coords, radius, priceRange, cuisine: cuisines.join("|"), userId: getUserId(), scene }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || "创建房间失败");
+ }
+
+ if (!data.roomId) {
+ throw new Error("创建房间失败");
+ }
+
+ setLoadingText("正在进入房间...");
+ await joinRoom(data.roomId, getUserId());
+ router.push(`/room/${data.roomId}`);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "创建失败,请重试");
+ setLoading(false);
+ setLoadingText("");
+ }
+ };
+
+ const handleJoin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (roomCode.length !== 4) {
+ setError("请输入 4 位房间号");
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ await joinRoom(roomCode, getUserId());
+ router.push(`/room/${roomCode}`);
+ } catch {
+ setError("房间不存在,请检查房间号");
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Back button */}
+
router.push("/")}
+ className="absolute left-4 top-3 flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
+ >
+
+ 返回
+
+
+
+
+
+
+
+
+ 极速救场
+
+
+ 10秒内出结果
+
+
+
+
+
+ {sceneConfig.subtitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {SCENES.map((s) => {
+ const cfg = getSceneConfig(s);
+ const active = scene === s;
+ return (
+ handleSceneChange(s)}
+ disabled={loading}
+ className={`flex shrink-0 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all disabled:opacity-50 ${
+ active
+ ? "bg-orange-500 text-white shadow-md shadow-orange-500/25"
+ : "bg-surface text-muted ring-1 ring-border hover:bg-elevated"
+ }`}
+ >
+ {cfg.emoji}
+ {cfg.label}
+
+ );
+ })}
+
+
+
+
+
+
+
+ handleLocationInput(e.target.value)}
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
+ disabled={loading}
+ className="h-10 w-full rounded-xl border-none bg-surface pl-9 pr-9 text-sm text-foreground outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
+ />
+ {(selectedLocation || locationQuery) && !loading && (
+
+
+
+ )}
+ {fetchingSuggestions && (
+
+ )}
+
+
+ {selectedLocation && (
+
+
+
+ {selectedLocation.district} {selectedLocation.address || selectedLocation.name}
+
+
+ )}
+
+ {!selectedLocation && !locationQuery && geo.status === "locating" && (
+
+
+ 正在获取当前位置...
+
+ )}
+
+ {!selectedLocation && !locationQuery && geo.status === "success" && (
+
+
+
+ 当前位置:{geo.locationName || "已定位"}
+
+
+ )}
+
+ {!selectedLocation && !locationQuery && (geo.status === "failed" || geo.status === "denied") && (
+
+
+
+
+ {geo.status === "denied" ? "定位权限被拒绝" : "定位失败"},请搜索选择位置
+
+
+
+ 重试
+
+
+ )}
+
+ {!selectedLocation && !locationQuery && geo.status === "idle" && (
+
+
+ 将使用当前定位
+
+ )}
+
+
+ {showSuggestions && (
+
+ {suggestions.map((s) => (
+
+ handleSelectLocation(s)}
+ className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-elevated"
+ >
+
+
+
{s.name}
+
{s.district} {s.address}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
{sceneConfig.tagLabel}
+
+ setCuisineInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const val = cuisineInput.trim();
+ if (val && !cuisines.includes(val)) {
+ setCuisines((prev) => [...prev, val]);
+ }
+ setCuisineInput("");
+ }
+ }}
+ disabled={loading}
+ className="h-7 w-full rounded-full border-none bg-elevated pl-3 pr-7 text-xs text-foreground outline-none ring-1 ring-subtle transition-colors placeholder:text-dim focus:ring-2 focus:ring-orange-500/50 disabled:opacity-50"
+ />
+ {cuisineInput && !loading && (
+ setCuisineInput("")}
+ aria-label="清除输入"
+ className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted hover:text-secondary"
+ >
+
+
+ )}
+
+
+
+
+
+
+
+ {sceneConfig.hotTags.map((tag) => {
+ const selected = cuisines.includes(tag);
+ return (
+
+ setCuisines((prev) =>
+ selected ? prev.filter((t) => t !== tag) : [...prev, tag],
+ )
+ }
+ disabled={loading}
+ className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
+ selected
+ ? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
+ : "bg-elevated text-muted hover:bg-subtle"
+ }`}
+ >
+ {tag}
+
+ );
+ })}
+ {cuisines.filter((t) => !sceneConfig.hotTags.includes(t)).map((tag) => (
+ setCuisines((prev) => prev.filter((t) => t !== tag))}
+ disabled={loading}
+ className="flex h-6 items-center gap-1 rounded-full bg-orange-500 pl-2.5 pr-1.5 text-xs font-medium text-white shadow-sm shadow-orange-500/25 disabled:opacity-50"
+ >
+ {tag}
+
+
+ ))}
+ {cuisines.length > 0 && !loading && (
+ setCuisines([])}
+ aria-label="清除口味"
+ className="ml-1 flex h-5 items-center gap-0.5 rounded-full px-1.5 text-xs text-muted hover:text-secondary"
+ >
+
+ 清空
+
+ )}
+
+
+
+
+
距离
+
+ {DISTANCE_OPTIONS.map((opt) => (
+ setRadius(opt.value)}
+ disabled={loading}
+ className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
+ radius === opt.value
+ ? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
+ : "bg-elevated text-muted hover:bg-subtle"
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
人均
+
+ {sceneConfig.priceOptions.map((opt) => (
+ setPriceRange(opt.value)}
+ disabled={loading}
+ className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
+ priceRange === opt.value
+ ? "bg-orange-500 text-white shadow-sm shadow-orange-500/25"
+ : "bg-elevated text-muted hover:bg-subtle"
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ {loading && loadingText ? (
+ <>
+
+ {loadingText}
+ >
+ ) : (
+ <>
+
+ 创建新房间
+ >
+ )}
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index dc8a67d..317ab09 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -6,12 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Mail,
- Clock,
- Star,
- MapPin,
- Trash2,
Loader2,
- ChevronDown,
LogOut,
Lock,
Edit3,
@@ -19,27 +14,26 @@ import {
X,
Eye,
EyeOff,
+ Zap,
+ Trophy,
+ ChevronRight,
} from "lucide-react";
+import Card from "@/components/Card";
+import Input from "@/components/Input";
+import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
+import { useToast } from "@/hooks/useToast";
+import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
-import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
-
-function firstImage(r: Restaurant): string {
- if (r.images?.length > 0) return r.images[0];
- // backward compat: old DB records may have `image` instead of `images`
- const legacy = (r as unknown as Record).image;
- return typeof legacy === "string" ? legacy : "";
-}
+import type { UserProfile, UserPreferences, FavoriteRecord } from "@/types";
export default function ProfilePage() {
const router = useRouter();
const [userId, setUserId] = useState("");
- const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences }) | null>(null);
+ const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true);
- const [history, setHistory] = useState([]);
const [favorites, setFavorites] = useState([]);
- const [historyLoading, setHistoryLoading] = useState(false);
const [favLoading, setFavLoading] = useState(false);
const [editingUsername, setEditingUsername] = useState(false);
@@ -61,14 +55,8 @@ export default function ProfilePage() {
const [emailSaving, setEmailSaving] = useState(false);
const [emailMsg, setEmailMsg] = useState("");
- const [showHistory, setShowHistory] = useState(true);
const [showFavorites, setShowFavorites] = useState(true);
- const [toast, setToast] = useState("");
-
- const showToast = useCallback((msg: string) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }, []);
+ const toast = useToast();
useEffect(() => {
const cached = getCachedProfile();
@@ -101,17 +89,10 @@ export default function ProfilePage() {
useEffect(() => {
if (!userId) return;
- setHistoryLoading(true);
- fetch(`/api/user/history?userId=${userId}`)
- .then((r) => r.json())
- .then(setHistory)
- .catch(() => {})
- .finally(() => setHistoryLoading(false));
-
setFavLoading(true);
fetch(`/api/user/favorite?userId=${userId}`)
- .then((r) => r.json())
- .then(setFavorites)
+ .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
+ .then((data) => setFavorites(Array.isArray(data) ? data : []))
.catch(() => {})
.finally(() => setFavLoading(false));
}, [userId]);
@@ -136,7 +117,7 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
setEditingUsername(false);
- showToast("用户名已更新");
+ toast.show("用户名已更新");
} else {
setUsernameMsg(data.error ?? "更新失败");
}
@@ -175,7 +156,7 @@ export default function ProfilePage() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
- showToast("密码已更新");
+ toast.show("密码已更新");
} else {
setPasswordMsg(data.error ?? "更新失败");
}
@@ -197,10 +178,10 @@ export default function ProfilePage() {
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
setEditingAvatar(false);
- showToast("头像已更新");
+ toast.show("头像已更新");
}
} catch {
- showToast("更新失败");
+ toast.show("更新失败");
}
};
@@ -239,9 +220,9 @@ export default function ProfilePage() {
body: JSON.stringify({ userId, favoriteId: favId }),
});
setFavorites((f) => f.filter((x) => x.id !== favId));
- showToast("已取消收藏");
+ toast.show("已取消收藏");
} catch {
- showToast("操作失败");
+ toast.show("操作失败");
}
};
@@ -252,52 +233,62 @@ export default function ProfilePage() {
if (loading) {
return (
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
if (!profile) return null;
- const amapNavUrl = (r: Restaurant) =>
- r.location
- ? `https://uri.amap.com/marker?position=${r.location}&name=${encodeURIComponent(r.name)}&callnative=1`
- : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name)}`;
return (
-
+
{/* Profile card */}
-
+
setEditingAvatar(!editingAvatar)}
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
>
{profile.avatar}
-
+
{editingUsername ? (
- {
@@ -306,34 +297,41 @@ export default function ProfilePage() {
}}
maxLength={16}
autoFocus
- className="h-8 flex-1 rounded-lg border border-zinc-200 px-2 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
+ size="sm"
+ className="flex-1"
/>
{usernameSaving ? : }
{ setEditingUsername(false); setUsernameMsg(""); }}
- className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500"
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
>
) : (
-
{profile.username}
+ {profile.username}
{ setEditingUsername(true); setNewUsername(profile.username); }}
- className="text-zinc-400 transition-colors active:text-zinc-600"
+ className="text-muted transition-colors active:text-secondary"
>
)}
- {usernameMsg &&
{usernameMsg}
}
+ {usernameMsg &&
{usernameMsg}
}
+ {(profile.decisionCount ?? 0) > 0 && (
+
+
+ 已拯救 {profile.decisionCount} 次选择困难症
+
+ )}
@@ -354,8 +352,8 @@ export default function ProfilePage() {
onClick={() => handleSaveAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
profile.avatar === a.emoji
- ? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
- : "bg-zinc-50 hover:bg-zinc-100"
+ ? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
+ : "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
@@ -365,21 +363,16 @@ export default function ProfilePage() {
)}
-
+
{/* Change password */}
-
+
{ setEditingPassword(!editingPassword); setPasswordMsg(""); }}
className="flex w-full items-center gap-2"
>
-
- 修改密码
+
+ 修改密码
@@ -393,46 +386,46 @@ export default function ProfilePage() {
>
-
当前密码
+
当前密码
- { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
- className="h-9 w-full rounded-lg border border-zinc-200 px-3 pr-9 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
+ className="pr-9"
/>
setShowPassword(!showPassword)}
- className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400"
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
>
{showPassword ? : }
{passwordMsg && (
-
+
{passwordMsg}
)}
@@ -440,7 +433,7 @@ export default function ProfilePage() {
{passwordSaving ? : "保存新密码"}
@@ -448,22 +441,17 @@ export default function ProfilePage() {
)}
-
+
{/* Email binding */}
-
+
-
-
绑定邮箱
- (可选)
+
+ 绑定邮箱
+ (可选)
-
{emailSaving ? : "保存"}
{emailMsg && (
-
+
{emailMsg}
)}
-
+
- {/* Decision History */}
-
+ {/* Achievements link */}
+
setShowHistory((v) => !v)}
- className="flex w-full items-center justify-between"
+ onClick={() => router.push("/achievements")}
+ className="flex w-full items-center gap-2"
>
-
-
-
- 决策记录 {history.length > 0 && `(${history.length})`}
-
-
-
-
-
+
+ 成就墙
+ 决策记录 · 契约成就
+
+
-
- {showHistory && (
-
- {historyLoading ? (
-
-
-
- ) : history.length === 0 ? (
-
- 还没有决策记录
-
- ) : (
-
- )}
-
- )}
-
-
-
- {/* Favorites */}
-
- setShowFavorites((v) => !v)}
- className="flex w-full items-center justify-between"
- >
-
-
-
- 收藏餐厅 {favorites.length > 0 && `(${favorites.length})`}
-
-
-
-
-
-
-
-
- {showFavorites && (
-
- {favLoading ? (
-
-
-
- ) : favorites.length === 0 ? (
-
- 还没有收藏的餐厅
-
- ) : (
-
- {favorites.map((f) => {
- const r = f.restaurantData;
- return (
-
- {firstImage(r) && (
-
})
- )}
-
-
{r.name}
-
-
-
- {r.rating}
-
- {r.price}
- {r.distance && (
-
-
- {r.distance}
-
- )}
-
-
-
handleRemoveFavorite(f.id)}
- className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-zinc-400 transition-colors active:bg-zinc-200 active:text-rose-500"
- >
-
-
-
- );
- })}
-
- )}
-
- )}
-
-
+
setShowFavorites((v) => !v)}
+ onRemove={handleRemoveFavorite}
+ onEmpty={() => router.push("/blindbox")}
+ delay={0.2}
+ />
{/* Logout */}
-
+
退出登录
-
- {toast && (
-
- {toast}
-
- )}
-
);
}
diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx
index 989f1ea..e7c25c2 100644
--- a/src/app/room/[id]/page.tsx
+++ b/src/app/room/[id]/page.tsx
@@ -4,10 +4,14 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck";
+import { SwipeDeckSkeleton } from "@/components/Skeleton";
import LeaveConfirmModal from "@/components/LeaveConfirmModal";
+import Button from "@/components/Button";
import { useRoomPolling } from "@/hooks/useRoomPolling";
import { getUserId } from "@/lib/userId";
+import { joinRoom } from "@/lib/room";
import { getSceneConfig } from "@/lib/sceneConfig";
+import { useToast } from "@/hooks/useToast";
export default function RoomPage() {
const params = useParams<{ id: string }>();
@@ -19,6 +23,7 @@ export default function RoomPage() {
const [joinFailed, setJoinFailed] = useState(false);
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
const leavingRef = useRef(false);
+ const toast = useToast();
const {
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
@@ -29,14 +34,9 @@ export default function RoomPage() {
const id = getUserId();
setUserId(id);
- fetch(`/api/room/${roomId}/join`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: id }),
- }).then((res) => {
- if (res.ok) setJoined(true);
- else setJoinFailed(true);
- }).catch(() => setJoinFailed(true));
+ joinRoom(roomId, id)
+ .then(() => setJoined(true))
+ .catch(() => setJoinFailed(true));
}, [roomId]);
useEffect(() => {
@@ -76,31 +76,48 @@ export default function RoomPage() {
}, []);
const handleReset = useCallback(async () => {
- await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
- await mutate();
- }, [roomId, mutate]);
+ try {
+ const res = await fetch(`/api/room/${roomId}/reset`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "重置失败");
+ }
+ await mutate();
+ } catch (e) {
+ toast.show(e instanceof Error ? e.message : "重置失败");
+ }
+ }, [roomId, userId, mutate, toast]);
const handleNarrow = useCallback(async (restaurantIds: string[]) => {
- await fetch(`/api/room/${roomId}/reset`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ restaurantIds }),
- });
- await mutate();
- }, [roomId, mutate]);
+ try {
+ const res = await fetch(`/api/room/${roomId}/reset`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId, restaurantIds }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "缩小范围失败");
+ }
+ await mutate();
+ } catch (e) {
+ toast.show(e instanceof Error ? e.message : "操作失败");
+ }
+ }, [roomId, userId, mutate, toast]);
if (notFound || joinFailed) {
return (
🍜
-
房间不存在或已过期
-
房间号可能有误,或房间已超过 24 小时
-
router.push("/")}
- className="mt-2 h-10 rounded-xl bg-emerald-500 px-6 text-sm font-bold text-white shadow-sm transition-colors hover:bg-emerald-600"
- >
+ 房间不存在或已过期
+ 房间号可能有误,或房间已超过 24 小时
+ router.push("/")}>
返回首页
-
+
);
}
@@ -111,12 +128,7 @@ export default function RoomPage() {
const sceneConfig = getSceneConfig(scene);
if (!ready) {
- return (
-
- );
+ return
;
}
return (
diff --git a/src/components/ActionButtons.tsx b/src/components/ActionButtons.tsx
index 86f4238..e9e6f8b 100644
--- a/src/components/ActionButtons.tsx
+++ b/src/components/ActionButtons.tsx
@@ -14,9 +14,9 @@ export default function ActionButtons({
disabled,
}: ActionButtonsProps) {
return (
-
+
onAction("left")}
@@ -27,7 +27,7 @@ export default function ActionButtons({
onAction("right")}
diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx
index 1a233ba..237a2f1 100644
--- a/src/components/AuthModal.tsx
+++ b/src/components/AuthModal.tsx
@@ -1,11 +1,14 @@
"use client";
-import { useState, useRef } from "react";
-import { motion, AnimatePresence } from "framer-motion";
-import { X, Loader2, Eye, EyeOff } from "lucide-react";
+import { useState, useEffect } from "react";
+import { motion } from "framer-motion";
+import { X, Eye, EyeOff } from "lucide-react";
import { AVATARS } from "@/lib/avatars";
import { setCachedProfile } from "@/lib/userId";
import type { UserProfile } from "@/types";
+import Modal from "@/components/Modal";
+import Button from "@/components/Button";
+import Input from "@/components/Input";
type Tab = "login" | "register";
@@ -13,11 +16,11 @@ interface AuthModalProps {
open: boolean;
onClose: () => void;
onAuth: (profile: UserProfile) => void;
+ defaultTab?: Tab;
}
-export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
- const backdropRef = useRef(null);
- const [tab, setTab] = useState("login");
+export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) {
+ const [tab, setTab] = useState(defaultTab);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -26,10 +29,6 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
- const handleBackdropClick = (e: React.MouseEvent) => {
- if (e.target === backdropRef.current) onClose();
- };
-
const resetForm = () => {
setUsername("");
setPassword("");
@@ -37,8 +36,17 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
setAvatar(AVATARS[0].emoji);
setShowPassword(false);
setError("");
+ setLoading(false);
};
+ useEffect(() => {
+ if (open) {
+ setTab(defaultTab);
+ resetForm();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
const switchTab = (t: Tab) => {
setTab(t);
resetForm();
@@ -113,169 +121,142 @@ export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
};
return (
-
- {open && (
-
+
+ 欢迎
+
-
+
+
+
+
+ {(["login", "register"] as const).map((t) => (
+
switchTab(t)}
+ className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
+ tab === t ? "text-heading" : "text-muted"
+ }`}
>
-
- 欢迎
-
-
-
-
-
- {/* Tabs */}
-
- {(["login", "register"] as const).map((t) => (
- switchTab(t)}
- className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
- tab === t ? "text-zinc-900" : "text-zinc-400"
- }`}
- >
- {tab === t && (
-
- )}
-
- {t === "login" ? "登录" : "注册"}
-
-
- ))}
-
-
- {/* Username */}
-
-
用户名
-
{
- setUsername(e.target.value.slice(0, 16));
- setError("");
- }}
- placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
- maxLength={16}
- className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
+ {tab === t && (
+
-
-
- {/* Password */}
-
-
密码
-
- {
- setPassword(e.target.value);
- setError("");
- }}
- placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
- className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
- />
- setShowPassword(!showPassword)}
- className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
- >
- {showPassword ? : }
-
-
-
-
- {/* Confirm password (register only) */}
- {tab === "register" && (
-
-
确认密码
-
{
- setConfirmPassword(e.target.value);
- setError("");
- }}
- placeholder="再次输入密码"
- className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
- />
-
)}
+
+ {t === "login" ? "登录" : "注册"}
+
+
+ ))}
+
- {/* Avatar picker (register only) */}
- {tab === "register" && (
-
-
- 选择头像
- (可选)
-
-
- {AVATARS.map((a) => (
- setAvatar(a.emoji)}
- className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
- avatar === a.emoji
- ? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
- : "bg-zinc-50 hover:bg-zinc-100"
- }`}
- >
- {a.emoji}
-
- ))}
-
-
- )}
+
+
用户名
+
{
+ setUsername(e.target.value.slice(0, 16));
+ setError("");
+ }}
+ placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
+ maxLength={16}
+ size="xl"
+ className="mt-2"
+ />
+
- {error && (
-
- {error}
-
- )}
+
+
密码
+
+ {
+ setPassword(e.target.value);
+ setError("");
+ }}
+ placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
+ size="xl"
+ className="pr-10"
+ />
+ setShowPassword(!showPassword)}
+ aria-label={showPassword ? "隐藏密码" : "显示密码"}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted transition-colors active:text-secondary"
+ >
+ {showPassword ? : }
+
+
+
-
- {loading ? (
- <>
-
- {tab === "login" ? "登录中..." : "注册中..."}
- >
- ) : tab === "login" ? (
- "登录"
- ) : (
- "注册"
- )}
-
-
-
+ {tab === "register" && (
+
+
确认密码
+
{
+ setConfirmPassword(e.target.value);
+ setError("");
+ }}
+ placeholder="再次输入密码"
+ size="xl"
+ className="mt-2"
+ />
+
)}
-
+
+ {tab === "register" && (
+
+
+ 选择头像
+ (可选)
+
+
+ {AVATARS.map((a) => (
+ setAvatar(a.emoji)}
+ className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
+ avatar === a.emoji
+ ? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
+ : "bg-elevated hover:bg-subtle"
+ }`}
+ >
+ {a.emoji}
+
+ ))}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {tab === "login" ? "登录" : "注册"}
+
+
);
}
diff --git a/src/components/BlindboxDrawnHistory.tsx b/src/components/BlindboxDrawnHistory.tsx
new file mode 100644
index 0000000..ea2c232
--- /dev/null
+++ b/src/components/BlindboxDrawnHistory.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Trophy } from "lucide-react";
+
+export interface DrawnIdea {
+ id: string;
+ content: string;
+ createdAt: string;
+ user?: { id: string; username: string; avatar: string };
+ drawnBy?: { id: string; username: string; avatar: string } | null;
+}
+
+export default function BlindboxDrawnHistory({ items }: { items: DrawnIdea[] }) {
+ if (items.length === 0) return null;
+
+ return (
+
+
+
+ {items.map((item, i) => (
+
+ 🏆
+
+
+ {item.content}
+
+
+ {item.user && (
+ {item.user.avatar} {item.user.username} 投入
+ )}
+ {item.drawnBy && (
+ <>
+ ·
+ {item.drawnBy.avatar} {item.drawnBy.username} 抽中
+ >
+ )}
+ ·
+
+ {new Date(item.createdAt).toLocaleDateString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ weekday: "short",
+ })}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/BlindboxMyIdeas.tsx b/src/components/BlindboxMyIdeas.tsx
new file mode 100644
index 0000000..0b85877
--- /dev/null
+++ b/src/components/BlindboxMyIdeas.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ Package,
+ Loader2,
+ Pencil,
+ Trash2,
+ Check,
+ X,
+ UtensilsCrossed,
+ TreePine,
+ Film,
+ ShoppingBag,
+ Dumbbell,
+ Landmark,
+ Coffee,
+} from "lucide-react";
+import type { IdeaCategory } from "@/types";
+
+export interface MyIdea {
+ id: string;
+ content: string;
+ createdAt: string;
+ category?: string | null;
+ timeSlot?: string | null;
+ estimatedMinutes?: number | null;
+ outdoor?: boolean | null;
+ searchQuery?: string | null;
+ searchType?: string | null;
+}
+
+const CATEGORY_CONFIG: Record<
+ IdeaCategory,
+ { icon: typeof UtensilsCrossed; color: string; label: string }
+> = {
+ dining: { icon: UtensilsCrossed, color: "text-orange-400", label: "美食" },
+ outdoor: { icon: TreePine, color: "text-emerald-400", label: "户外" },
+ entertainment: { icon: Film, color: "text-sky-400", label: "娱乐" },
+ shopping: { icon: ShoppingBag, color: "text-pink-400", label: "购物" },
+ sports: { icon: Dumbbell, color: "text-amber-400", label: "运动" },
+ culture: { icon: Landmark, color: "text-violet-400", label: "文化" },
+ relaxation: { icon: Coffee, color: "text-teal-400", label: "休闲" },
+};
+
+function CategoryBadge({ category }: { category?: string | null }) {
+ if (!category) return 💡;
+ const cfg = CATEGORY_CONFIG[category as IdeaCategory];
+ if (!cfg) return 💡;
+ const Icon = cfg.icon;
+ return ;
+}
+
+function DurationLabel({ minutes }: { minutes?: number | null }) {
+ if (!minutes) return null;
+ const display = minutes >= 60 ? `${(minutes / 60).toFixed(minutes % 60 === 0 ? 0 : 1)}h` : `${minutes}min`;
+ return (
+
+ ~{display}
+
+ );
+}
+
+function MyIdeaItem({
+ idea,
+ onEdit,
+ onDelete,
+}: {
+ idea: MyIdea;
+ onEdit: (id: string, content: string) => Promise;
+ onDelete: (id: string) => Promise;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(idea.content);
+ const [saving, setSaving] = useState(false);
+
+ const handleSave = async () => {
+ if (!draft.trim() || saving) return;
+ setSaving(true);
+ await onEdit(idea.id, draft);
+ setSaving(false);
+ setEditing(false);
+ };
+
+ return (
+
+ {editing ? (
+ <>
+ setDraft(e.target.value.slice(0, 200))}
+ onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") { setEditing(false); setDraft(idea.content); } }}
+ maxLength={200}
+ autoFocus
+ className="h-8 min-w-0 flex-1 rounded-lg bg-elevated px-2.5 text-sm text-foreground outline-none ring-1 ring-border focus:ring-2 focus:ring-purple-600/50"
+ />
+
+ {saving ? : }
+
+ { setEditing(false); setDraft(idea.content); }}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-elevated text-muted"
+ >
+
+
+ >
+ ) : (
+ <>
+
+ {idea.content}
+
+ setEditing(true)}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-purple-400"
+ >
+
+
+ onDelete(idea.id)}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-rose-400"
+ >
+
+
+ >
+ )}
+
+ );
+}
+
+export default function BlindboxMyIdeas({
+ ideas,
+ onEdit,
+ onDelete,
+}: {
+ ideas: MyIdea[];
+ onEdit: (id: string, content: string) => Promise;
+ onDelete: (id: string) => Promise;
+}) {
+ return (
+
+
+
+
+ 我投入的想法({ideas.length})
+
+
+
+
+
+ {ideas.map((idea) => (
+
+ ))}
+
+
+
+ );
+}
+
+export { CATEGORY_CONFIG, CategoryBadge, DurationLabel };
diff --git a/src/components/BlindboxPlan.tsx b/src/components/BlindboxPlan.tsx
new file mode 100644
index 0000000..c487dd5
--- /dev/null
+++ b/src/components/BlindboxPlan.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ MapPin,
+ Clock,
+ Navigation,
+ Share2,
+ RefreshCw,
+ Sparkles,
+ ChevronRight,
+ ChevronLeft,
+ CornerDownLeft,
+} from "lucide-react";
+import { CategoryBadge } from "@/components/BlindboxMyIdeas";
+import Button from "@/components/Button";
+import type { WeekendPlanData } from "@/types";
+
+interface BlindboxPlanProps {
+ days: WeekendPlanData[];
+ onAccept: () => void;
+ onRegenerate: () => void;
+ onShare: () => void;
+ onBack: () => void;
+ accepted?: boolean;
+ regenerating?: boolean;
+}
+
+function guessCategory(activity: string): string | null {
+ const lower = activity.toLowerCase();
+ if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
+ 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`;
+}
+
+export default function BlindboxPlan({
+ days,
+ onAccept,
+ onRegenerate,
+ onShare,
+ onBack,
+ accepted,
+ regenerating,
+}: BlindboxPlanProps) {
+ const [dayIndex, setDayIndex] = useState(0);
+ const scrollRef = useRef(null);
+ const currentDay = days[dayIndex];
+ const hasNext = dayIndex < days.length - 1;
+ const hasPrev = dayIndex > 0;
+
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+ }, [dayIndex]);
+
+ if (!currentDay) return null;
+
+ return (
+
+ {/* Day header — sticky top */}
+
+
+
+ {currentDay.date} · 行程规划
+
+
+ {days.length > 1 && (
+
+ {days.map((day, i) => (
+ setDayIndex(i)}
+ className={`rounded-full transition-all ${
+ i === dayIndex
+ ? "h-1.5 w-5 bg-purple-400"
+ : "h-1.5 w-1.5 bg-purple-400/25"
+ }`}
+ />
+ ))}
+
+ )}
+
+ {currentDay.summary && (
+
+ {currentDay.summary}
+
+ )}
+
+
+ {/* Scrollable timeline */}
+
+
+
+
+
+ {currentDay.items.map((item, i) => (
+
+
+
+
+
+
{item.time}
+
+
+
+
+ {item.poi}
+
+ {item.address && (
+
{item.address}
+ )}
+
+
+
+ {formatDuration(item.duration)}
+
+ {item.lat !== 0 && item.lng !== 0 && (
+
+
+ 导航
+
+ )}
+
+ {item.reason && (
+
+ {item.reason}
+
+ )}
+
+
+
+
+ ))}
+
+
+
+ {/* Back to pool — at end of scroll content */}
+
+
+
+ 返回想法池
+
+
+
+
+ {/* Fixed bottom bar — actions + day navigation */}
+
+ {/* Day navigation */}
+ {days.length > 1 && (
+
+ {hasPrev && (
+ setDayIndex(dayIndex - 1)}
+ className="flex items-center gap-1 rounded-full bg-surface px-3 py-1.5 text-[11px] font-bold text-purple-400 ring-1 ring-border/60 active:bg-elevated"
+ >
+
+ {days[dayIndex - 1].date}
+
+ )}
+
+ {dayIndex + 1} / {days.length}
+
+ {hasNext && (
+ setDayIndex(dayIndex + 1)}
+ className="flex items-center gap-1 rounded-full bg-purple-600/15 px-3 py-1.5 text-[11px] font-bold text-purple-400 active:bg-purple-600/25"
+ >
+ {days[dayIndex + 1].date}
+
+
+ )}
+
+ )}
+
+ {/* Action buttons */}
+
+ {accepted ? (
+ }
+ >
+ 分享计划
+
+ ) : (
+ <>
+ }
+ >
+ 接受契约
+
+ }
+ >
+ 换一个方案
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/BlindboxPlanShareCard.tsx b/src/components/BlindboxPlanShareCard.tsx
new file mode 100644
index 0000000..dc4ff8b
--- /dev/null
+++ b/src/components/BlindboxPlanShareCard.tsx
@@ -0,0 +1,259 @@
+import { QRCodeSVG } from "qrcode.react";
+import type { WeekendPlanData } from "@/types";
+
+export interface PlanShareData {
+ type: "plan";
+ days: WeekendPlanData[];
+ roomName: string;
+}
+
+export default function BlindboxPlanShareCard({
+ data,
+ cardRef,
+}: {
+ data: PlanShareData;
+ cardRef: React.RefObject;
+}) {
+ const { days, roomName } = data;
+ const shareUrl =
+ typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
+
+ return (
+
+
+ {/* Decorative glows */}
+
+
+ {/* Brand header */}
+
+
📋
+
+
+ NoWhatever
+
+
+ 别说随便 · WEEKEND PLAN
+
+
+
+
+ {/* Thin accent line */}
+
+
+ {/* Each day */}
+ {days.map((day, dayIdx) => (
+
+ {/* Room + date badge */}
+
+
+ ✦ {roomName} · {day.date} ✦
+
+ {day.summary && (
+
+ {day.summary}
+
+ )}
+
+
+ {/* Timeline items */}
+
+ {day.items.map((item, i) => (
+
+
+ {item.time}
+
+
+
+
+ {i < day.items.length - 1 && (
+
+ )}
+
+
+
+
+ {item.activity}
+
+
+ 📍 {item.poi}
+
+ {item.reason && (
+
+ {item.reason}
+
+ )}
+
+
+ ))}
+
+
+ {/* Separator between days */}
+ {dayIdx < days.length - 1 && (
+
+ )}
+
+ ))}
+
+ {/* Contract stamp */}
+
+ 此契约一旦开启,绝不反悔
+
+
+ {/* QR footer */}
+
+
+
+
+
+
+ 扫码一起「别说随便」
+
+
+ {shareUrl.replace(/^https?:\/\//, "")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/BlindboxShareCard.tsx b/src/components/BlindboxShareCard.tsx
new file mode 100644
index 0000000..7389f53
--- /dev/null
+++ b/src/components/BlindboxShareCard.tsx
@@ -0,0 +1,323 @@
+import { QRCodeSVG } from "qrcode.react";
+
+export interface BlindboxShareData {
+ type: "blindbox";
+ idea: string;
+ submitter?: { avatar: string; username: string };
+ drawer?: { avatar: string; username: string };
+ roomName: string;
+}
+
+export default function BlindboxShareCard({
+ data,
+ cardRef,
+}: {
+ data: BlindboxShareData;
+ cardRef: React.RefObject;
+}) {
+ const { idea, submitter, drawer, roomName } = data;
+ const shareUrl =
+ typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
+
+ return (
+
+
+ {/* Decorative glows */}
+
+
+
+ {/* Brand header */}
+
+
🎁
+
+
+ NoWhatever
+
+
+ 别说随便 · ADVENTURE ROULETTE
+
+
+
+
+ {/* Thin accent line */}
+
+
+ {/* Room name badge */}
+
+
+ {/* Idea card */}
+
+
+ {/* Corner decorations */}
+
+
+
+
+
+
+ {idea}
+
+
+
+
+
+ 此契约一旦开启,绝不反悔
+
+
+
+
+ {/* Attribution */}
+ {(submitter || drawer) && (
+
+ {submitter && (
+
+ {submitter.avatar}
+ {submitter.username} 投入
+
+ )}
+ {submitter && drawer && (
+
·
+ )}
+ {drawer && (
+
+ {drawer.avatar}
+ {drawer.username} 抽中
+
+ )}
+
+ )}
+
+ {/* QR footer */}
+
+
+
+
+
+
+ 扫码一起「别说随便」
+
+
+ {shareUrl.replace(/^https?:\/\//, "")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 0000000..f19215a
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { type ComponentProps, type ReactNode } from "react";
+import { motion } from "framer-motion";
+import { Loader2 } from "lucide-react";
+
+const variantStyles = {
+ primary:
+ "bg-accent text-white shadow-lg shadow-accent/20 hover:bg-accent-hover disabled:opacity-50",
+ secondary:
+ "bg-surface text-secondary ring-1 ring-border hover:bg-elevated disabled:opacity-40",
+ danger:
+ "bg-rose-600 text-white hover:bg-rose-500 disabled:opacity-50",
+ ghost:
+ "text-muted hover:text-secondary hover:bg-elevated disabled:opacity-50",
+ purple:
+ "bg-purple-600 text-white hover:bg-purple-500 disabled:opacity-50",
+} as const;
+
+const sizeStyles = {
+ sm: "h-8 px-3 text-xs gap-1",
+ md: "h-10 px-4 text-sm gap-1.5",
+ lg: "h-11 px-6 text-sm font-bold gap-2",
+} as const;
+
+const spinnerSize = { sm: 13, md: 15, lg: 18 } as const;
+
+interface ButtonProps
+ extends Omit, "ref" | "children"> {
+ variant?: keyof typeof variantStyles;
+ size?: "sm" | "md" | "lg";
+ shape?: "rounded" | "pill";
+ loading?: boolean;
+ loadingText?: string;
+ icon?: ReactNode;
+ fullWidth?: boolean;
+ children?: ReactNode;
+}
+
+export default function Button({
+ variant = "primary",
+ size = "md",
+ shape = "rounded",
+ loading = false,
+ loadingText,
+ icon,
+ fullWidth = false,
+ className = "",
+ disabled,
+ children,
+ ...rest
+}: ButtonProps) {
+ const base = "flex items-center justify-center font-semibold transition-colors";
+ const shapeClass = shape === "pill" ? "rounded-full" : "rounded-xl";
+
+ return (
+
+ {loading ? (
+
+ ) : icon ? (
+ icon
+ ) : null}
+ {loading && loadingText ? loadingText : children}
+
+ );
+}
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 0000000..67695f4
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,37 @@
+import { motion } from "framer-motion";
+import type { ReactNode } from "react";
+
+interface CardProps {
+ children: ReactNode;
+ className?: string;
+ animated?: boolean;
+ delay?: number;
+}
+
+const fadeUp = {
+ initial: { y: 10, opacity: 0 },
+ animate: { y: 0, opacity: 1 },
+} as const;
+
+export default function Card({
+ children,
+ className = "",
+ animated = false,
+ delay,
+}: CardProps) {
+ const cls = `rounded-2xl bg-surface p-4 ring-1 ring-border ${className}`;
+
+ if (animated) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return {children}
;
+}
diff --git a/src/components/ContractCompletionModal.tsx b/src/components/ContractCompletionModal.tsx
new file mode 100644
index 0000000..82740c9
--- /dev/null
+++ b/src/components/ContractCompletionModal.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
+
+interface PendingContract {
+ id: string;
+ roomName: string;
+ date: string;
+ activities: string[];
+}
+
+interface ContractCompletionModalProps {
+ contracts: PendingContract[];
+ userId: string;
+ onDone: () => void;
+}
+
+export default function ContractCompletionModal({
+ contracts,
+ userId,
+ onDone,
+}: ContractCompletionModalProps) {
+ const [current, setCurrent] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ if (contracts.length === 0) return null;
+
+ const contract = contracts[current];
+
+ const handleAction = async (action: "complete" | "expire") => {
+ if (loading) return;
+ setLoading(true);
+ try {
+ await fetch("/api/blindbox/plan", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ planId: contract.id, userId, action }),
+ });
+ } catch { /* best-effort */ }
+ setLoading(false);
+
+ if (current < contracts.length - 1) {
+ setCurrent((c) => c + 1);
+ } else {
+ onDone();
+ }
+ };
+
+ const summary =
+ contract.activities.length <= 3
+ ? contract.activities.join(" → ")
+ : contract.activities.slice(0, 3).join(" → ") + "…";
+
+ return (
+
+
+
+
+
+ ✦ 契约到期 ✦
+
+
+ {contract.roomName}
+
+
+ {contract.date} · {summary}
+
+
+
+
+
+ 这份契约已到期,你完成了吗?
+
+
+
+ handleAction("complete")}
+ disabled={loading}
+ className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ 完成了!
+
+ handleAction("expire")}
+ disabled={loading}
+ className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-surface py-3 text-sm font-medium text-muted ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-50"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ 没完成
+
+
+
+ {contracts.length > 1 && (
+
+ {current + 1} / {contracts.length}
+
+ )}
+
+
+
+
+ );
+}
+
+export type { PendingContract };
diff --git a/src/components/ContractHistoryItem.tsx b/src/components/ContractHistoryItem.tsx
new file mode 100644
index 0000000..fb88a74
--- /dev/null
+++ b/src/components/ContractHistoryItem.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { CheckCircle2, XCircle, ChevronDown } from "lucide-react";
+import type { ContractRecord } from "@/types";
+
+interface ContractHistoryItemProps {
+ record: ContractRecord;
+}
+
+export default function ContractHistoryItem({ record }: ContractHistoryItemProps) {
+ const [expanded, setExpanded] = useState(false);
+ const isCompleted = record.status === "completed";
+
+ const summary =
+ record.activities.length <= 3
+ ? record.activities.join(" → ")
+ : record.activities.slice(0, 3).join(" → ") + "…";
+
+ return (
+ setExpanded(!expanded)}
+ className="w-full rounded-xl bg-elevated p-3 text-left transition-colors active:bg-subtle"
+ >
+
+
+ {isCompleted ? : }
+
+
+
+
+
+ {record.roomName}
+
+
+ {isCompleted ? "已完成" : "未完成"}
+
+
+
+ {record.date}
+ ·
+ {record.activities.length} 项活动
+ ·
+
+ {new Date(record.createdAt).toLocaleDateString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+
{summary}
+
+
+
+
+
+
+
+
+ {expanded && (
+
+
+
+ {record.activities.map((activity, i) => (
+
+
+ {i + 1}
+
+
{activity}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
new file mode 100644
index 0000000..8593294
--- /dev/null
+++ b/src/components/EmptyState.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { motion } from "framer-motion";
+import type { LucideIcon } from "lucide-react";
+
+interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ subtitle?: string;
+ ctaLabel?: string;
+ onCta?: () => void;
+ color?: string;
+}
+
+export default function EmptyState({
+ icon: Icon,
+ title,
+ subtitle,
+ ctaLabel,
+ onCta,
+ color = "purple",
+}: EmptyStateProps) {
+ const colorMap: Record = {
+ purple: { glow: "bg-purple-600/15", icon: "text-purple-400/60", btn: "bg-purple-600 hover:bg-purple-500", btnHover: "" },
+ amber: { glow: "bg-amber-500/15", icon: "text-amber-400/60", btn: "bg-amber-600 hover:bg-amber-500", btnHover: "" },
+ rose: { glow: "bg-rose-500/15", icon: "text-rose-400/60", btn: "bg-rose-600 hover:bg-rose-500", btnHover: "" },
+ sky: { glow: "bg-sky-500/15", icon: "text-sky-400/60", btn: "bg-sky-600 hover:bg-sky-500", btnHover: "" },
+ };
+ const c = colorMap[color] ?? colorMap.purple;
+
+ return (
+
+
+
+
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {ctaLabel && onCta && (
+
+ {ctaLabel}
+
+ )}
+
+ );
+}
diff --git a/src/components/GlobalUserBadge.tsx b/src/components/GlobalUserBadge.tsx
new file mode 100644
index 0000000..3d3f2e7
--- /dev/null
+++ b/src/components/GlobalUserBadge.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { motion } from "framer-motion";
+import { User, Sun, Moon, Monitor } from "lucide-react";
+import { getCachedProfile } from "@/lib/userId";
+import { type Theme, getStoredTheme, setStoredTheme } from "@/lib/theme";
+import AuthModal from "@/components/AuthModal";
+import type { UserProfile } from "@/types";
+
+const HIDDEN_PREFIXES = ["/profile"];
+
+export default function GlobalUserBadge() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [profile, setProfile] = useState(null);
+ const [showAuth, setShowAuth] = useState(false);
+ const [theme, setTheme] = useState("system");
+ const hidden = HIDDEN_PREFIXES.some((p) => pathname.startsWith(p));
+
+ useEffect(() => {
+ setProfile(getCachedProfile());
+ }, [pathname]);
+
+ useEffect(() => {
+ setTheme(getStoredTheme());
+ }, []);
+
+ useEffect(() => {
+ const handler = () => setProfile(getCachedProfile());
+ window.addEventListener("nowhatever_auth", handler);
+ return () => window.removeEventListener("nowhatever_auth", handler);
+ }, []);
+
+ const handleAuth = useCallback((p: UserProfile) => {
+ setProfile(p);
+ setShowAuth(false);
+ }, []);
+
+ const cycleTheme = useCallback(() => {
+ const order: Theme[] = ["system", "light", "dark"];
+ const next = order[(order.indexOf(theme) + 1) % 3];
+ setTheme(next);
+ setStoredTheme(next);
+ }, [theme]);
+
+ const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
+ const themeLabel = theme === "light" ? "浅色" : theme === "dark" ? "深色" : "自动";
+
+ if (hidden) return null;
+
+ return (
+ <>
+
+
+
+ {themeLabel}
+
+
+ {profile ? (
+ router.push("/profile")}
+ className="flex h-8 items-center gap-1.5 rounded-full bg-surface/80 px-3 ring-1 ring-border/50 backdrop-blur-md transition-colors hover:bg-elevated active:opacity-80"
+ >
+ {profile.avatar}
+
+ {profile.username}
+
+
+ ) : (
+ setShowAuth(true)}
+ className="flex h-8 items-center gap-1.5 rounded-full bg-surface/80 px-3 text-xs font-medium text-muted ring-1 ring-border/50 backdrop-blur-md transition-colors hover:bg-elevated hover:text-secondary"
+ >
+
+ 登录
+
+ )}
+
+
+ setShowAuth(false)}
+ onAuth={handleAuth}
+ />
+ >
+ );
+}
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
new file mode 100644
index 0000000..585cbb5
--- /dev/null
+++ b/src/components/Input.tsx
@@ -0,0 +1,31 @@
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+
+const sizeStyles = {
+ sm: "h-8 rounded-lg px-2",
+ md: "h-9 rounded-lg px-3",
+ lg: "h-10 rounded-xl px-3",
+ xl: "h-11 rounded-xl px-4",
+} as const;
+
+const variantStyles = {
+ default: "bg-elevated text-heading focus:ring-accent/50",
+ purple: "bg-surface text-foreground focus:ring-purple-600",
+} as const;
+
+interface InputProps extends Omit, "size"> {
+ size?: keyof typeof sizeStyles;
+ variant?: keyof typeof variantStyles;
+}
+
+const Input = forwardRef(
+ ({ size = "md", variant = "default", className = "", ...rest }, ref) => (
+
+ ),
+);
+
+Input.displayName = "Input";
+export default Input;
diff --git a/src/components/LeaveConfirmModal.tsx b/src/components/LeaveConfirmModal.tsx
index aaddda7..030ccbb 100644
--- a/src/components/LeaveConfirmModal.tsx
+++ b/src/components/LeaveConfirmModal.tsx
@@ -1,8 +1,7 @@
"use client";
-import { useRef } from "react";
-import { motion, AnimatePresence } from "framer-motion";
import { LogOut } from "lucide-react";
+import Modal from "@/components/Modal";
interface LeaveConfirmModalProps {
open: boolean;
@@ -15,61 +14,35 @@ export default function LeaveConfirmModal({
onConfirm,
onCancel,
}: LeaveConfirmModalProps) {
- const backdropRef = useRef(null);
-
- const handleBackdropClick = (e: React.MouseEvent) => {
- if (e.target === backdropRef.current) onCancel();
- };
-
return (
-
- {open && (
-
-
+
+
+
+
+
+
+ 确定要退出房间吗?
+
+
+ 退出后你的滑卡进度不会丢失,可以用房间号重新加入
+
+
+
+
-
-
-
-
-
-
- 确定要退出房间吗?
-
-
- 退出后你的滑卡进度不会丢失,可以用房间号重新加入
-
-
-
-
- 继续滑卡
-
-
- 退出房间
-
-
-
-
-
- )}
-
+ 继续滑卡
+
+
+ 退出房间
+
+
+
+
);
}
diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx
index bef706b..bbe99d2 100644
--- a/src/components/MatchResult.tsx
+++ b/src/components/MatchResult.tsx
@@ -12,17 +12,31 @@ import {
Clock,
Trophy,
RotateCcw,
- SearchX,
- Home,
ChevronDown,
Swords,
RefreshCw,
Share2,
Zap,
+ Heart,
+ UserPlus,
} from "lucide-react";
-import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types";
+import {
+ Restaurant,
+ MatchType,
+ RunnerUp,
+ SceneType,
+ UserProfile,
+} from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
+import ShareCardModal from "@/components/ShareCardModal";
+import RestaurantImage from "@/components/RestaurantImage";
+import AuthModal from "@/components/AuthModal";
+import Button from "@/components/Button";
+import NoMatchResult from "@/components/NoMatchResult";
+import RunnerUpCard from "@/components/RunnerUpCard";
+import { buildNavUrl } from "@/lib/navigation";
+import { useToast } from "@/hooks/useToast";
interface MatchResultProps {
restaurant: Restaurant;
@@ -39,134 +53,6 @@ interface MatchResultProps {
scene?: SceneType;
}
-function buildNavUrl(restaurant: Restaurant): string {
- if (restaurant.location) {
- const [lng, lat] = restaurant.location.split(",");
- return `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
- }
- return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
-}
-
-function NoMatchResult({
- onReset,
- resetting,
-}: {
- onReset: () => Promise;
- resetting: boolean;
-}) {
- const router = useRouter();
-
- return (
-
-
-
-
-
-
- 都不太满意
-
-
-
- 这一轮没有店被选中,换个范围或类型再试试?
-
-
-
-
-
- {resetting ? "重置中..." : "再来一轮"}
-
-
- router.push("/")}
- className="flex items-center justify-center gap-2 rounded-full bg-white/15 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/25"
- whileTap={{ scale: 0.95 }}
- >
-
- 换个条件重新搜
-
-
-
- );
-}
-
-function RunnerUpCard({
- restaurant,
- likes,
- userCount,
-}: {
- restaurant: Restaurant;
- likes: number;
- userCount: number;
-}) {
- return (
-
- {restaurant.images?.[0] && (
-
- )}
-
-
- {restaurant.name}
-
-
-
-
- {restaurant.rating}
-
- {restaurant.price}
- {restaurant.distance && (
-
-
- {restaurant.distance}
-
- )}
-
-
- {likes}/{userCount} 人想去
-
-
-
- );
-}
-
export default function MatchResult({
restaurant,
matchType,
@@ -183,15 +69,16 @@ export default function MatchResult({
}: MatchResultProps) {
const router = useRouter();
const [showRunnerUps, setShowRunnerUps] = useState(false);
- const [toast, setToast] = useState("");
+ const [showShareCard, setShowShareCard] = useState(false);
+ const toast = useToast();
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
+ const isSolo = userCount <= 1;
const isUnanimous = matchType === "unanimous";
-
- const showToast = useCallback((msg: string) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }, []);
+ const [favorited, setFavorited] = useState(false);
+ const [favLoading, setFavLoading] = useState(false);
+ const [registered, setRegistered] = useState(() => isRegistered());
+ const [showAuth, setShowAuth] = useState(false);
useEffect(() => {
if (isUnanimous && !celebratedRef.current) {
@@ -206,7 +93,7 @@ export default function MatchResult({
useEffect(() => {
if (historySavedRef.current) return;
- if (!isRegistered()) return;
+ if (!registered) return;
if (matchType === "no_match") return;
historySavedRef.current = true;
@@ -221,48 +108,39 @@ export default function MatchResult({
participants: userCount,
}),
}).catch(() => {});
- }, [userId, roomId, restaurant, matchType, userCount]);
+ }, [registered, userId, roomId, restaurant, matchType, userCount]);
- const handleShare = useCallback(async () => {
- const verb = scene === "drink" ? "喝" : "吃";
- const lines = [
- isUnanimous
- ? `🎉 默契度 100%!${userCount} 人全员一致选了同一家!`
- : `🎉 我们用 NoWhatever 选好了去哪${verb}!`,
- ``,
- `📍 ${restaurant.name}`,
- restaurant.rating ? `⭐ ${restaurant.rating}` : "",
- restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "",
- restaurant.address ? `📮 ${restaurant.address}` : "",
- ``,
- isUnanimous ? `✨ 这就是心有灵犀吧~` : "",
- ].filter(Boolean);
+ const handleOpenShareCard = useCallback(() => {
+ setShowShareCard(true);
+ }, []);
- const text = lines.join("\n");
- const navUrl = buildNavUrl(restaurant);
-
- const shareData = {
- title: `我们选了${restaurant.name}!`,
- text,
- url: navUrl,
- };
+ const handleAuth = useCallback(
+ (profile: UserProfile) => {
+ setRegistered(true);
+ setShowAuth(false);
+ toast.show(`欢迎,${profile.username}!记录已保存`);
+ },
+ [toast],
+ );
+ const handleFavorite = useCallback(async () => {
+ if (!registered || favorited || favLoading) return;
+ setFavLoading(true);
try {
- if (navigator.share && navigator.canShare?.(shareData)) {
- await navigator.share(shareData);
- return;
+ const res = await fetch("/api/user/favorite", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId, restaurant }),
+ });
+ if (res.ok) {
+ setFavorited(true);
+ toast.show("已收藏");
}
- } catch (e) {
- if (e instanceof Error && e.name === "AbortError") return;
- }
-
- try {
- await navigator.clipboard.writeText(`${text}\n\n${navUrl}`);
- showToast("已复制,快去发给朋友吧!");
} catch {
- showToast("复制失败,请手动复制");
+ /* ignore */
}
- }, [restaurant, showToast, isUnanimous, userCount, scene]);
+ setFavLoading(false);
+ }, [registered, userId, restaurant, favorited, favLoading, toast]);
if (matchType === "no_match") {
return ;
@@ -282,51 +160,61 @@ export default function MatchResult({
return (
-
+ {/* Accent glow behind icon */}
+
+
+
{isUnanimous ? (
-
+
) : (
-
+
)}
- 就去这了!
+ {isSolo ? "帮你选好了" : "就去这了"}
- {isUnanimous
- ? "大家一拍即合!"
- : `${matchLikes}/${userCount} 人想去这家`}
+ {isSolo
+ ? "你的首选,别犹豫了"
+ : isUnanimous
+ ? "大家一拍即合!"
+ : `${matchLikes}/${userCount} 人想去这家`}
- {isUnanimous && (
+ {isUnanimous && !isSolo && (
-
-
+
+
默契度 100% · {userCount} 人全员一致
-
+
)}
+ {/* Result card */}
+ {registered && (
+
+
+
+ )}
{restaurant.images?.[0] && (
-
)}
-
+
{restaurant.name}
{restaurant.category && (
-
+
{restaurant.category}
)}
-
+
{restaurant.rating}
-
+
{restaurant.price}
{restaurant.distance && (
@@ -387,13 +303,13 @@ export default function MatchResult({
{restaurant.address && (
-
+
{restaurant.address}
)}
{restaurant.openTime && (
-
+
{restaurant.openTime}
@@ -407,7 +323,7 @@ export default function MatchResult({
.map((t) => (
{t.trim()}
@@ -417,6 +333,7 @@ export default function MatchResult({
+ {/* Action buttons */}
@@ -439,7 +354,7 @@ export default function MatchResult({
{restaurant.tel && (
@@ -447,16 +362,43 @@ export default function MatchResult({
)}
- }
+ className="px-8 py-3"
>
-
- 分享结果到群里
-
+ 生成分享卡片
+
+ {/* Registration nudge */}
+ {!registered && (
+
+
+ 注册后,决策记录和收藏不会丢失
+
+
+ 10 秒注册,无需手机号
+
+ setShowAuth(true)}
+ fullWidth
+ icon={}
+ className="mt-3"
+ >
+ 注册保存记录
+
+
+ )}
+
+ {/* Runner ups */}
{!isUnanimous && runnerUpRestaurants.length > 0 && (
setShowRunnerUps((v) => !v)}
- className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-white/80 transition-colors hover:text-white"
+ className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-muted transition-colors hover:text-foreground"
>
其他候选({runnerUpRestaurants.length})
)}
+
+ {canNarrow && (
+
+
+ 还有 {runnerUpRestaurants.length} 家不相上下,再选一轮?
+
+
onNarrow(narrowIds)}
+ disabled={resetting}
+ className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
+ whileTap={{ scale: 0.95 }}
+ >
+
+ {resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
+
+
+ )}
)}
-
-
- {canNarrow ? (
- <>
- onNarrow(narrowIds)}
- disabled={resetting}
- className="flex w-full items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30 disabled:opacity-50"
- whileTap={{ scale: 0.95 }}
- >
-
- {resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
-
- router.push("/")}
- className="flex items-center gap-1.5 text-sm font-medium text-amber-200 underline underline-offset-2 hover:text-white"
- >
-
- 换一批店
-
- >
- ) : (
- <>
-
-
- {resetting ? "重置中..." : "再来一轮"}
-
- router.push("/")}
- className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
- isUnanimous ? "text-emerald-200" : "text-amber-200"
- }`}
- >
-
- 换一批店
-
- >
- )}
-
-
- {toast && (
-
+
+ 不满意?
+
- {toast}
-
- )}
-
+
+ {resetting ? "重置中..." : "再来一轮"}
+
+ router.push("/")}
+ className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle"
+ whileTap={{ scale: 0.95 }}
+ >
+
+ 换一批店
+
+
+
+
+ setShowAuth(false)}
+ onAuth={handleAuth}
+ defaultTab="register"
+ />
+
+ setShowShareCard(false)}
+ data={{
+ type: "restaurant",
+ restaurant,
+ matchType,
+ matchLikes,
+ userCount,
+ scene,
+ }}
+ />
);
}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..5da8018
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useRef } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+
+type ModalVariant = "sheet" | "dialog";
+
+interface ModalProps {
+ open: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+ variant?: ModalVariant;
+}
+
+const sheet = {
+ backdrop:
+ "fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center",
+ content:
+ "relative w-full max-w-sm rounded-t-3xl bg-surface px-5 pb-8 pt-5 shadow-2xl ring-1 ring-border sm:rounded-3xl sm:pb-6",
+ initial: { y: "100%" },
+ animate: { y: 0 },
+ exit: { y: "100%" },
+ transition: { type: "spring" as const, damping: 28, stiffness: 350 },
+};
+
+const dialog = {
+ backdrop:
+ "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
+ content:
+ "mx-6 w-full max-w-xs rounded-2xl bg-surface px-6 py-6 shadow-2xl ring-1 ring-border",
+ initial: { scale: 0.9, opacity: 0 },
+ animate: { scale: 1, opacity: 1 },
+ exit: { scale: 0.9, opacity: 0 },
+ transition: { type: "spring" as const, damping: 25, stiffness: 350 },
+};
+
+const variants = { sheet, dialog };
+
+export default function Modal({
+ open,
+ onClose,
+ children,
+ variant = "sheet",
+}: ModalProps) {
+ const backdropRef = useRef(null);
+ const v = variants[variant];
+
+ return (
+
+ {open && (
+ {
+ if (e.target === backdropRef.current) onClose();
+ }}
+ >
+
+ {children}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/NoMatchResult.tsx b/src/components/NoMatchResult.tsx
new file mode 100644
index 0000000..6cb0060
--- /dev/null
+++ b/src/components/NoMatchResult.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { useRouter } from "next/navigation";
+import { SearchX, RotateCcw, Home } from "lucide-react";
+import Button from "@/components/Button";
+
+interface NoMatchResultProps {
+ onReset: () => Promise;
+ resetting: boolean;
+}
+
+export default function NoMatchResult({ onReset, resetting }: NoMatchResultProps) {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+
+ 都不太满意
+
+
+
+ 这一轮没有店被选中,换个范围或类型再试试?
+
+
+
+ }
+ className="px-8 py-3"
+ >
+ 再来一轮
+
+
+ router.push("/")}
+ variant="secondary"
+ shape="pill"
+ icon={}
+ className="px-8 py-3"
+ >
+ 换个条件重新搜
+
+
+
+ );
+}
diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx
new file mode 100644
index 0000000..4187530
--- /dev/null
+++ b/src/components/PageTransition.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useContext, useRef, type PropsWithChildren } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { usePathname } from "next/navigation";
+import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
+
+/**
+ * Preserves the previous route's React context during the exit animation
+ * so the old page doesn't break while fading out.
+ */
+function FrozenRoute({ children }: PropsWithChildren) {
+ const ctx = useContext(LayoutRouterContext);
+ const frozen = useRef(ctx).current;
+ return (
+
+ {children}
+
+ );
+}
+
+const variants = {
+ enter: { opacity: 0 },
+ center: { opacity: 1 },
+ exit: { opacity: 0 },
+};
+
+export default function PageTransition({ children }: PropsWithChildren) {
+ const pathname = usePathname();
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/ProfileFavoritesCard.tsx b/src/components/ProfileFavoritesCard.tsx
new file mode 100644
index 0000000..72fae12
--- /dev/null
+++ b/src/components/ProfileFavoritesCard.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { motion, AnimatePresence } from "framer-motion";
+import { Star, MapPin, ChevronDown, Trash2, Heart } from "lucide-react";
+import Card from "@/components/Card";
+import EmptyState from "@/components/EmptyState";
+import RestaurantImage from "@/components/RestaurantImage";
+import { RecordItemSkeleton } from "@/components/Skeleton";
+import type { FavoriteRecord, Restaurant } from "@/types";
+
+function firstImage(r: Restaurant): string {
+ if (r.images?.length > 0) return r.images[0];
+ const legacy = (r as unknown as Record).image;
+ return typeof legacy === "string" ? legacy : "";
+}
+
+interface ProfileFavoritesCardProps {
+ favorites: FavoriteRecord[];
+ loading: boolean;
+ open: boolean;
+ onToggle: () => void;
+ onRemove: (id: string) => Promise;
+ onEmpty: () => void;
+ delay?: number;
+}
+
+export default function ProfileFavoritesCard({
+ favorites,
+ loading,
+ open,
+ onToggle,
+ onRemove,
+ onEmpty,
+ delay,
+}: ProfileFavoritesCardProps) {
+ return (
+
+
+
+
+
+ 收藏餐厅 {favorites.length > 0 && `(${favorites.length})`}
+
+
+
+
+
+
+
+
+ {open && (
+
+ {loading ? (
+
+
+
+
+ ) : favorites.length === 0 ? (
+
+ ) : (
+
+ {favorites.map((f) => {
+ const r = f.restaurantData;
+ return (
+
+ {firstImage(r) && (
+
+ )}
+
+
{r.name}
+
+
+
+ {r.rating}
+
+ {r.price}
+ {r.distance && (
+
+
+ {r.distance}
+
+ )}
+
+
+
onRemove(f.id)}
+ className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-muted transition-colors active:bg-subtle active:text-rose-400"
+ >
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ProfileHistoryCard.tsx b/src/components/ProfileHistoryCard.tsx
new file mode 100644
index 0000000..faa1e5a
--- /dev/null
+++ b/src/components/ProfileHistoryCard.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { motion, AnimatePresence } from "framer-motion";
+import { Clock, ChevronDown, ClipboardList } from "lucide-react";
+import Card from "@/components/Card";
+import EmptyState from "@/components/EmptyState";
+import RestaurantImage from "@/components/RestaurantImage";
+import { RecordItemSkeleton } from "@/components/Skeleton";
+import { buildNavUrl } from "@/lib/navigation";
+import type { DecisionRecord, Restaurant } from "@/types";
+
+function firstImage(r: Restaurant): string {
+ if (r.images?.length > 0) return r.images[0];
+ const legacy = (r as unknown as Record).image;
+ return typeof legacy === "string" ? legacy : "";
+}
+
+interface ProfileHistoryCardProps {
+ history: DecisionRecord[];
+ loading: boolean;
+ open: boolean;
+ onToggle: () => void;
+ onEmpty: () => void;
+ delay?: number;
+}
+
+export default function ProfileHistoryCard({
+ history,
+ loading,
+ open,
+ onToggle,
+ onEmpty,
+ delay,
+}: ProfileHistoryCardProps) {
+ return (
+
+
+
+
+
+ 决策记录 {history.length > 0 && `(${history.length})`}
+
+
+
+
+
+
+
+
+ {open && (
+
+ {loading ? (
+
+
+
+
+ ) : history.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/QrInviteModal.tsx b/src/components/QrInviteModal.tsx
index 3e4f4a9..211efa1 100644
--- a/src/components/QrInviteModal.tsx
+++ b/src/components/QrInviteModal.tsx
@@ -1,17 +1,18 @@
"use client";
-import { useCallback, useRef } from "react";
-import { motion, AnimatePresence } from "framer-motion";
+import { useCallback } from "react";
import { QRCodeSVG } from "qrcode.react";
import { X, Copy, Share2, QrCode } from "lucide-react";
import type { SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
+import Modal from "@/components/Modal";
+import Button from "@/components/Button";
+import { useShare } from "@/hooks/useShare";
interface QrInviteModalProps {
open: boolean;
onClose: () => void;
roomId: string;
- onToast: (msg: string) => void;
scene?: SceneType;
}
@@ -19,120 +20,80 @@ export default function QrInviteModal({
open,
onClose,
roomId,
- onToast,
scene = "eat",
}: QrInviteModalProps) {
+ const { share, copyToClipboard } = useShare();
const sceneConfig = getSceneConfig(scene);
const inviteUrl =
typeof window !== "undefined"
? `${window.location.origin}/invite/${roomId}`
: "";
- const backdropRef = useRef(null);
- const handleBackdropClick = (e: React.MouseEvent) => {
- if (e.target === backdropRef.current) onClose();
- };
+ const handleCopy = useCallback(
+ () => copyToClipboard(inviteUrl, "邀请链接已复制,快去发给朋友吧!"),
+ [inviteUrl, copyToClipboard],
+ );
- const handleCopy = useCallback(async () => {
- try {
- await navigator.clipboard.writeText(inviteUrl);
- onToast("邀请链接已复制,快去发给朋友吧!");
- } catch {
- onToast("复制失败,请手动复制链接");
- }
- }, [inviteUrl, onToast]);
-
- const handleShare = useCallback(async () => {
- const shareData = {
- title: sceneConfig.shareTitle,
- text: sceneConfig.shareText,
- url: inviteUrl,
- };
-
- try {
- if (navigator.share && navigator.canShare?.(shareData)) {
- await navigator.share(shareData);
- return;
- }
- } catch (e) {
- if (e instanceof Error && e.name === "AbortError") return;
- }
-
- handleCopy();
- }, [inviteUrl, handleCopy, sceneConfig]);
+ const handleShare = useCallback(
+ () => share({ title: sceneConfig.shareTitle, text: sceneConfig.shareText, url: inviteUrl }, handleCopy),
+ [inviteUrl, sceneConfig, share, handleCopy],
+ );
return (
-
- {open && (
-
-
+
+
+
+
+
+
+
+
邀请饭搭子
+
+
+ {sceneConfig.qrSubtitle}
+
+
+
+
+
+
+
+ 房间号
+
+ {roomId}
+
+
+
+
+
}
+ className="flex-1"
>
-
-
-
-
-
-
-
-
邀请饭搭子
-
-
- {sceneConfig.qrSubtitle}
-
-
-
-
-
-
-
- 房间号
-
- {roomId}
-
-
-
-
-
-
- 复制链接
-
-
-
- 发送邀请
-
-
-
-
-
- )}
-
+ 复制链接
+
+
}
+ className="flex-1"
+ >
+ 发送邀请
+
+
+
+
);
}
diff --git a/src/components/RestaurantCard.tsx b/src/components/RestaurantCard.tsx
index c22cf39..e0ebeac 100644
--- a/src/components/RestaurantCard.tsx
+++ b/src/components/RestaurantCard.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useCallback, useState, useEffect } from "react";
+import { useCallback, useState, useEffect, useRef } from "react";
+import { motion, AnimatePresence } from "framer-motion";
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
import { Restaurant } from "@/types";
import { getUserId, isRegistered } from "@/lib/userId";
+import RestaurantImage from "@/components/RestaurantImage";
interface RestaurantCardProps {
restaurant: Restaurant;
@@ -57,16 +59,15 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
return (
-

{fadingOut !== null && (
-

setFadingOut(null)}
draggable={false}
- referrerPolicy="no-referrer"
/>
)}
@@ -119,6 +119,17 @@ function ImageGallery({ images, name }: { images: string[]; name: string }) {
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
const [favorited, setFavorited] = useState(false);
+ const [likeBounce, setLikeBounce] = useState(false);
+ const prevLikeRef = useRef(likeCount);
+
+ useEffect(() => {
+ if (likeCount > prevLikeRef.current) {
+ setLikeBounce(true);
+ const t = setTimeout(() => setLikeBounce(false), 600);
+ return () => clearTimeout(t);
+ }
+ prevLikeRef.current = likeCount;
+ }, [likeCount]);
const images = restaurant.images?.filter(Boolean);
const hasImage = images && images.length > 0;
@@ -150,29 +161,45 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
const dianpingUrl = `https://m.dianping.com/search/keyword/0/0_${encodeURIComponent(restaurant.name)}`;
return (
-
-
+
+
{hasImage &&
}
{restaurant.category && (
-
+
{restaurant.category}
)}
- {likeCount > 0 && (
-
-
- {likeCount} 人想去
-
- )}
+
+ {likeCount > 0 && (
+
+
+ {likeCount} 人想去
+
+ )}
+
-
+
{restaurant.name}
@@ -183,8 +210,8 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onTouchStart={stopAll}
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
favorited
- ? "bg-amber-100 text-amber-500"
- : "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
+ ? "bg-amber-500/20 text-amber-400"
+ : "bg-elevated text-muted active:bg-amber-500/15 active:text-amber-400"
}`}
>
@@ -194,7 +221,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onClick={openLink(amapUrl)}
onPointerDown={stopAll}
onTouchStart={stopAll}
- className="flex items-center gap-0.5 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-500 transition-colors active:bg-blue-100"
+ className="flex items-center gap-0.5 rounded-full bg-blue-500/15 px-2 py-0.5 text-[11px] font-medium text-blue-400 transition-colors active:bg-blue-500/25"
>
高德
@@ -203,7 +230,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
onClick={openLink(dianpingUrl)}
onPointerDown={stopAll}
onTouchStart={stopAll}
- className="flex items-center gap-0.5 rounded-full bg-orange-50 px-2 py-0.5 text-[11px] font-medium text-orange-500 transition-colors active:bg-orange-100"
+ className="flex items-center gap-0.5 rounded-full bg-orange-500/15 px-2 py-0.5 text-[11px] font-medium text-orange-400 transition-colors active:bg-orange-500/25"
>
点评
@@ -214,17 +241,17 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
-
+
{restaurant.rating}
-
+
{restaurant.price}
{restaurant.distance && (
-
+
{restaurant.distance}
@@ -232,13 +259,13 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
{restaurant.address && (
-
+
{restaurant.address}
)}
{restaurant.openTime && (
-
+
{restaurant.openTime}
@@ -252,7 +279,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
.map((t) => (
{t.trim()}
diff --git a/src/components/RestaurantImage.tsx b/src/components/RestaurantImage.tsx
new file mode 100644
index 0000000..f41a558
--- /dev/null
+++ b/src/components/RestaurantImage.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { UtensilsCrossed } from "lucide-react";
+
+interface RestaurantImageProps {
+ src: string;
+ alt: string;
+ className?: string;
+ draggable?: boolean;
+ style?: React.CSSProperties;
+ onAnimationEnd?: () => void;
+}
+
+export default function RestaurantImage({
+ src,
+ alt,
+ className = "",
+ draggable,
+ style,
+ onAnimationEnd,
+}: RestaurantImageProps) {
+ const [failed, setFailed] = useState(false);
+
+ const handleError = useCallback(() => setFailed(true), []);
+
+ if (failed) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+

+ );
+}
diff --git a/src/components/RestaurantShareCard.tsx b/src/components/RestaurantShareCard.tsx
new file mode 100644
index 0000000..111ac20
--- /dev/null
+++ b/src/components/RestaurantShareCard.tsx
@@ -0,0 +1,388 @@
+import { Star, MapPin, Zap } from "lucide-react";
+import { QRCodeSVG } from "qrcode.react";
+import type { Restaurant, MatchType, SceneType } from "@/types";
+import { getSceneConfig } from "@/lib/sceneConfig";
+
+export interface RestaurantShareData {
+ type: "restaurant";
+ restaurant: Restaurant;
+ matchType: MatchType;
+ matchLikes: number;
+ userCount: number;
+ scene?: SceneType;
+}
+
+export default function RestaurantShareCard({
+ data,
+ cardRef,
+ imageDataUrl,
+}: {
+ data: RestaurantShareData;
+ cardRef: React.RefObject
;
+ imageDataUrl: string | null;
+}) {
+ const { restaurant, matchType, matchLikes, userCount, scene } = data;
+ const isUnanimous = matchType === "unanimous";
+ const verb = getSceneConfig(scene ?? "eat").verb;
+ const shareUrl =
+ typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
+ const accentFrom = isUnanimous ? "#059669" : "#b45309";
+ const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
+ const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
+ const accentBg = isUnanimous
+ ? "rgba(16, 185, 129, 0.12)"
+ : "rgba(245, 158, 11, 0.12)";
+
+ return (
+
+
+ {/* Decorative glows */}
+
+
+
+ {/* Brand header */}
+
+
+
⚡
+
+
+ NoWhatever
+
+
+ 别说随便 · PANIC MODE
+
+
+
+
+
+ {/* Thin accent line */}
+
+
+ {/* Hero section */}
+
+
+ {isUnanimous ? "🎉" : "🏆"}
+
+
+ 就去这{verb}!
+
+
+ {isUnanimous && (
+
+ )}
+
+ {isUnanimous
+ ? `默契度 100% · ${userCount}人全员一致`
+ : `${matchLikes}/${userCount} 人选了这家`}
+
+ {isUnanimous && (
+
+ )}
+
+
+
+ {/* Restaurant card */}
+
+
+ {imageDataUrl && (
+

+ )}
+
+
+
+ {restaurant.name}
+
+ {restaurant.category && (
+
+ {restaurant.category}
+
+ )}
+
+
+
+ {restaurant.rating > 0 && (
+
+
+ {restaurant.rating}
+
+ )}
+ {restaurant.price && restaurant.price !== "未知" && (
+
+ {restaurant.price}
+
+ )}
+ {restaurant.distance && (
+
+
+ {restaurant.distance}
+
+ )}
+
+
+ {restaurant.address && (
+
+ 📍 {restaurant.address}
+
+ )}
+
+ {restaurant.tag && (
+
+ {restaurant.tag
+ .split(",")
+ .slice(0, 4)
+ .map((t) => (
+
+ {t.trim()}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* QR footer */}
+
+
+
+
+
+
+ 扫码一起「别说随便」
+
+
+ {shareUrl.replace(/^https?:\/\//, "")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/RoomManageModal.tsx b/src/components/RoomManageModal.tsx
index 22c6e69..4ac5de6 100644
--- a/src/components/RoomManageModal.tsx
+++ b/src/components/RoomManageModal.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useState, useRef, useCallback } from "react";
-import { motion, AnimatePresence } from "framer-motion";
+import { useState, useCallback } from "react";
import {
X,
Lock,
@@ -12,7 +11,9 @@ import {
Loader2,
} from "lucide-react";
import { UserProfile } from "@/types";
-import { getAvatar, getAvatarBg } from "@/lib/avatars";
+import UserAvatar from "@/components/UserAvatar";
+import Modal from "@/components/Modal";
+import { useToast } from "@/hooks/useToast";
interface RoomManageModalProps {
open: boolean;
@@ -24,7 +25,6 @@ interface RoomManageModalProps {
swipeCounts: Record;
totalCards: number;
userProfiles: Record;
- onToast: (msg: string) => void;
}
export default function RoomManageModal({
@@ -37,17 +37,12 @@ export default function RoomManageModal({
swipeCounts,
totalCards,
userProfiles,
- onToast,
}: RoomManageModalProps) {
- const backdropRef = useRef(null);
+ const toast = useToast();
const [loading, setLoading] = useState(null);
const [confirmKick, setConfirmKick] = useState(null);
const [confirmEnd, setConfirmEnd] = useState(false);
- const handleBackdropClick = (e: React.MouseEvent) => {
- if (e.target === backdropRef.current) onClose();
- };
-
const manage = useCallback(
async (action: string, targetUserId?: string) => {
setLoading(action + (targetUserId ?? ""));
@@ -59,222 +54,192 @@ export default function RoomManageModal({
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- onToast(data.error ?? "操作失败");
+ toast.show(data.error ?? "操作失败");
return;
}
switch (action) {
case "lock":
- onToast("房间已锁定,其他人无法加入");
+ toast.show("房间已锁定,其他人无法加入");
break;
case "unlock":
- onToast("房间已解锁");
+ toast.show("房间已解锁");
break;
case "kick":
- onToast("已将该用户移出房间");
+ toast.show("已将该用户移出房间");
setConfirmKick(null);
break;
case "end_voting":
- onToast("已结束投票,正在结算结果");
+ toast.show("已结束投票,正在结算结果");
setConfirmEnd(false);
onClose();
break;
}
} catch {
- onToast("操作失败,请重试");
+ toast.show("操作失败,请重试");
} finally {
setLoading(null);
}
},
- [roomId, userId, onToast, onClose],
+ [roomId, userId, toast, onClose],
);
const otherUsers = users.filter((u) => u !== userId);
return (
-
- {open && (
-
+
+
+
+
+
+
+
房间管理
+
+
+ 房间号 {roomId}
+
+
+
+ manage(locked ? "unlock" : "lock")}
+ disabled={loading !== null}
+ className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
+ locked
+ ? "bg-accent/15 text-accent ring-1 ring-accent/30 active:bg-accent/25"
+ : "bg-elevated text-secondary ring-1 ring-border active:bg-subtle"
+ }`}
>
-
-
-
-
+ {loading === "lock" || loading === "unlock" ? (
+
+ ) : locked ? (
+
+ ) : (
+
+ )}
+ {locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
+
+
-
-
-
房间管理
-
-
- 房间号 {roomId}
-
+
+
+ 房间成员({users.length})
+
+
+ {users.map((uid) => {
+ const displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
+ const isCreator = uid === userId;
+ const swiped = swipeCounts[uid] ?? 0;
+ const finished = swiped >= totalCards;
- {/* Lock/Unlock */}
-
-
manage(locked ? "unlock" : "lock")}
- disabled={loading !== null}
- className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
- locked
- ? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100"
- : "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50"
- }`}
+ return (
+
- {loading === "lock" || loading === "unlock" ? (
-
- ) : locked ? (
-
- ) : (
-
+
+
+
+ {isCreator && (
+
+
+ 房主
+
+ )}
+
+ {displayName}
+
+
+
+ {swiped}/{totalCards}
+ {finished ? " 已完成" : " 进行中"}
+
+
+
+ {!isCreator && (
+ <>
+ {confirmKick === uid ? (
+
+ manage("kick", uid)}
+ disabled={loading !== null}
+ className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
+ >
+ {loading === "kick" + uid ? (
+
+ ) : (
+ "确认"
+ )}
+
+ setConfirmKick(null)}
+ className="rounded-lg bg-subtle px-2.5 py-1 text-[11px] font-semibold text-tertiary transition-colors active:bg-elevated"
+ >
+ 取消
+
+
+ ) : (
+
setConfirmKick(uid)}
+ className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-muted transition-colors active:bg-subtle active:text-rose-400"
+ >
+
+ 移出
+
+ )}
+ >
)}
- {locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
+
+ );
+ })}
+
+
+
+
+ {confirmEnd ? (
+
+
+ 确定要结束投票吗?将根据当前已有的投票结果直接结算。
+
+
+ manage("end_voting")}
+ disabled={loading !== null}
+ className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
+ >
+ {loading === "end_voting" ? (
+
+ ) : (
+
+ )}
+ 确认结束
+
+ setConfirmEnd(false)}
+ className="flex h-9 flex-1 items-center justify-center rounded-lg bg-elevated text-xs font-semibold text-tertiary transition-colors active:bg-subtle"
+ >
+ 再等等
-
- {/* User list with kick */}
-
-
- 房间成员({users.length})
-
-
- {users.map((uid) => {
- const profile = userProfiles[uid];
- const emoji = profile?.avatar ?? getAvatar(uid).emoji;
- const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
- const displayName = profile?.username ?? uid.slice(0, 8);
- const isCreator = uid === userId;
- const swiped = swipeCounts[uid] ?? 0;
- const finished = swiped >= totalCards;
-
- return (
-
-
- {emoji}
-
-
-
- {isCreator && (
-
-
- 房主
-
- )}
-
- {displayName}
-
-
-
- {swiped}/{totalCards}
- {finished ? " 已完成" : " 进行中"}
-
-
-
- {!isCreator && (
- <>
- {confirmKick === uid ? (
-
- manage("kick", uid)}
- disabled={loading !== null}
- className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
- >
- {loading === "kick" + uid ? (
-
- ) : (
- "确认"
- )}
-
- setConfirmKick(null)}
- className="rounded-lg bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 transition-colors active:bg-zinc-300"
- >
- 取消
-
-
- ) : (
-
setConfirmKick(uid)}
- className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-400 transition-colors active:bg-zinc-100 active:text-rose-500"
- >
-
- 移出
-
- )}
- >
- )}
-
- );
- })}
-
-
-
- {/* End voting */}
-
- {confirmEnd ? (
-
-
- 确定要结束投票吗?将根据当前已有的投票结果直接结算。
-
-
- manage("end_voting")}
- disabled={loading !== null}
- className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
- >
- {loading === "end_voting" ? (
-
- ) : (
-
- )}
- 确认结束
-
- setConfirmEnd(false)}
- className="flex h-9 flex-1 items-center justify-center rounded-lg bg-white text-xs font-semibold text-zinc-600 transition-colors active:bg-zinc-50"
- >
- 再等等
-
-
-
- ) : (
-
setConfirmEnd(true)}
- disabled={loading !== null}
- className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-amber-200 bg-amber-50 text-sm font-semibold text-amber-700 transition-colors active:bg-amber-100 disabled:opacity-50"
- >
-
- 结束投票(立即出结果)
-
- )}
-
-
-
- )}
-
+
+ ) : (
+
setConfirmEnd(true)}
+ disabled={loading !== null}
+ className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-500/10 text-sm font-semibold text-amber-400 ring-1 ring-amber-500/30 transition-colors active:bg-amber-500/20 disabled:opacity-50"
+ >
+
+ 结束投票(立即出结果)
+
+ )}
+
+
);
}
diff --git a/src/components/RunnerUpCard.tsx b/src/components/RunnerUpCard.tsx
new file mode 100644
index 0000000..304939a
--- /dev/null
+++ b/src/components/RunnerUpCard.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Star, MapPin } from "lucide-react";
+import type { Restaurant } from "@/types";
+import RestaurantImage from "@/components/RestaurantImage";
+import { buildNavUrl } from "@/lib/navigation";
+
+interface RunnerUpCardProps {
+ restaurant: Restaurant;
+ likes: number;
+ userCount: number;
+}
+
+export default function RunnerUpCard({ restaurant, likes, userCount }: RunnerUpCardProps) {
+ return (
+
+ {restaurant.images?.[0] && (
+
+ )}
+
+
+ {restaurant.name}
+
+
+
+
+ {restaurant.rating}
+
+ {restaurant.price}
+ {restaurant.distance && (
+
+
+ {restaurant.distance}
+
+ )}
+
+
+ {likes}/{userCount} 人想去
+
+
+
+ );
+}
diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx
new file mode 100644
index 0000000..e87f7b3
--- /dev/null
+++ b/src/components/ShareCardModal.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { useState, useRef, useCallback, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { X, Download, Share2, Loader2 } from "lucide-react";
+import { useToast } from "@/hooks/useToast";
+import { useShare } from "@/hooks/useShare";
+import {
+ loadImageAsDataUrl,
+ generateImage,
+ downloadDataUrl,
+ dataUrlToFile,
+} from "@/lib/shareImage";
+import RestaurantShareCard, {
+ type RestaurantShareData,
+} from "@/components/RestaurantShareCard";
+import BlindboxShareCard, {
+ type BlindboxShareData,
+} from "@/components/BlindboxShareCard";
+import BlindboxPlanShareCard, {
+ type PlanShareData,
+} from "@/components/BlindboxPlanShareCard";
+
+export type ShareCardData = RestaurantShareData | BlindboxShareData | PlanShareData;
+
+interface ShareCardModalProps {
+ open: boolean;
+ onClose: () => void;
+ data: ShareCardData;
+}
+
+export default function ShareCardModal({
+ open,
+ onClose,
+ data,
+}: ShareCardModalProps) {
+ const toast = useToast();
+ const cardRef = useRef
(null);
+ const backdropRef = useRef(null);
+ const [generating, setGenerating] = useState(false);
+ const [imageDataUrl, setImageDataUrl] = useState(null);
+ const [imageLoading, setImageLoading] = useState(false);
+
+ const imageSrc = data.type === "restaurant" ? data.restaurant.images?.[0] : undefined;
+
+ useEffect(() => {
+ if (!open) {
+ setImageDataUrl(null);
+ return;
+ }
+ if (!imageSrc) return;
+
+ setImageLoading(true);
+ loadImageAsDataUrl(imageSrc)
+ .then(setImageDataUrl)
+ .finally(() => setImageLoading(false));
+ }, [open, imageSrc]);
+
+ const handleGenerate = useCallback(async (): Promise => {
+ if (!cardRef.current) return null;
+ setGenerating(true);
+ try {
+ return await generateImage(cardRef.current);
+ } catch {
+ try {
+ setImageDataUrl(null);
+ await new Promise((r) => setTimeout(r, 100));
+ if (!cardRef.current) return null;
+ return await generateImage(cardRef.current);
+ } catch {
+ return null;
+ }
+ } finally {
+ setGenerating(false);
+ }
+ }, []);
+
+ const handleSave = useCallback(async () => {
+ const png = await handleGenerate();
+ if (!png) {
+ toast.show("生成图片失败,请重试");
+ return;
+ }
+ const name =
+ data.type === "restaurant"
+ ? `NoWhatever_${data.restaurant.name}.png`
+ : `NoWhatever_周末契约.png`;
+ downloadDataUrl(png, name);
+ toast.show("图片已保存");
+ }, [handleGenerate, toast, data]);
+
+ const { share: nativeShare } = useShare();
+
+ const handleShare = useCallback(async () => {
+ const png = await handleGenerate();
+ if (!png) {
+ toast.show("生成图片失败,请重试");
+ return;
+ }
+
+ const file = dataUrlToFile(png, "NoWhatever.png");
+ const shared = await nativeShare({ files: [file] });
+ if (!shared) {
+ downloadDataUrl(png, "NoWhatever.png");
+ toast.show("图片已保存,快去分享吧!");
+ }
+ }, [handleGenerate, toast, nativeShare]);
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (e.target === backdropRef.current) onClose();
+ };
+
+ return (
+
+ {open && (
+
+
+ {/* Card + close button wrapper */}
+
+
+
+
+ {imageLoading ? (
+
+
+
+ ) : data.type === "restaurant" ? (
+
+ ) : data.type === "plan" ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Action buttons */}
+
+
+ {generating ? (
+
+ ) : (
+
+ )}
+ 保存图片
+
+
+ {generating ? (
+
+ ) : (
+
+ )}
+ 分享给好友
+
+
+
+
+ 长按图片也可以保存到相册
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 0000000..34e5015
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className = "" }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function SkeletonCircle({ className = "" }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function RoomCardSkeleton() {
+ return (
+
+ );
+}
+
+export function ProfileCardSkeleton() {
+ return (
+
+ );
+}
+
+export function RecordItemSkeleton() {
+ return (
+
+ );
+}
+
+export function SwipeDeckSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export function BlindboxRoomSkeleton() {
+ return (
+
+ );
+}
+
+export function BlindboxListSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx
index 5efcb3a..139a722 100644
--- a/src/components/SwipeDeck.tsx
+++ b/src/components/SwipeDeck.tsx
@@ -8,7 +8,7 @@ import MatchResult from "./MatchResult";
import SwipeGuide from "./SwipeGuide";
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
import { Heart, Undo2, Check } from "lucide-react";
-import { getAvatar, getAvatarBg } from "@/lib/avatars";
+import UserAvatar from "@/components/UserAvatar";
function UserProgressBar({
userId,
@@ -16,48 +16,53 @@ function UserProgressBar({
localIndex,
total,
userProfiles,
+ onUndo,
}: {
userId: string;
swipeCounts: Record;
localIndex: number;
total: number;
userProfiles: Record;
+ onUndo: () => void;
}) {
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
- if (others.length === 0) return null;
-
- const myProfile = userProfiles[userId];
- const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
- const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
return (
-
-
-
- {myAvatar}
-
- 你 {localIndex}/{total}
-
- {others.map(([id, count]) => {
- const finished = count >= total;
- const profile = userProfiles[id];
- const emoji = profile?.avatar ?? getAvatar(id).emoji;
- const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
- const label = profile?.username ?? "";
- return (
-
-
- {emoji}
-
- {label && {label}}
- {count}/{total}
- {finished && }
+
+
+
+
+ 你
+
+ {localIndex}/{total}
- );
- })}
+
+ {others.map(([id, count]) => {
+ const finished = count >= total;
+ const label = userProfiles[id]?.username ?? "";
+ return (
+
+
+ {label && {label}}
+
+ {count}/{total}
+
+ {finished && }
+
+ );
+ })}
+
+
+
+ 撤回
+
);
}
@@ -79,17 +84,11 @@ function WaitingProgress({
const others = entries.filter(([id]) => id !== userId);
const finishedCount = others.filter(([, c]) => c >= total).length;
- const myProfile = userProfiles[userId];
- const myEmoji = myProfile?.avatar ?? getAvatar(userId).emoji;
- const myBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
-
return (
-
+
-
-
- {myEmoji}
-
+
+
你 {total}/{total}
@@ -98,24 +97,24 @@ function WaitingProgress({
{others.map(([id, count]) => {
const finished = count >= total;
const pct = Math.min((count / total) * 100, 100);
- const profile = userProfiles[id];
- const emoji = profile?.avatar ?? getAvatar(id).emoji;
- const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
- const label = profile?.username ?? "";
+ const label = userProfiles[id]?.username ?? "";
return (
-
-
- {emoji}
-
- {label && {label}}
+
+
+ {label && {label}}
{count}/{total}
{finished && }
{!finished && (
-
+
+
{finishedCount}/{others.length} 人已完成
@@ -292,6 +291,13 @@ export default function SwipeDeck({
prevLikeCounts.current = {};
}, []);
+ useEffect(() => {
+ const serverIndex = swipeCounts[userId] ?? 0;
+ if (serverIndex === 0 && currentIndex > 0 && !resetting) {
+ clearLocalState();
+ }
+ }, [swipeCounts, userId, currentIndex, resetting, clearLocalState]);
+
const handleReset = useCallback(async () => {
setResetting(true);
try {
@@ -319,47 +325,24 @@ export default function SwipeDeck({
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
: null;
- const showWaiting = allSwiped && !resolvedMatchId;
+ const showWaiting = allSwiped && !resolvedMatchId && userCount > 1;
return (
<>
- {!allSwiped && !resolvedMatchId && (
-
-
-
-
-
-
- {currentIndex}/{restaurants.length}
-
-
-
- 撤回
-
-
-
-
- )}
-
+ {!allSwiped && !resolvedMatchId && (
+
+
+
+ )}
{currentIndex === 0 && !resolvedMatchId && guideVisible && (
setGuideVisible(false)} />
)}
@@ -385,8 +368,8 @@ export default function SwipeDeck({
{showWaiting && (
-
-
等待其他人完成选择
+
+
等待其他人完成选择
+ typeof window !== "undefined" ? window.innerWidth * 1.5 : 600;
const ROTATION_RANGE = 18;
interface SwipeableCardProps {
@@ -66,7 +67,8 @@ export default function SwipeableCard({
const flyOut = (direction: SwipeDirection) => {
if (isSwiping.current) return;
isSwiping.current = true;
- const exitX = direction === "right" ? EXIT_X : -EXIT_X;
+ const exit = getExitX();
+ const exitX = direction === "right" ? exit : -exit;
animate(x, exitX, {
type: "spring",
stiffness: 600,
diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx
new file mode 100644
index 0000000..1bd6b42
--- /dev/null
+++ b/src/components/Toast.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { motion, AnimatePresence } from "framer-motion";
+
+interface ToastProps {
+ message: string;
+ position?: "top" | "bottom";
+}
+
+const positionClass = {
+ top: "top-10",
+ bottom: "bottom-8",
+};
+
+export default function Toast({ message, position = "top" }: ToastProps) {
+ const isTop = position === "top";
+ const y = isTop ? -12 : 12;
+
+ return (
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/src/components/ToastProvider.tsx b/src/components/ToastProvider.tsx
new file mode 100644
index 0000000..85d50d7
--- /dev/null
+++ b/src/components/ToastProvider.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import { ToastContext, type ToastPosition } from "@/hooks/useToast";
+import Toast from "./Toast";
+
+export default function ToastProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [message, setMessage] = useState("");
+ const [position, setPosition] = useState("top");
+ const timerRef = useRef>(undefined);
+
+ const show = useCallback((msg: string, pos: ToastPosition = "top") => {
+ clearTimeout(timerRef.current);
+ setMessage(msg);
+ setPosition(pos);
+ timerRef.current = setTimeout(() => setMessage(""), 2200);
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
index 2d5758f..e13bf03 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -1,8 +1,7 @@
"use client";
-import { useState, useCallback } from "react";
-import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
-import { motion, AnimatePresence } from "framer-motion";
+import { useState } from "react";
+import { QrCode, LogOut, Crown, Lock } from "lucide-react";
import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal";
import type { UserProfile, SceneType } from "@/types";
@@ -10,7 +9,7 @@ import { getSceneConfig } from "@/lib/sceneConfig";
interface TopNavProps {
roomId: string;
- userCount: number;
+ userCount?: number;
onExit?: () => void;
isCreator?: boolean;
userId?: string;
@@ -36,84 +35,51 @@ export default function TopNav({
scene = "eat",
}: TopNavProps) {
const sceneConfig = getSceneConfig(scene);
- const [toast, setToast] = useState("");
const [showQr, setShowQr] = useState(false);
const [showManage, setShowManage] = useState(false);
- const showToast = useCallback((msg: string) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }, []);
-
return (
<>
-
+
+
+ NoWhatever
+
+ 别说随便
+
+
+
setShowQr(false)}
roomId={roomId}
- onToast={showToast}
scene={scene}
/>
@@ -128,7 +94,6 @@ export default function TopNav({
swipeCounts={swipeCounts}
totalCards={totalCards}
userProfiles={userProfiles}
- onToast={showToast}
/>
)}
>
diff --git a/src/components/UserAvatar.tsx b/src/components/UserAvatar.tsx
new file mode 100644
index 0000000..75be9dc
--- /dev/null
+++ b/src/components/UserAvatar.tsx
@@ -0,0 +1,34 @@
+import { resolveAvatar } from "@/lib/avatars";
+
+const sizeStyles = {
+ xs: "h-4 w-4 text-[10px]",
+ sm: "h-5 w-5 text-sm",
+ md: "h-8 w-8 text-base",
+ lg: "h-11 w-11 text-xl",
+ xl: "h-14 w-14 text-2xl",
+} as const;
+
+interface UserAvatarProps {
+ userId: string;
+ profile?: { avatar: string } | null;
+ size?: keyof typeof sizeStyles;
+ bg?: string;
+ className?: string;
+}
+
+export default function UserAvatar({
+ userId,
+ profile,
+ size = "md",
+ bg,
+ className = "",
+}: UserAvatarProps) {
+ const avatar = resolveAvatar(userId, profile);
+ return (
+
+ {avatar.emoji}
+
+ );
+}
diff --git a/src/components/WeekendTimeSelector.tsx b/src/components/WeekendTimeSelector.tsx
new file mode 100644
index 0000000..3c1f997
--- /dev/null
+++ b/src/components/WeekendTimeSelector.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState } from "react";
+import { motion } from "framer-motion";
+import { Calendar, Clock, Sparkles, X } from "lucide-react";
+import Button from "@/components/Button";
+
+interface TimeConfig {
+ date: string;
+ startHour: number;
+ endHour: number;
+}
+
+const PRESETS: { label: string; value: TimeConfig }[] = [
+ { label: "周六全天", value: { date: "周六", startHour: 10, endHour: 21 } },
+ { label: "周日全天", value: { date: "周日", startHour: 10, endHour: 21 } },
+ { label: "整个周末", value: { date: "整个周末", startHour: 10, endHour: 21 } },
+];
+
+const HOURS = Array.from({ length: 15 }, (_, i) => i + 7);
+
+export default function WeekendTimeSelector({
+ onConfirm,
+ onClose,
+ loading,
+}: {
+ onConfirm: (config: TimeConfig) => void;
+ onClose: () => void;
+ loading?: boolean;
+}) {
+ const [config, setConfig] = useState(PRESETS[0].value);
+
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ {PRESETS.map((preset) => (
+ setConfig({ ...preset.value })}
+ className={`flex-1 rounded-xl py-2.5 text-xs font-bold transition-all ${
+ config.date === preset.value.date
+ ? "bg-purple-600 text-white shadow-lg shadow-purple-900/30"
+ : "bg-elevated text-secondary ring-1 ring-border"
+ }`}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+
+
+
+ 至
+
+
+
+
+ onConfirm(config)}
+ variant="purple"
+ size="lg"
+ loading={loading}
+ icon={}
+ className="mt-6 w-full"
+ >
+ 生成周末计划
+
+
+
+ );
+}
diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts
new file mode 100644
index 0000000..2f154ca
--- /dev/null
+++ b/src/hooks/useGeolocation.ts
@@ -0,0 +1,70 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+
+export type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
+
+type GpsResult =
+ | { ok: true; lat: number; lng: number }
+ | { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
+
+function requestGps(): Promise {
+ return new Promise((resolve) => {
+ if (!navigator.geolocation) {
+ resolve({ ok: false, reason: "unsupported" });
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (pos) =>
+ resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
+ (err) => {
+ const reason =
+ err.code === err.PERMISSION_DENIED
+ ? "denied"
+ : err.code === err.TIMEOUT
+ ? "timeout"
+ : "unknown";
+ resolve({ ok: false, reason });
+ },
+ { timeout: 8000, enableHighAccuracy: false },
+ );
+ });
+}
+
+async function reverseGeocode(lat: number, lng: number): Promise {
+ try {
+ const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
+ const data = await res.json();
+ return data.name || data.formatted || null;
+ } catch {
+ return null;
+ }
+}
+
+export function useGeolocation() {
+ const [status, setStatus] = useState("idle");
+ const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
+ const [locationName, setLocationName] = useState(null);
+
+ const locate = useCallback(async () => {
+ setStatus("locating");
+ const result = await requestGps();
+ if (result.ok) {
+ setCoords({ lat: result.lat, lng: result.lng });
+ setStatus("success");
+ const name = await reverseGeocode(result.lat, result.lng);
+ if (name) setLocationName(name);
+ } else {
+ setCoords(null);
+ setLocationName(null);
+ setStatus(result.reason === "denied" ? "denied" : "failed");
+ }
+ }, []);
+
+ useEffect(() => {
+ locate();
+ }, [locate]);
+
+ return { status, coords, locationName, retry: locate };
+}
diff --git a/src/hooks/useShare.ts b/src/hooks/useShare.ts
new file mode 100644
index 0000000..b95966e
--- /dev/null
+++ b/src/hooks/useShare.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { useCallback } from "react";
+import { useToast } from "@/hooks/useToast";
+
+export function useShare() {
+ const toast = useToast();
+
+ const copyToClipboard = useCallback(
+ async (text: string, successMsg = "已复制") => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.show(successMsg);
+ } catch {
+ toast.show("复制失败,请手动复制");
+ }
+ },
+ [toast],
+ );
+
+ /** Try the native Web Share API; calls `fallback` if unavailable. Returns true if native share was triggered. */
+ const share = useCallback(
+ async (data: ShareData, fallback?: () => void): Promise => {
+ try {
+ if (navigator.share && navigator.canShare?.(data)) {
+ await navigator.share(data);
+ return true;
+ }
+ } catch (e) {
+ if (e instanceof Error && e.name === "AbortError") return true;
+ }
+ fallback?.();
+ return false;
+ },
+ [],
+ );
+
+ return { share, copyToClipboard };
+}
diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts
new file mode 100644
index 0000000..8b561c4
--- /dev/null
+++ b/src/hooks/useToast.ts
@@ -0,0 +1,15 @@
+import { createContext, useContext } from "react";
+
+export type ToastPosition = "top" | "bottom";
+
+export interface ToastContextValue {
+ show: (msg: string, position?: ToastPosition) => void;
+}
+
+export const ToastContext = createContext(null);
+
+export function useToast() {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error("useToast must be used within ToastProvider");
+ return ctx;
+}
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
new file mode 100644
index 0000000..47f9db3
--- /dev/null
+++ b/src/lib/ai.ts
@@ -0,0 +1,172 @@
+import OpenAI from "openai";
+import type { IdeaTags, PlanItem } from "@/types";
+
+function getClient() {
+ const apiKey = process.env.DEEPSEEK_API_KEY;
+ if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not configured");
+ return new OpenAI({ baseURL: "https://api.deepseek.com", apiKey });
+}
+
+const TAG_SYSTEM_PROMPT = `你是一个周末活动分析助手。用户会输入一条周末活动想法,你需要分析并返回结构化 JSON。
+
+返回字段:
+- category: 活动品类,必须是以下之一:
+ "dining"(餐饮美食)| "outdoor"(户外活动)| "entertainment"(娱乐休闲,如电影、KTV、密室)| "shopping"(购物逛街)| "sports"(运动健身)| "culture"(文化艺术,如博物馆、展览)| "relaxation"(放松休息,如SPA、咖啡、下午茶)
+- timeSlot: 最适合的时间段,必须是以下之一:
+ "morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天)
+- estimatedMinutes: 预估活动时长(整数,单位分钟)
+- outdoor: 是否户外活动(布尔值)
+- searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名)
+- searchType: 搜索策略,必须是以下之一:
+ "brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的)
+
+只返回 JSON,不要任何额外文字。`;
+
+const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。
+
+规划原则:
+1. 选择地理位置相近的 POI,最小化总移动距离
+2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活)
+3. 活动之间留出合理的交通时间(15-30分钟)
+4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
+
+返回 JSON 格式:
+{
+ "items": [
+ {
+ "time": "10:00",
+ "activity": "原始活动描述",
+ "poi": "选定的具体 POI 名称",
+ "address": "详细地址",
+ "lat": 31.2,
+ "lng": 121.5,
+ "duration": 120,
+ "reason": "选择这个时间和地点的简短理由"
+ }
+ ],
+ "summary": "一句话总结这个行程的亮点"
+}
+
+按时间顺序排列。只返回 JSON。`;
+
+export interface ScheduleContext {
+ ideas: {
+ content: string;
+ category: string;
+ timeSlot: string;
+ estimatedMinutes: number;
+ searchQuery: string;
+ searchType: string;
+ }[];
+ candidates: Record<
+ string,
+ { name: string; address: string; lat: number; lng: number; rating?: number }[]
+ >;
+ userLocation: { lat: number; lng: number };
+ availableTime: { date: string; startHour: number; endHour: number };
+}
+
+export async function tagIdea(content: string): Promise {
+ try {
+ const client = getClient();
+ const response = await client.chat.completions.create({
+ model: "deepseek-chat",
+ messages: [
+ { role: "system", content: TAG_SYSTEM_PROMPT },
+ { role: "user", content },
+ ],
+ response_format: { type: "json_object" },
+ max_tokens: 200,
+ temperature: 0.3,
+ });
+
+ const text = response.choices[0]?.message?.content;
+ if (!text) return null;
+
+ const parsed = JSON.parse(text);
+
+ const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation"];
+ const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
+ const validSearchTypes = ["brand", "place", "category"];
+
+ if (
+ !validCategories.includes(parsed.category) ||
+ !validTimeSlots.includes(parsed.timeSlot) ||
+ !validSearchTypes.includes(parsed.searchType) ||
+ typeof parsed.estimatedMinutes !== "number" ||
+ typeof parsed.outdoor !== "boolean" ||
+ typeof parsed.searchQuery !== "string"
+ ) {
+ return null;
+ }
+
+ return {
+ category: parsed.category,
+ timeSlot: parsed.timeSlot,
+ estimatedMinutes: parsed.estimatedMinutes,
+ outdoor: parsed.outdoor,
+ searchQuery: parsed.searchQuery,
+ searchType: parsed.searchType,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function generateSchedule(
+ ctx: ScheduleContext,
+): Promise<{ items: PlanItem[]; summary: string } | null> {
+ try {
+ const client = getClient();
+
+ const userPrompt = `
+可用时间:${ctx.availableTime.date},${ctx.availableTime.startHour}:00 - ${ctx.availableTime.endHour}:00
+用户出发位置:纬度 ${ctx.userLocation.lat},经度 ${ctx.userLocation.lng}
+
+活动列表:
+${ctx.ideas.map((idea, i) => `${i + 1}. "${idea.content}"(品类:${idea.category},偏好时间:${idea.timeSlot},预估${idea.estimatedMinutes}分钟)`).join("\n")}
+
+各活动的候选地点:
+${Object.entries(ctx.candidates)
+ .map(
+ ([query, pois]) =>
+ `"${query}" 的候选:\n${pois.map((p) => ` - ${p.name} | ${p.address} | 坐标(${p.lat},${p.lng})${p.rating ? ` | 评分${p.rating}` : ""}`).join("\n")}`,
+ )
+ .join("\n\n")}
+
+请为以上活动生成最优行程安排。`;
+
+ const response = await client.chat.completions.create({
+ model: "deepseek-chat",
+ messages: [
+ { role: "system", content: SCHEDULE_SYSTEM_PROMPT },
+ { role: "user", content: userPrompt },
+ ],
+ response_format: { type: "json_object" },
+ max_tokens: 1500,
+ temperature: 0.5,
+ });
+
+ const text = response.choices[0]?.message?.content;
+ if (!text) return null;
+
+ const parsed = JSON.parse(text);
+ if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
+
+ return {
+ items: parsed.items.map((item: Record) => ({
+ time: String(item.time ?? ""),
+ activity: String(item.activity ?? ""),
+ poi: String(item.poi ?? ""),
+ address: String(item.address ?? ""),
+ lat: Number(item.lat) || 0,
+ lng: Number(item.lng) || 0,
+ duration: Number(item.duration) || 60,
+ reason: String(item.reason ?? ""),
+ })),
+ summary: String(parsed.summary ?? ""),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/amap.ts b/src/lib/amap.ts
new file mode 100644
index 0000000..bb72826
--- /dev/null
+++ b/src/lib/amap.ts
@@ -0,0 +1,7 @@
+import { ApiError } from "@/lib/api";
+
+export function requireAmapApiKey(): string {
+ const key = process.env.AMAP_API_KEY;
+ if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
+ return key;
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..f3879dc
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from "next/server";
+import { Prisma } from "@prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public status: number = 400,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+/** Validates that value is a non-empty string; throws 401 otherwise. */
+export function requireUserId(value: unknown): string {
+ if (!value || typeof value !== "string") {
+ throw new ApiError("请先登录", 401);
+ }
+ return value;
+}
+
+/** Finds user by ID; throws 404 if not found. */
+export async function requireUser(userId: string) {
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user) throw new ApiError("用户不存在", 404);
+ return user;
+}
+
+type RouteContext = { params: Promise> };
+
+type RouteHandler = (
+ req: NextRequest,
+ ctx: RouteContext,
+) => Promise;
+
+/**
+ * Wraps a Next.js route handler with unified error handling.
+ * - ApiError instances are converted to JSON responses with matching status codes
+ * - Unknown errors are logged and returned as 500
+ */
+export function apiHandler(handler: RouteHandler): RouteHandler {
+ return async (req, ctx) => {
+ try {
+ return await handler(req, ctx);
+ } catch (e) {
+ if (e instanceof ApiError) {
+ return NextResponse.json({ error: e.message }, { status: e.status });
+ }
+ if (
+ e instanceof Prisma.PrismaClientKnownRequestError &&
+ e.code === "P2002"
+ ) {
+ return NextResponse.json(
+ { error: "该记录已存在或值已被占用" },
+ { status: 409 },
+ );
+ }
+ console.error(`[API ${req.method} ${req.nextUrl.pathname}]`, e);
+ return NextResponse.json({ error: "操作失败" }, { status: 500 });
+ }
+ };
+}
diff --git a/src/lib/avatars.ts b/src/lib/avatars.ts
index c139e5e..88f3cef 100644
--- a/src/lib/avatars.ts
+++ b/src/lib/avatars.ts
@@ -25,3 +25,14 @@ export function getAvatarBg(emoji: string): string {
const found = AVATARS.find((a) => a.emoji === emoji);
return found?.bg ?? "bg-zinc-100";
}
+
+export function resolveAvatar(
+ userId: string,
+ profile?: { avatar: string } | null,
+): { emoji: string; bg: string } {
+ if (profile) {
+ return { emoji: profile.avatar, bg: getAvatarBg(profile.avatar) };
+ }
+ const fallback = getAvatar(userId);
+ return { emoji: fallback.emoji, bg: fallback.bg };
+}
diff --git a/src/lib/blindbox.ts b/src/lib/blindbox.ts
new file mode 100644
index 0000000..c9e5b4a
--- /dev/null
+++ b/src/lib/blindbox.ts
@@ -0,0 +1,41 @@
+import { prisma } from "@/lib/prisma";
+import { ApiError } from "@/lib/api";
+
+export function generateRoomCode(): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ let code = "";
+ for (let i = 0; i < 6; i++) {
+ code += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return code;
+}
+
+export async function generateUniqueRoomCode(): Promise {
+ for (let attempt = 0; attempt < 10; attempt++) {
+ const code = generateRoomCode();
+ const existing = await prisma.blindBoxRoom.findUnique({ where: { code } });
+ if (!existing) return code;
+ }
+ throw new Error("无法生成唯一房间号");
+}
+
+/** Throws 403 if user is not a member of the room. */
+export async function requireMembership(roomId: string, userId: string) {
+ const member = await prisma.blindBoxMember.findUnique({
+ where: { roomId_userId: { roomId, userId } },
+ });
+ if (!member) throw new ApiError("你不是这个房间的成员", 403);
+ return member;
+}
+
+export async function getRoomByCode(code: string) {
+ return prisma.blindBoxRoom.findUnique({
+ where: { code },
+ include: {
+ members: {
+ include: { user: { select: { id: true, username: true, avatar: true } } },
+ },
+ _count: { select: { ideas: { where: { status: "in_pool" } } } },
+ },
+ });
+}
diff --git a/src/lib/celebrate.ts b/src/lib/celebrate.ts
index decd438..1c10146 100644
--- a/src/lib/celebrate.ts
+++ b/src/lib/celebrate.ts
@@ -62,9 +62,20 @@ export function fireCelebration() {
setTimeout(frame, 300);
}
+let _audioCtx: AudioContext | null = null;
+
+function getAudioContext(): AudioContext {
+ if (!_audioCtx || _audioCtx.state === "closed") {
+ _audioCtx = new AudioContext();
+ }
+ return _audioCtx;
+}
+
export function playChime() {
try {
- const ctx = new AudioContext();
+ const ctx = getAudioContext();
+ if (ctx.state === "suspended") ctx.resume();
+
const gain = ctx.createGain();
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
@@ -88,8 +99,6 @@ export function playChime() {
osc.start(start);
osc.stop(start + 0.6);
});
-
- setTimeout(() => ctx.close(), 2000);
} catch {
// Audio not available — silent fallback
}
diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts
new file mode 100644
index 0000000..86dc61b
--- /dev/null
+++ b/src/lib/navigation.ts
@@ -0,0 +1,11 @@
+import type { Restaurant } from "@/types";
+
+export function buildNavUrl(restaurant: Restaurant): string {
+ if (restaurant.location) {
+ const parts = restaurant.location.split(",");
+ if (parts.length === 2 && parts[0] && parts[1]) {
+ return `https://uri.amap.com/marker?position=${parts[0]},${parts[1]}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
+ }
+ }
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
+}
diff --git a/src/lib/room.ts b/src/lib/room.ts
new file mode 100644
index 0000000..cf617a2
--- /dev/null
+++ b/src/lib/room.ts
@@ -0,0 +1,11 @@
+export async function joinRoom(roomId: string, userId: string): Promise {
+ const res = await fetch(`/api/room/${roomId}/join`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "加入房间失败");
+ }
+}
diff --git a/src/lib/sceneConfig.ts b/src/lib/sceneConfig.ts
index 14e60e0..e2ae67c 100644
--- a/src/lib/sceneConfig.ts
+++ b/src/lib/sceneConfig.ts
@@ -4,6 +4,7 @@ export interface SceneConfig {
key: SceneType;
label: string;
emoji: string;
+ verb: string;
poiTypes: string;
defaultImage: string;
hotTags: readonly string[];
@@ -22,56 +23,83 @@ export interface SceneConfig {
const SCENE_CONFIGS: Record = {
eat: {
key: "eat",
- label: "吃什么",
+ label: "餐厅",
emoji: "🍜",
+ verb: "吃",
poiTypes: "050000",
defaultImage:
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80",
- hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "咖啡甜品", "小吃快餐"],
+ hotTags: ["火锅", "日料", "烧烤", "西餐", "川菜", "粤菜", "小吃快餐"],
priceOptions: [
{ label: "不限", value: "any" },
{ label: "¥50以下", value: "under50" },
{ label: "¥50-100", value: "50to100" },
{ label: "¥100+", value: "over100" },
],
- tagLabel: "美食",
+ tagLabel: "口味",
tagPlaceholder: "想吃什么?(留空则不限)",
- loadingText: "正在搜索周边美食...",
- emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个菜系",
+ loadingText: "正在搜索周边餐厅...",
+ emptyError: "附近没有找到餐厅,试试扩大搜索范围或换个口味",
subtitle: "和朋友一起滑卡片,再也不用纠结吃什么",
inviteText: "有人邀请你一起选餐厅",
shareTitle: "别说随便啦,来滑卡片决定吃什么!",
shareText: "我建好房间了,快点开链接一起选餐厅,滑中同一家就去吃!",
qrSubtitle: "让朋友扫码加入房间,一起滑卡片选餐厅",
},
- drink: {
- key: "drink",
- label: "喝什么",
+ drinks: {
+ key: "drinks",
+ label: "咖啡/奶茶",
emoji: "🧋",
+ verb: "喝",
poiTypes: "050301|050302|050303|050400",
defaultImage:
"https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=800&q=80",
- hotTags: ["奶茶", "咖啡", "酒吧", "果茶", "甜品", "茶馆", "鸡尾酒"],
+ hotTags: ["咖啡", "奶茶", "拿铁", "美式", "水果茶", "冷萃", "芋泥"],
priceOptions: [
{ label: "不限", value: "any" },
{ label: "¥20以下", value: "under20" },
{ label: "¥20-50", value: "20to50" },
{ label: "¥50+", value: "over50" },
],
- tagLabel: "饮品",
+ tagLabel: "类型",
tagPlaceholder: "想喝什么?(留空则不限)",
loadingText: "正在搜索周边饮品店...",
- emptyError: "附近没有找到饮品店,试试扩大搜索范围或换个类型",
+ emptyError: "附近没有找到饮品店,试试扩大搜索范围",
subtitle: "和朋友一起滑卡片,再也不用纠结喝什么",
inviteText: "有人邀请你一起选饮品店",
shareTitle: "别说随便啦,来滑卡片决定喝什么!",
shareText: "我建好房间了,快点开链接一起选店,滑中同一家就去喝!",
- qrSubtitle: "让朋友扫码加入房间,一起滑卡片选店",
+ qrSubtitle: "让朋友扫码加入房间,一起滑卡片选饮品店",
+ },
+ dessert: {
+ key: "dessert",
+ label: "甜品",
+ emoji: "🍰",
+ verb: "吃",
+ poiTypes: "050403|050404|050400",
+ defaultImage:
+ "https://images.unsplash.com/photo-1488477181946-6428a0291777?w=800&q=80",
+ hotTags: ["蛋糕", "冰淇淋", "芋圆", "麻薯", "草莓", "班戟"],
+ priceOptions: [
+ { label: "不限", value: "any" },
+ { label: "¥20以下", value: "under20" },
+ { label: "¥20-50", value: "20to50" },
+ { label: "¥50+", value: "over50" },
+ ],
+ tagLabel: "类型",
+ tagPlaceholder: "想吃什么甜品?(留空则不限)",
+ loadingText: "正在搜索周边甜品店...",
+ emptyError: "附近没有找到甜品店,试试扩大搜索范围",
+ subtitle: "和朋友一起滑卡片,选一家甜蜜蜜的甜品店",
+ inviteText: "有人邀请你一起选甜品店",
+ shareTitle: "别说随便啦,来滑卡片决定吃什么甜品!",
+ shareText: "我建好房间了,快点开链接一起选甜品店,滑中同一家就去吃!",
+ qrSubtitle: "让朋友扫码加入房间,一起滑卡片选甜品店",
},
};
-export const SCENES: SceneType[] = ["eat", "drink"];
+export const SCENES: SceneType[] = ["eat", "drinks", "dessert"];
-export function getSceneConfig(scene: SceneType): SceneConfig {
- return SCENE_CONFIGS[scene];
+export function getSceneConfig(scene: string): SceneConfig {
+ return SCENE_CONFIGS[scene as SceneType] ?? SCENE_CONFIGS.eat;
}
diff --git a/src/lib/shareImage.ts b/src/lib/shareImage.ts
new file mode 100644
index 0000000..9bb75a9
--- /dev/null
+++ b/src/lib/shareImage.ts
@@ -0,0 +1,52 @@
+import { toPng } from "html-to-image";
+
+export async function loadImageAsDataUrl(src: string): Promise {
+ try {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.src = src;
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject();
+ });
+ const canvas = document.createElement("canvas");
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ const ctx = canvas.getContext("2d")!;
+ ctx.drawImage(img, 0, 0);
+ return canvas.toDataURL("image/jpeg", 0.85);
+ } catch {
+ return null;
+ }
+}
+
+export async function generateImage(el: HTMLElement): Promise {
+ return toPng(el, {
+ pixelRatio: 2,
+ quality: 0.95,
+ cacheBust: true,
+ skipAutoScale: true,
+ filter: (node) => {
+ if (node instanceof HTMLElement && node.dataset.shareExclude === "true") {
+ return false;
+ }
+ return true;
+ },
+ });
+}
+
+export function downloadDataUrl(dataUrl: string, filename: string) {
+ const link = document.createElement("a");
+ link.download = filename;
+ link.href = dataUrl;
+ link.click();
+}
+
+export function dataUrlToFile(dataUrl: string, filename: string): File {
+ const arr = dataUrl.split(",");
+ const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
+ const bstr = atob(arr[1]);
+ const u8 = new Uint8Array(bstr.length);
+ for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i);
+ return new File([u8], filename, { type: mime });
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index cfb8f1f..8e2d775 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -1,4 +1,5 @@
import { prisma } from "./prisma";
+import { Prisma } from "@prisma/client";
import { Restaurant, SceneType } from "@/types";
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -15,8 +16,14 @@ export interface RoomData {
scene: SceneType;
}
+const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
function generateRoomId(): string {
- return String(Math.floor(1000 + Math.random() * 9000));
+ let id = "";
+ for (let i = 0; i < 6; i++) {
+ id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
+ }
+ return id;
}
function normalize(raw: Partial): RoomData {
@@ -69,26 +76,24 @@ export async function createRoom(restaurants: Restaurant[], creatorId: string, s
};
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
- let roomId: string;
- let attempts = 0;
+ const payload = JSON.stringify(data);
- while (attempts < 20) {
- roomId = generateRoomId();
- const existing = await prisma.room.findUnique({ where: { id: roomId } });
- if (!existing) {
+ for (let attempts = 0; attempts < 20; attempts++) {
+ const roomId = generateRoomId();
+ try {
await prisma.room.create({
- data: { id: roomId, data: JSON.stringify(data), expiresAt },
+ data: { id: roomId, data: payload, expiresAt },
});
return roomId;
+ } catch (e) {
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
+ continue;
+ }
+ throw e;
}
- attempts++;
}
- roomId = generateRoomId() + String(Date.now()).slice(-2);
- await prisma.room.create({
- data: { id: roomId, data: JSON.stringify(data), expiresAt },
- });
- return roomId;
+ throw new Error("无法生成唯一房间号,请稍后重试");
}
export async function getRoomData(
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..d0723a9
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,44 @@
+export type Theme = "light" | "dark" | "system";
+
+const STORAGE_KEY = "nowhatever-theme";
+const VALID_THEMES: Theme[] = ["light", "dark", "system"];
+
+export function getStoredTheme(): Theme {
+ if (typeof window === "undefined") return "system";
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return stored && VALID_THEMES.includes(stored as Theme)
+ ? (stored as Theme)
+ : "system";
+}
+
+export function setStoredTheme(theme: Theme): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(STORAGE_KEY, theme);
+ applyTheme(theme);
+}
+
+function resolveTheme(theme: Theme): "light" | "dark" {
+ if (theme !== "system") return theme;
+ if (typeof window === "undefined") return "dark";
+ return window.matchMedia("(prefers-color-scheme: light)").matches
+ ? "light"
+ : "dark";
+}
+
+export function applyTheme(theme: Theme): void {
+ if (typeof window === "undefined") return;
+ const resolved = resolveTheme(theme);
+ document.documentElement.setAttribute("data-theme", resolved);
+}
+
+export function initTheme(): void {
+ if (typeof window === "undefined") return;
+ const theme = getStoredTheme();
+ applyTheme(theme);
+
+ if (theme === "system") {
+ window
+ .matchMedia("(prefers-color-scheme: light)")
+ .addEventListener("change", () => applyTheme("system"));
+ }
+}
diff --git a/src/lib/userId.ts b/src/lib/userId.ts
index b41ddc3..90a77dd 100644
--- a/src/lib/userId.ts
+++ b/src/lib/userId.ts
@@ -31,6 +31,7 @@ export function setCachedProfile(profile: UserProfile | null): void {
} else {
localStorage.removeItem(PROFILE_KEY);
}
+ window.dispatchEvent(new CustomEvent("nowhatever_auth"));
}
export function isRegistered(): boolean {
@@ -60,4 +61,5 @@ export function logout(): void {
localStorage.removeItem("nowhatever_preferences");
const newId = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, newId);
+ window.dispatchEvent(new CustomEvent("nowhatever_auth"));
}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
new file mode 100644
index 0000000..0c9082b
--- /dev/null
+++ b/src/lib/validation.ts
@@ -0,0 +1,50 @@
+import { ApiError } from "@/lib/api";
+
+export function validateUsername(raw: string): string {
+ const trimmed = raw.trim();
+ if (trimmed.length < 2 || trimmed.length > 16) {
+ throw new ApiError("用户名需要 2-16 个字符");
+ }
+ return trimmed;
+}
+
+export function validatePassword(password: string, label = "密码"): void {
+ if (password.length < 6) {
+ throw new ApiError(`${label}至少 6 个字符`);
+ }
+ if (password.length > 128) {
+ throw new ApiError(`${label}不能超过 128 个字符`);
+ }
+}
+
+export function validateEmail(email: string): void {
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ throw new ApiError("邮箱格式不正确");
+ }
+}
+
+export function validateIdeaContent(raw: unknown): string {
+ if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
+ throw new ApiError("内容不能为空");
+ }
+ const trimmed = raw.trim();
+ if (trimmed.length > 200) {
+ throw new ApiError("内容不能超过 200 字");
+ }
+ return trimmed;
+}
+
+export function validateRoomName(raw: unknown, fallback = "我们的周末"): string {
+ const trimmed = ((raw as string) || "").trim() || fallback;
+ if (trimmed.length > 30) {
+ throw new ApiError("房间名不能超过 30 个字");
+ }
+ return trimmed;
+}
+
+export function requireString(value: unknown, fieldName: string): string {
+ if (!value || typeof value !== "string" || !value.trim()) {
+ throw new ApiError(`${fieldName}不能为空`);
+ }
+ return value;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 76a6b20..4860caa 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,4 +1,4 @@
-export type SceneType = "eat" | "drink";
+export type SceneType = "eat" | "drinks" | "dessert";
export interface Restaurant {
id: string;
@@ -49,6 +49,7 @@ export interface UserProfile {
export interface UserPreferences {
cuisine?: string;
+ cuisines?: string[];
priceRange?: string;
radius?: number;
}
@@ -68,3 +69,58 @@ export interface FavoriteRecord {
restaurantData: Restaurant;
createdAt: string;
}
+
+export type IdeaCategory =
+ | "dining"
+ | "outdoor"
+ | "entertainment"
+ | "shopping"
+ | "sports"
+ | "culture"
+ | "relaxation";
+
+export type IdeaTimeSlot =
+ | "morning"
+ | "afternoon"
+ | "evening"
+ | "flexible"
+ | "all_day";
+
+export type IdeaSearchType = "brand" | "place" | "category";
+
+export interface IdeaTags {
+ category: IdeaCategory;
+ timeSlot: IdeaTimeSlot;
+ estimatedMinutes: number;
+ outdoor: boolean;
+ searchQuery: string;
+ searchType: IdeaSearchType;
+}
+
+export interface PlanItem {
+ time: string;
+ activity: string;
+ poi: string;
+ address: string;
+ lat: number;
+ lng: number;
+ duration: number;
+ reason: string;
+}
+
+export interface WeekendPlanData {
+ date: string;
+ items: PlanItem[];
+ summary: string;
+}
+
+export interface ContractRecord {
+ id: string;
+ status: "completed" | "expired";
+ roomName: string;
+ roomCode: string;
+ date: string;
+ dayCount: number;
+ activities: string[];
+ createdAt: string;
+}