151 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|