UI-UX/src/hooks/useVoteAction.ts
iye d5ed43acbd feat(ui): design overhaul, global login modal, design spec
- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing
- auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect
- home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates
- home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav)
- filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button
- ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically
- typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved
- layout: max-w-[1500px] unified across pages
- docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:59:30 +08:00

100 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback } from "react";
import { useSession } from "next-auth/react";
import toast from "react-hot-toast";
import {
useVoteStore,
selectRemaining,
DAILY_VOTE_QUOTA,
} from "@/lib/store";
import { useLoginModalStore } from "@/lib/login-modal-store";
import type { Artist } from "@/types/artist";
interface UseVoteActionResult {
/** 当前投票目标艺人null 时弹窗关闭) */
target: Artist | null;
/** 今日剩余票数 */
remaining: number;
/** 每日总额度(常量,供 UI 文案展示) */
dailyQuota: number;
/** 触发投票(自动检查登录态 + 额度) */
openVote: (artist: Artist) => void;
/** 关闭投票弹窗 */
closeVote: () => void;
/** 确认投票(已登录态下调用) */
confirmVote: (artist: Artist, count: number) => Promise<void>;
}
/**
* 投票交互统一入口。
*
* 规则:
* - 每用户每日总额度 = 10 票,跨艺人共享。无单艺人上限。
* - 未登录 → toast 提示并跳登录页
* - 已登录但当日票数已用完 → toast 提示,不打开弹窗
* - 弹窗确认后:本地 store 立即扣减 + 调用后端 APIfire-and-forget
*/
export function useVoteAction(): UseVoteActionResult {
const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote);
const remaining = useVoteStore(selectRemaining);
const openLogin = useLoginModalStore((s) => s.show);
const [target, setTarget] = useState<Artist | null>(null);
const openVote = useCallback(
(artist: Artist) => {
if (status === "loading") return;
if (status === "unauthenticated") {
toast("请先登录后再为偶像投票");
setTimeout(openLogin, 350);
return;
}
if (remaining <= 0) {
toast("今日票数已用完,明天再来吧");
return;
}
setTarget(artist);
},
[status, openLogin, remaining],
);
const closeVote = useCallback(() => setTarget(null), []);
const confirmVote = useCallback(
async (artist: Artist, count: number) => {
// 1. 本地 store 立即扣减(包含额度校验)
const success = recordVote(artist.id, count);
if (!success) {
toast.error("今日票数不足");
setTarget(null);
return;
}
toast.success(`已为 ${artist.name} 投出 ${count}`);
setTarget(null);
// 2. 后台 fire-and-forget 调用真实 API5 秒超时,失败静默忽略)
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artistId: artist.id, count }),
signal: ctrl.signal,
})
.catch(() => {})
.finally(() => clearTimeout(timer));
},
[recordVote],
);
return {
target,
remaining,
dailyQuota: DAILY_VOTE_QUOTA,
openVote,
closeVote,
confirmVote,
};
}