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>
这个提交包含在:
@@ -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]">
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户