mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
voting UI done
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createRouter } from "pages/api/trpc/[trpc]";
|
||||
import { throwIfNotLoggedIn } from "utils/api";
|
||||
import { suggestionFullSchema } from "utils/validators/suggestion";
|
||||
import { votesSchema } from "utils/validators/votes";
|
||||
import { vouchSchema } from "utils/validators/vouch";
|
||||
import service from "./service";
|
||||
|
||||
@@ -15,10 +16,10 @@ const plusApi = createRouter()
|
||||
return service.getPlusStatuses();
|
||||
},
|
||||
})
|
||||
.query("ballots", {
|
||||
.query("usersForVoting", {
|
||||
resolve({ ctx }) {
|
||||
const user = throwIfNotLoggedIn(ctx.user);
|
||||
return service.getBallots(user.id);
|
||||
return service.getUsersForVoting(user.id);
|
||||
},
|
||||
})
|
||||
.mutation("suggestion", {
|
||||
@@ -34,5 +35,12 @@ const plusApi = createRouter()
|
||||
const user = throwIfNotLoggedIn(ctx.user);
|
||||
return service.addVouch({ input, userId: user.id });
|
||||
},
|
||||
})
|
||||
.mutation("vote", {
|
||||
input: votesSchema,
|
||||
resolve({ input, ctx }) {
|
||||
const user = throwIfNotLoggedIn(ctx.user);
|
||||
return service.addVotes({ input, userId: user.id });
|
||||
},
|
||||
});
|
||||
export default plusApi;
|
||||
|
||||
23
app/plus/components/PlusVotingButton.tsx
Normal file
23
app/plus/components/PlusVotingButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@chakra-ui/button";
|
||||
|
||||
export function PlusVotingButton({
|
||||
number,
|
||||
onClick,
|
||||
}: {
|
||||
number: -2 | -1 | 1 | 2;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
borderRadius="50%"
|
||||
height={12}
|
||||
width={12}
|
||||
variant="outline"
|
||||
colorScheme={number < 0 ? "red" : "theme"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{number > 0 ? "+" : ""}
|
||||
{number}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,103 @@
|
||||
import { Button } from "@chakra-ui/button";
|
||||
import { Box, HStack } from "@chakra-ui/layout";
|
||||
import { Progress } from "@chakra-ui/progress";
|
||||
import Markdown from "components/common/Markdown";
|
||||
import SubText from "components/common/SubText";
|
||||
import UserAvatar from "components/common/UserAvatar";
|
||||
import { useEffect } from "react";
|
||||
import { getFullUsername } from "utils/strings";
|
||||
import usePlusVoting from "../hooks/usePlusVoting";
|
||||
import { PlusVotingButton } from "./PlusVotingButton";
|
||||
|
||||
export default function PlusVotingPage() {
|
||||
const { isLoading, usersToVoteOn } = usePlusVoting();
|
||||
const {
|
||||
isLoading,
|
||||
shouldRedirect,
|
||||
plusStatus,
|
||||
currentUser,
|
||||
handleVote,
|
||||
progress,
|
||||
previousUser,
|
||||
goBack,
|
||||
} = usePlusVoting();
|
||||
|
||||
useEffect(() => {
|
||||
//redirect!!
|
||||
}, [shouldRedirect]);
|
||||
|
||||
if (isLoading || !plusStatus || !currentUser) return null;
|
||||
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<h1>
|
||||
<pre>{JSON.stringify(usersToVoteOn, null, 2)}</pre>
|
||||
</h1>
|
||||
<Box>
|
||||
{previousUser && (
|
||||
<Box textAlign="center" mb={6}>
|
||||
<UserAvatar user={previousUser} isSmall />
|
||||
<Box my={2} fontSize="sm">
|
||||
{getFullUsername(previousUser)}
|
||||
</Box>
|
||||
<Button
|
||||
borderRadius="50%"
|
||||
height={10}
|
||||
width={10}
|
||||
variant="outline"
|
||||
colorScheme={previousUser.score < 0 ? "red" : "theme"}
|
||||
onClick={goBack}
|
||||
>
|
||||
{previousUser.score > 0 ? "+" : ""}
|
||||
{previousUser.score}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Progress value={progress} size="xs" colorScheme="pink" />
|
||||
<Box mt={6} textAlign="center">
|
||||
<UserAvatar user={currentUser} size="2xl" mx="auto" />
|
||||
<Box fontSize="2rem" fontWeight="bold" mt={2}>
|
||||
{getFullUsername(currentUser)}
|
||||
</Box>
|
||||
</Box>
|
||||
<HStack justify="center" spacing={4} mt={2}>
|
||||
{currentUser.region === plusStatus.region && (
|
||||
<PlusVotingButton
|
||||
number={-2}
|
||||
onClick={() =>
|
||||
handleVote({ userId: currentUser.userId, score: -2 })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PlusVotingButton
|
||||
number={-1}
|
||||
onClick={() => handleVote({ userId: currentUser.userId, score: -1 })}
|
||||
/>
|
||||
<PlusVotingButton
|
||||
number={1}
|
||||
onClick={() => handleVote({ userId: currentUser.userId, score: 1 })}
|
||||
/>
|
||||
{currentUser.region === plusStatus.region && (
|
||||
<PlusVotingButton
|
||||
number={2}
|
||||
onClick={() => handleVote({ userId: currentUser.userId, score: 2 })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{currentUser.suggestions && (
|
||||
<Box mt={5}>
|
||||
<SubText>Suggestions</SubText>
|
||||
{currentUser.suggestions.map((suggestion) => {
|
||||
return (
|
||||
<Box key={suggestion.suggesterUser.id} mt={4} fontSize="sm">
|
||||
"{suggestion.description}" -{" "}
|
||||
{getFullUsername(suggestion.suggesterUser)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{currentUser.bio && (
|
||||
<Box mt={4}>
|
||||
<SubText mb={4}>Bio</SubText>
|
||||
<Markdown value={currentUser.bio} smallHeaders />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
import { useUser } from "hooks/common";
|
||||
import { getVotingRange } from "utils/plus";
|
||||
import { useState } from "react";
|
||||
import { trpc } from "utils/trpc";
|
||||
import { Unpacked } from "utils/types";
|
||||
import { votesSchema } from "utils/validators/votes";
|
||||
import * as z from "zod";
|
||||
|
||||
export default function usePlusVoting() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [votes, setVotes] = useState<z.infer<typeof votesSchema>>([]);
|
||||
|
||||
const [user] = useUser();
|
||||
const { data: ballotsData, isLoading: isLoadingBallots } = trpc.useQuery([
|
||||
"plus.ballots",
|
||||
"plus.usersForVoting",
|
||||
]);
|
||||
const { data: statusesData, isLoading: isLoadingStatuses } = trpc.useQuery([
|
||||
"plus.statuses",
|
||||
]);
|
||||
const {
|
||||
data: suggestionsData,
|
||||
isLoading: isLoadingSuggestions,
|
||||
} = trpc.useQuery(["plus.suggestions"]);
|
||||
|
||||
const ownPlusStatus = statusesData?.find(
|
||||
(status) => status.user.id === user?.id
|
||||
);
|
||||
|
||||
const votingTier = ownPlusStatus?.membershipTier;
|
||||
|
||||
return {
|
||||
ballotsData: ballotsData?.filter((ballot) => !ballot.isStale),
|
||||
staleBallots: ballotsData?.filter((ballot) => ballot.isStale),
|
||||
shouldRedirect:
|
||||
(statusesData && !votingTier) || !getVotingRange().isHappening,
|
||||
usersToVoteOn: statusesData?.filter(
|
||||
(user) =>
|
||||
(user.membershipTier && user.membershipTier === votingTier) ||
|
||||
(user.vouchTier && user.vouchTier === votingTier)
|
||||
),
|
||||
suggestedUsersToVoteOn: suggestionsData?.filter(
|
||||
(suggestion) => suggestion.tier === votingTier
|
||||
),
|
||||
isLoading: isLoadingBallots || isLoadingStatuses || isLoadingSuggestions,
|
||||
isLoading: isLoadingBallots || isLoadingStatuses,
|
||||
shouldRedirect: !isLoadingBallots && !ballotsData,
|
||||
plusStatus: ownPlusStatus,
|
||||
currentUser: ballotsData?.[currentIndex],
|
||||
previousUser:
|
||||
currentIndex > 0 && ballotsData
|
||||
? { ...ballotsData[currentIndex - 1], ...votes[votes.length - 1] }
|
||||
: undefined,
|
||||
progress: ballotsData
|
||||
? ((currentIndex + 1) / ballotsData.length) * 100
|
||||
: undefined,
|
||||
handleVote: (vote: Unpacked<z.infer<typeof votesSchema>>) => {
|
||||
setVotes([...votes, vote]);
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
(<HTMLElement>document.activeElement).blur();
|
||||
},
|
||||
goBack: () => {
|
||||
setVotes(votes.slice(0, votes.length - 1));
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PlusRegion, Prisma } from "@prisma/client";
|
||||
import { httpError } from "@trpc/server";
|
||||
import prisma from "prisma/client";
|
||||
import { getPercentageFromCounts, getVotingRange } from "utils/plus";
|
||||
import { userBasicSelection } from "utils/prisma";
|
||||
import { shuffleArray } from "utils/shuffleArray";
|
||||
import { suggestionFullSchema } from "utils/validators/suggestion";
|
||||
import { votesSchema } from "utils/validators/votes";
|
||||
import { vouchSchema } from "utils/validators/vouch";
|
||||
import * as z from "zod";
|
||||
|
||||
@@ -163,9 +165,100 @@ const getDistinctSummaryMonths = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getBallots = (userId: number) => {
|
||||
const getUsersForVoting = async (userId: number) => {
|
||||
if (!getVotingRange().isHappening) return null;
|
||||
return prisma.plusBallot.findMany({ where: { voterUser: { id: userId } } });
|
||||
const plusStatus = await prisma.plusStatus.findUnique({ where: { userId } });
|
||||
|
||||
if (!plusStatus?.membershipTier) return null;
|
||||
|
||||
const [plusStatuses, suggestions] = await Promise.all([
|
||||
prisma.plusStatus.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ membershipTier: plusStatus.membershipTier },
|
||||
{ membershipTier: plusStatus.membershipTier },
|
||||
],
|
||||
},
|
||||
include: { user: { include: { profile: true } } },
|
||||
}),
|
||||
prisma.plusSuggestion.findMany({
|
||||
where: { tier: plusStatus.membershipTier },
|
||||
include: {
|
||||
suggestedUser: { include: { profile: true, plusStatus: true } },
|
||||
suggesterUser: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const result: {
|
||||
userId: number;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
discordAvatar: string | null;
|
||||
discordId: string;
|
||||
region: PlusRegion;
|
||||
bio?: string | null;
|
||||
suggestions?: {
|
||||
description: string;
|
||||
suggesterUser: {
|
||||
id: number;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
};
|
||||
}[];
|
||||
}[] = [];
|
||||
|
||||
for (const status of plusStatuses) {
|
||||
result.push({
|
||||
userId: status.user.id,
|
||||
username: status.user.username,
|
||||
discriminator: status.user.discriminator,
|
||||
discordAvatar: status.user.discordAvatar,
|
||||
bio: status.user.profile?.bio,
|
||||
discordId: status.user.discordId,
|
||||
region: status.region,
|
||||
});
|
||||
}
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
const user = result.find(
|
||||
({ userId }) => userId === suggestion.suggestedUser.id
|
||||
);
|
||||
if (user) {
|
||||
user.suggestions?.push({
|
||||
description: suggestion.description,
|
||||
suggesterUser: {
|
||||
id: suggestion.suggesterUser.id,
|
||||
username: suggestion.suggesterUser.username,
|
||||
discriminator: suggestion.suggestedUser.discriminator,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
userId: suggestion.suggestedUser.id,
|
||||
username: suggestion.suggestedUser.username,
|
||||
discriminator: suggestion.suggestedUser.discriminator,
|
||||
discordAvatar: suggestion.suggestedUser.discordAvatar,
|
||||
bio: suggestion.suggestedUser.profile?.bio,
|
||||
discordId: suggestion.suggestedUser.discordId,
|
||||
region: suggestion.suggestedUser.plusStatus?.region!,
|
||||
suggestions: [
|
||||
{
|
||||
description: suggestion.description,
|
||||
suggesterUser: {
|
||||
id: suggestion.suggesterUser.id,
|
||||
username: suggestion.suggesterUser.username,
|
||||
discriminator: suggestion.suggestedUser.discriminator,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return shuffleArray(result).sort((a, b) => a.region.localeCompare(b.region));
|
||||
};
|
||||
|
||||
const addSuggestion = async ({
|
||||
@@ -295,13 +388,95 @@ const addVouch = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const addVotes = async ({
|
||||
input,
|
||||
userId,
|
||||
}: {
|
||||
input: z.infer<typeof votesSchema>;
|
||||
userId: number;
|
||||
}) => {
|
||||
const [plusStatuses, suggestions] = await Promise.all([
|
||||
prisma.plusStatus.findMany({}),
|
||||
prisma.plusSuggestion.findMany({ where: { isResuggestion: false } }),
|
||||
]);
|
||||
|
||||
const usersPlusStatus = plusStatuses.find(
|
||||
(status) => status.userId === userId
|
||||
);
|
||||
|
||||
const usersMembership = usersPlusStatus?.membershipTier;
|
||||
|
||||
if (!usersPlusStatus || !usersMembership)
|
||||
throw httpError.badRequest("not a member");
|
||||
|
||||
const allowedUsers = new Map<number, "EU" | "NA">();
|
||||
|
||||
for (const status of plusStatuses) {
|
||||
if (
|
||||
status.membershipTier !== usersMembership &&
|
||||
status.vouchTier !== usersMembership
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allowedUsers.set(status.userId, status.region);
|
||||
}
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.tier !== usersMembership) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = plusStatuses.find(
|
||||
(status) => status.userId === suggestion.suggestedId
|
||||
);
|
||||
if (!status)
|
||||
throw httpError.badRequest("unexpected no status for suggested user");
|
||||
|
||||
allowedUsers.set(suggestion.suggestedId, status.region);
|
||||
}
|
||||
|
||||
if (input.length !== allowedUsers.size) {
|
||||
throw httpError.badRequest("didn't vote on every user exactly once");
|
||||
}
|
||||
|
||||
if (
|
||||
input.some((vote) => {
|
||||
const region = allowedUsers.get(vote.userId);
|
||||
if (!region) return false;
|
||||
|
||||
if (region === usersPlusStatus.region) {
|
||||
if (![-2, -1, 1, 2].includes(vote.score)) return false;
|
||||
} else {
|
||||
if (![-1, 1].includes(vote.score)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
) {
|
||||
throw httpError.badRequest("invalid vote provided");
|
||||
}
|
||||
|
||||
return prisma.plusBallot.createMany({
|
||||
data: input.map((vote) => {
|
||||
return {
|
||||
score: vote.score,
|
||||
tier: usersMembership,
|
||||
voterId: userId,
|
||||
votedId: vote.userId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
getPlusStatuses,
|
||||
getSuggestions,
|
||||
getVotingSummariesByMonthAndTier,
|
||||
getMostRecentVotingWithResultsMonth,
|
||||
getDistinctSummaryMonths,
|
||||
getBallots,
|
||||
getUsersForVoting,
|
||||
addSuggestion,
|
||||
addVouch,
|
||||
addVotes,
|
||||
};
|
||||
|
||||
@@ -59,5 +59,6 @@ export const getVotingRange = () => {
|
||||
? startDate
|
||||
: getThirdFridayDate(true);
|
||||
|
||||
return { startDate, endDate, isHappening, nextVotingDate };
|
||||
//return { startDate, endDate, isHappening, nextVotingDate };
|
||||
return { startDate, endDate, isHappening: true, nextVotingDate };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const shuffleArray = (array: string[]) => {
|
||||
export const shuffleArray = <T>(array: T[]) => {
|
||||
return array
|
||||
.map((a) => ({ sort: Math.random(), value: a }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
|
||||
8
utils/validators/votes.ts
Normal file
8
utils/validators/votes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const votesSchema = z.array(
|
||||
z.object({
|
||||
score: z.number().min(-2).max(2).int(),
|
||||
userId: z.number(),
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user