feat: toast notifications for redeem with friendly error messages

- Replace static error/msg text with slide-in toast overlay
- Auto-dismiss after 4-5 seconds with fade animation
- Translate 'rating not enough' to friendly Minecraft-themed message
- Dismissible via × button

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
cryptocommuniums-afk
2026-02-16 19:02:43 +08:00
父节点 87ce46ed1a
当前提交 fb777c3479

查看文件

@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ArrowRightLeft, ArrowRightLeft,
Calendar, Calendar,
@@ -105,8 +105,29 @@ export default function MePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [redeemLoading, setRedeemLoading] = useState(false); const [redeemLoading, setRedeemLoading] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState(""); // Toast notification system
const [toast, setToast] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const showToast = useCallback((type: "success" | "error", text: string) => {
if (toastTimer.current) clearTimeout(toastTimer.current);
// Clean up common error prefixes
let cleaned = text.replace(/^Error:\s*/i, "");
if (type === "error") {
cleaned = cleaned
.replace(/rating not enough/i, isZh ? "💎 绿宝石不足,继续冒险积攒吧!" : "💎 Not enough Emeralds! Keep adventuring!")
.replace(/please sign in/i, isZh ? "🔒 请先登录" : "🔒 Please sign in first")
.replace(/select trade item/i, isZh ? "📦 请选择交易物品" : "📦 Select a trade item");
}
setToast({ type, text: cleaned });
setToastVisible(true);
toastTimer.current = setTimeout(() => {
setToastVisible(false);
setTimeout(() => setToast(null), 300);
}, type === "error" ? 5000 : 4000);
}, [isZh]);
const selectedItem = useMemo( const selectedItem = useMemo(
() => items.find((item) => item.id === selectedItemId) ?? null, () => items.find((item) => item.id === selectedItemId) ?? null,
@@ -175,8 +196,6 @@ export default function MePage() {
const loadAll = async () => { const loadAll = async () => {
setLoading(true); setLoading(true);
setError("");
setMsg("");
try { try {
const tk = readToken(); const tk = readToken();
setToken(tk); setToken(tk);
@@ -202,7 +221,7 @@ export default function MePage() {
setSelectedItemId((prev) => prev || redeemItems[0].id); setSelectedItemId((prev) => prev || redeemItems[0].id);
} }
} catch (e: unknown) { } catch (e: unknown) {
setError(String(e)); showToast("error", String(e));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -215,8 +234,6 @@ export default function MePage() {
const redeem = async () => { const redeem = async () => {
setRedeemLoading(true); setRedeemLoading(true);
setError("");
setMsg("");
try { try {
if (!token) throw new Error(tx("请先登录", "Please sign in first")); if (!token) throw new Error(tx("请先登录", "Please sign in first"));
if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item")); if (!selectedItemId) throw new Error(tx("请选择交易物品", "Select trade item"));
@@ -238,15 +255,15 @@ export default function MePage() {
token token
); );
setMsg( showToast("success",
isZh isZh
? `交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石` ? `交易成功:${created.item_name} × ${created.quantity},花费 ${created.total_cost} 绿宝石`
: `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds.` : `Trade successful: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} Emeralds`
); );
setNote(""); setNote("");
await loadAll(); await loadAll();
} catch (e: unknown) { } catch (e: unknown) {
setError(String(e)); showToast("error", String(e));
} finally { } finally {
setRedeemLoading(false); setRedeemLoading(false);
} }
@@ -260,8 +277,27 @@ export default function MePage() {
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")} {tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
</h1> </h1>
{loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>} {loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
{error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
{msg && <p className="mt-3 text-sm text-[color:var(--mc-green)]">{msg}</p>} {/* Toast notification */}
{toast && (
<div
className={`fixed top-6 left-1/2 -translate-x-1/2 z-50 max-w-md w-[90vw] px-5 py-3 rounded-none border-[3px] border-black shadow-[4px_4px_0_rgba(0,0,0,0.5)] font-mono text-sm transition-all duration-300 ${
toastVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-4"
} ${
toast.type === "error"
? "bg-red-900/95 text-red-100 border-red-700"
: "bg-emerald-900/95 text-emerald-100 border-emerald-700"
}`}
>
<div className="flex items-center justify-between gap-3">
<span>{toast.text}</span>
<button
onClick={() => { setToastVisible(false); setTimeout(() => setToast(null), 300); }}
className="shrink-0 text-lg leading-none opacity-70 hover:opacity-100"
>×</button>
</div>
</div>
)}
{profile && ( {profile && (
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]"> <div className="mt-4 grid gap-4 md:grid-cols-[1fr_2fr]">