diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx
index 33819b8..4f02594 100644
--- a/src/app/blindbox/[code]/page.tsx
+++ b/src/app/blindbox/[code]/page.tsx
@@ -23,6 +23,8 @@ import {
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
+import Toast from "@/components/Toast";
+import { useToast } from "@/hooks/useToast";
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
@@ -182,7 +184,7 @@ export default function BlindboxRoomPage() {
const [error, setError] = useState("");
const [showInvite, setShowInvite] = useState(false);
const [showShareCard, setShowShareCard] = useState(false);
- const [toast, setToast] = useState("");
+ const toast = useToast();
const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false);
@@ -314,8 +316,7 @@ export default function BlindboxRoomPage() {
}
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
} catch (e) {
- setToast(e instanceof Error ? e.message : "编辑失败");
- setTimeout(() => setToast(""), 2200);
+ toast.show(e instanceof Error ? e.message : "编辑失败");
}
}, [profile]);
@@ -334,8 +335,7 @@ export default function BlindboxRoomPage() {
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
setPoolCount((c) => Math.max(0, c - 1));
} catch (e) {
- setToast(e instanceof Error ? e.message : "删除失败");
- setTimeout(() => setToast(""), 2200);
+ toast.show(e instanceof Error ? e.message : "删除失败");
}
}, [profile]);
@@ -395,8 +395,7 @@ export default function BlindboxRoomPage() {
if (!room) return;
try {
await navigator.clipboard.writeText(room.code);
- setToast("房间号已复制");
- setTimeout(() => setToast(""), 2000);
+ toast.show("房间号已复制");
} catch { /* ignore */ }
};
@@ -441,8 +440,7 @@ export default function BlindboxRoomPage() {
}
router.replace("/blindbox");
} catch (e) {
- setToast(e instanceof Error ? e.message : "操作失败");
- setTimeout(() => setToast(""), 2200);
+ toast.show(e instanceof Error ? e.message : "操作失败");
setConfirmLeave(false);
} finally {
setLeaving(false);
@@ -817,10 +815,7 @@ export default function BlindboxRoomPage() {
drawer: revealedIdea.drawnBy ?? undefined,
roomName: room.name,
}}
- onToast={(msg) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }}
+ onToast={toast.show}
/>
)}
@@ -855,19 +850,7 @@ export default function BlindboxRoomPage() {
)}
- {/* Toast */}
-
- {toast && (
-
- {toast}
-
- )}
-
+
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index 28ca239..9d17bbd 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -24,6 +24,8 @@ import {
Heart,
} from "lucide-react";
import EmptyState from "@/components/EmptyState";
+import Toast from "@/components/Toast";
+import { useToast } from "@/hooks/useToast";
import RestaurantImage from "@/components/RestaurantImage";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
@@ -68,12 +70,7 @@ export default function ProfilePage() {
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();
@@ -141,7 +138,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 ?? "更新失败");
}
@@ -180,7 +177,7 @@ export default function ProfilePage() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
- showToast("密码已更新");
+ toast.show("密码已更新");
} else {
setPasswordMsg(data.error ?? "更新失败");
}
@@ -202,10 +199,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("更新失败");
}
};
@@ -244,9 +241,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("操作失败");
}
};
@@ -714,19 +711,7 @@ export default function ProfilePage() {
-
- {toast && (
-
- {toast}
-
- )}
-
+
);
}
diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx
index a736f6c..9c7c99d 100644
--- a/src/components/MatchResult.tsx
+++ b/src/components/MatchResult.tsx
@@ -34,6 +34,8 @@ import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal";
+import Toast from "@/components/Toast";
+import { useToast } from "@/hooks/useToast";
interface MatchResultProps {
restaurant: Restaurant;
@@ -194,7 +196,7 @@ export default function MatchResult({
const router = useRouter();
const [showRunnerUps, setShowRunnerUps] = useState(false);
const [showShareCard, setShowShareCard] = useState(false);
- const [toast, setToast] = useState("");
+ const toast = useToast();
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
const isSolo = userCount <= 1;
@@ -204,11 +206,6 @@ export default function MatchResult({
const [registered, setRegistered] = useState(() => isRegistered());
const [showAuth, setShowAuth] = useState(false);
- const showToast = useCallback((msg: string) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }, []);
-
useEffect(() => {
if (isUnanimous && !celebratedRef.current) {
const timer = setTimeout(() => {
@@ -247,9 +244,9 @@ export default function MatchResult({
(profile: UserProfile) => {
setRegistered(true);
setShowAuth(false);
- showToast(`欢迎,${profile.username}!记录已保存`);
+ toast.show(`欢迎,${profile.username}!记录已保存`);
},
- [showToast],
+ [toast],
);
const handleFavorite = useCallback(async () => {
@@ -263,13 +260,13 @@ export default function MatchResult({
});
if (res.ok) {
setFavorited(true);
- showToast("已收藏");
+ toast.show("已收藏");
}
} catch {
/* ignore */
}
setFavLoading(false);
- }, [registered, userId, restaurant, favorited, favLoading, showToast]);
+ }, [registered, userId, restaurant, favorited, favLoading, toast]);
if (matchType === "no_match") {
return ;
@@ -635,22 +632,10 @@ export default function MatchResult({
userCount,
scene,
}}
- onToast={showToast}
+ onToast={toast.show}
/>
-
- {toast && (
-
- {toast}
-
- )}
-
+
);
}
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/TopNav.tsx b/src/components/TopNav.tsx
index e078c08..edccb5f 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -1,12 +1,13 @@
"use client";
-import { useState, useCallback } from "react";
+import { useState } from "react";
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
-import { motion, AnimatePresence } from "framer-motion";
import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal";
+import Toast from "@/components/Toast";
import type { UserProfile, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
+import { useToast } from "@/hooks/useToast";
interface TopNavProps {
roomId: string;
@@ -36,15 +37,10 @@ export default function TopNav({
scene = "eat",
}: TopNavProps) {
const sceneConfig = getSceneConfig(scene);
- const [toast, setToast] = useState("");
+ const toast = useToast();
const [showQr, setShowQr] = useState(false);
const [showManage, setShowManage] = useState(false);
- const showToast = useCallback((msg: string) => {
- setToast(msg);
- setTimeout(() => setToast(""), 2200);
- }, []);
-
return (
<>
-
- {toast && (
-
- {toast}
-
- )}
-
+
setShowQr(false)}
roomId={roomId}
- onToast={showToast}
+ onToast={toast.show}
scene={scene}
/>
@@ -116,7 +100,7 @@ export default function TopNav({
swipeCounts={swipeCounts}
totalCards={totalCards}
userProfiles={userProfiles}
- onToast={showToast}
+ onToast={toast.show}
/>
)}
>
diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts
new file mode 100644
index 0000000..16abab4
--- /dev/null
+++ b/src/hooks/useToast.ts
@@ -0,0 +1,17 @@
+import { useState, useCallback, useRef } from "react";
+
+export function useToast(duration = 2200) {
+ const [message, setMessage] = useState("");
+ const timerRef = useRef>(undefined);
+
+ const show = useCallback(
+ (msg: string) => {
+ clearTimeout(timerRef.current);
+ setMessage(msg);
+ timerRef.current = setTimeout(() => setMessage(""), duration);
+ },
+ [duration],
+ );
+
+ return { message, show } as const;
+}