EventBuzz_Website/src/components/AudienceQuestionCard.tsx

151 lines
5.1 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { ThumbsDown, ThumbsUp, User } from "lucide-react";
import { postVisitorSessionQuestionVote } from "../lib/api";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import {
getOrCreateAudienceVoteKey,
readStoredQuestionVote,
writeStoredQuestionVote,
} from "../lib/audienceVoteKey";
export type AudienceQuestionItem = {
_id?: string;
author: string;
text: string;
up: number;
down: number;
};
export type AudienceQuestionVoteContext = {
eventId: string;
sessionId: string;
};
export function AudienceQuestionCard({
item,
variant = "default",
voteContext,
}: {
item: AudienceQuestionItem;
variant?: "default" | "embedded";
/** When set and `item._id` is present, up/down votes call the visitor API and sync to RTDB */
voteContext?: AudienceQuestionVoteContext;
}) {
const rawId = item._id;
const qid =
rawId == null || rawId === ""
? ""
: typeof rawId === "string"
? rawId.trim()
: String(rawId).trim();
const canVote = Boolean(voteContext && qid);
const [userVote, setUserVote] = useState<"up" | "down" | null>(() =>
qid ? readStoredQuestionVote(qid) : null,
);
const [voting, setVoting] = useState(false);
const [voteError, setVoteError] = useState<string | null>(null);
useEffect(() => {
setUserVote(qid ? readStoredQuestionVote(qid) : null);
}, [qid]);
const shell =
variant === "embedded"
? "flex flex-col gap-4 p-4"
: "flex flex-col gap-4 rounded-md border border-[#f2f2f7] bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]";
const doVote = useCallback(
async (direction: "up" | "down") => {
if (!canVote || !voteContext || voting) return;
if (userVote === direction) return;
setVoting(true);
setVoteError(null);
try {
const voterKey = getOrCreateAudienceVoteKey();
const res = await postVisitorSessionQuestionVote(
voteContext.sessionId,
voteContext.eventId,
qid,
{ voterKey, direction },
);
setUserVote(res.yourVote);
writeStoredQuestionVote(qid, res.yourVote);
notifyRewardFromApiData(res);
} catch (e) {
setVoteError(e instanceof Error ? e.message : "Vote failed");
} finally {
setVoting(false);
}
},
[canVote, voteContext, qid, userVote, voting],
);
const upBtn = (
<button
type="button"
disabled={!canVote || voting}
onClick={() => void doVote("up")}
className={`flex items-center gap-2 rounded-full border-2 px-2 py-2 transition ${
userVote === "up"
? "border-[#34c759] bg-[#34c759]/10 text-[#34c759]"
: "border-[#34c759] bg-white text-[#34c759] hover:bg-[#34c759]/5"
} disabled:cursor-not-allowed disabled:opacity-45`}
aria-label={canVote ? "Upvote" : undefined}
>
<ThumbsUp className="size-4 shrink-0" strokeWidth={2} />
<span className="text-[10px] font-medium tracking-wide">{item.up}</span>
</button>
);
const downBtn = (
<button
type="button"
disabled={!canVote || voting}
onClick={() => void doVote("down")}
className={`flex items-center gap-2 rounded-full border-2 px-2 py-2 transition ${
userVote === "down"
? "border-[#ff3b30] bg-[#ff3b30]/10 text-[#ff3b30]"
: "border-[#ff3b30] bg-white text-[#ff3b30] hover:bg-[#ff3b30]/5"
} disabled:cursor-not-allowed disabled:opacity-45`}
aria-label={canVote ? "Downvote" : undefined}
>
<span className="text-[10px] font-medium tracking-wide">{item.down}</span>
<ThumbsDown className="size-4 shrink-0" strokeWidth={2} />
</button>
);
return (
<div className={shell}>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-[#f2f2f7]">
<User className="size-4 text-[#24282f]/70" strokeWidth={2} />
</div>
<span className="text-base font-semibold tracking-wide text-[#24282f]">{item.author}</span>
</div>
<p className="text-base leading-[22px] tracking-wide text-[#24282f]">{item.text}</p>
<div className="flex flex-col items-end gap-1">
<div className="flex justify-end gap-2">
{canVote ? (
upBtn
) : (
<div className="flex items-center gap-2 rounded-full border-2 border-[#34c759] bg-white px-2 py-2 text-[#34c759]">
<ThumbsUp className="size-4" strokeWidth={2} />
<span className="text-[10px] font-medium tracking-wide">{item.up}</span>
</div>
)}
{canVote ? (
downBtn
) : (
<div className="flex items-center gap-2 rounded-full border-2 border-[#ff3b30] bg-white px-2 py-2 text-[#ff3b30]">
<span className="text-[10px] font-medium tracking-wide">{item.down}</span>
<ThumbsDown className="size-4" strokeWidth={2} />
</div>
)}
</div>
{voteError ? <p className="max-w-full text-right text-xs text-red-600">{voteError}</p> : null}
</div>
</div>
);
}