mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
can vouch
This commit is contained in:
@@ -1,38 +1,93 @@
|
||||
import { Box, Center, Divider, Flex, Stack } from "@chakra-ui/layout";
|
||||
import { Radio, RadioGroup } from "@chakra-ui/react";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
chakra,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "@chakra-ui/react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import SubText from "components/common/SubText";
|
||||
import { useUser } from "hooks/common";
|
||||
import { usePlus } from "hooks/plus";
|
||||
import { getFullUsername } from "lib/strings";
|
||||
import { Fragment } from "react";
|
||||
import { Suggestions } from "services/plus";
|
||||
import { PlusStatuses, Suggestions } from "services/plus";
|
||||
import Suggestion from "./Suggestion";
|
||||
import SuggestionVouchModal from "./SuggestionVouchModal";
|
||||
import SuggestionModal from "./SuggestionModal";
|
||||
import VouchModal from "./VouchModal";
|
||||
|
||||
export interface PlusHomePageProps {
|
||||
suggestions: Suggestions;
|
||||
statuses: PlusStatuses;
|
||||
}
|
||||
|
||||
const PlusHomePage = ({ suggestions }: PlusHomePageProps) => {
|
||||
const PlusHomePage = ({ suggestions, statuses }: PlusHomePageProps) => {
|
||||
const [user] = useUser();
|
||||
const {
|
||||
plusStatusData,
|
||||
suggestionsData,
|
||||
ownSuggestion,
|
||||
suggestionsLoading,
|
||||
suggestionCounts,
|
||||
setSuggestionsFilter,
|
||||
} = usePlus(suggestions);
|
||||
vouchedPlusStatusData,
|
||||
} = usePlus({ suggestions, statuses });
|
||||
|
||||
return (
|
||||
<>
|
||||
{plusStatusData && plusStatusData.membershipTier && (
|
||||
<SuggestionVouchModal
|
||||
canSuggest={!suggestionsLoading && !ownSuggestion}
|
||||
canVouch={!!plusStatusData.canVouchFor}
|
||||
{plusStatusData && plusStatusData.membershipTier && !ownSuggestion && (
|
||||
<SuggestionModal
|
||||
userPlusMembershipTier={plusStatusData.membershipTier}
|
||||
/>
|
||||
)}
|
||||
{plusStatusData &&
|
||||
plusStatusData.canVouchFor &&
|
||||
!plusStatusData.canVouchAgainAfter && (
|
||||
<VouchModal canVouchFor={plusStatusData.canVouchFor} />
|
||||
)}
|
||||
{plusStatusData &&
|
||||
(plusStatusData.canVouchAgainAfter ||
|
||||
plusStatusData.voucher ||
|
||||
vouchedPlusStatusData) && (
|
||||
<Alert
|
||||
status="success"
|
||||
variant="subtle"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
mt={2}
|
||||
mb={6}
|
||||
rounded="lg"
|
||||
>
|
||||
<AlertDescription maxWidth="sm">
|
||||
<AlertTitle mb={1} fontSize="lg">
|
||||
Vouching status
|
||||
</AlertTitle>
|
||||
{plusStatusData?.canVouchAgainAfter && (
|
||||
<chakra.div>
|
||||
Can vouch again after:{" "}
|
||||
{new Date(
|
||||
plusStatusData.canVouchAgainAfter
|
||||
).toLocaleDateString()}
|
||||
</chakra.div>
|
||||
)}
|
||||
{plusStatusData?.voucher && (
|
||||
<chakra.div>
|
||||
Vouched for <b>+{plusStatusData.vouchTier}</b> by{" "}
|
||||
{getFullUsername(plusStatusData.voucher)}
|
||||
</chakra.div>
|
||||
)}
|
||||
{vouchedPlusStatusData && (
|
||||
<chakra.div>
|
||||
Vouched {getFullUsername(vouchedPlusStatusData.user)} to{" "}
|
||||
<b>+{vouchedPlusStatusData.vouchTier}</b>
|
||||
</chakra.div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Center mt={2}>
|
||||
<RadioGroup
|
||||
defaultValue="ALL"
|
||||
@@ -69,9 +124,8 @@ const PlusHomePage = ({ suggestions }: PlusHomePageProps) => {
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Center>
|
||||
{!suggestionsLoading &&
|
||||
suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
|
||||
0 ? (
|
||||
{suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
|
||||
0 ? (
|
||||
<Box mt={4}>No suggestions yet for this month</Box>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -26,18 +26,12 @@ import UserSelector from "components/common/UserSelector";
|
||||
import useMutation from "hooks/useMutation";
|
||||
|
||||
interface Props {
|
||||
canVouch: boolean;
|
||||
canSuggest: boolean;
|
||||
userPlusMembershipTier?: number;
|
||||
userPlusMembershipTier: number;
|
||||
}
|
||||
|
||||
type FormData = z.infer<typeof suggestionFullSchema>;
|
||||
|
||||
const SuggestionVouchModal: React.FC<Props> = ({
|
||||
canVouch,
|
||||
canSuggest,
|
||||
userPlusMembershipTier,
|
||||
}) => {
|
||||
const SuggestionModal: React.FC<Props> = ({ userPlusMembershipTier }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { handleSubmit, errors, register, watch, control } = useForm<FormData>({
|
||||
resolver: zodResolver(suggestionFullSchema),
|
||||
@@ -51,17 +45,6 @@ const SuggestionVouchModal: React.FC<Props> = ({
|
||||
|
||||
const watchDescription = watch("description", "");
|
||||
|
||||
if (!canVouch && !canSuggest) return null;
|
||||
|
||||
const getButtonText = () => {
|
||||
if (canSuggest && canVouch) return "Add new suggestion or vouch";
|
||||
if (canVouch) return "Vouch";
|
||||
|
||||
return "Add new suggestion";
|
||||
};
|
||||
|
||||
if (!userPlusMembershipTier) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -70,7 +53,7 @@ const SuggestionVouchModal: React.FC<Props> = ({
|
||||
onClick={() => setIsOpen(true)}
|
||||
data-cy="suggestion-button"
|
||||
>
|
||||
{getButtonText()}
|
||||
Add new suggestion
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
@@ -81,7 +64,7 @@ const SuggestionVouchModal: React.FC<Props> = ({
|
||||
>
|
||||
<ModalOverlay>
|
||||
<ModalContent>
|
||||
<ModalHeader>Adding a new suggestion or vouch</ModalHeader>
|
||||
<ModalHeader>Adding a new suggestion</ModalHeader>
|
||||
<ModalCloseButton borderRadius="50%" />
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalBody pb={2}>
|
||||
@@ -181,4 +164,4 @@ const SuggestionVouchModal: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestionVouchModal;
|
||||
export default SuggestionModal;
|
||||
140
components/plus/VouchModal.tsx
Normal file
140
components/plus/VouchModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
FormErrorMessage,
|
||||
Select,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { vouchSchema } from "lib/validators/vouch";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import UserSelector from "components/common/UserSelector";
|
||||
import useMutation from "hooks/useMutation";
|
||||
|
||||
interface Props {
|
||||
canVouchFor: number;
|
||||
}
|
||||
|
||||
type FormData = z.infer<typeof vouchSchema>;
|
||||
|
||||
const VouchModal: React.FC<Props> = ({ canVouchFor }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { handleSubmit, errors, register, control } = useForm<FormData>({
|
||||
resolver: zodResolver(vouchSchema),
|
||||
});
|
||||
const { onSubmit, sending } = useMutation({
|
||||
onSuccess: () => setIsOpen(false),
|
||||
route: "plus/vouch",
|
||||
mutationKey: "plus",
|
||||
successText: "Successfully vouched",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
mb={4}
|
||||
ml={2}
|
||||
onClick={() => setIsOpen(true)}
|
||||
data-cy="vouch-button"
|
||||
>
|
||||
Vouch
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
size="xl"
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<ModalOverlay>
|
||||
<ModalContent>
|
||||
<ModalHeader>Vouching</ModalHeader>
|
||||
<ModalCloseButton borderRadius="50%" />
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalBody pb={2}>
|
||||
<FormLabel>Tier</FormLabel>
|
||||
<Controller
|
||||
name="tier"
|
||||
control={control}
|
||||
defaultValue={canVouchFor}
|
||||
render={({ value, onChange }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
>
|
||||
{canVouchFor === 1 && <option value="1">+1</option>}
|
||||
{canVouchFor <= 2 && <option value="2">+2</option>}
|
||||
{false && <option value="3">+3</option>}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl isInvalid={!!errors.vouchedId}>
|
||||
<FormLabel mt={4}>User</FormLabel>
|
||||
<Controller
|
||||
name="vouchedId"
|
||||
control={control}
|
||||
render={({ value, onChange }) => (
|
||||
<UserSelector
|
||||
value={value}
|
||||
setValue={onChange}
|
||||
isMulti={false}
|
||||
maxMultiCount={undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.vouchedId?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel mt={4}>Region</FormLabel>
|
||||
<Select
|
||||
name="region"
|
||||
ref={register}
|
||||
data-cy="region-select"
|
||||
>
|
||||
<option value="NA">NA</option>
|
||||
<option value="EU">EU</option>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
If the player isn't from either region then choose the one
|
||||
they play most commonly with.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
mr={3}
|
||||
type="submit"
|
||||
isLoading={sending}
|
||||
data-cy="submit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VouchModal;
|
||||
@@ -59,4 +59,28 @@ context("Plus Home Page", () => {
|
||||
cy.contains('"yes agreed" - Sendou#4059');
|
||||
cy.dataCy("comment-button").should("not.exist");
|
||||
});
|
||||
|
||||
it("can add vouch", () => {
|
||||
cy.login("sendou");
|
||||
cy.visit("/plus");
|
||||
cy.dataCy("vouch-button")
|
||||
.click()
|
||||
.get(".select__value-container")
|
||||
.type("NZAP{enter}")
|
||||
.dataCy("region-select")
|
||||
.select("EU")
|
||||
.dataCy("submit-button")
|
||||
.click();
|
||||
|
||||
cy.dataCy("vouch-button").should("not.exist");
|
||||
cy.contains("Vouched NZAP#6227");
|
||||
});
|
||||
|
||||
it.only("can't vouch if canVouchAgainAfter set", () => {
|
||||
cy.login("nzap");
|
||||
cy.visit("/plus");
|
||||
|
||||
cy.dataCy("vouch-button").should("not.exist");
|
||||
cy.contains("Can vouch again after:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { PlusStatus, Suggestions } from "services/plus";
|
||||
import { PlusStatuses, Suggestions } from "services/plus";
|
||||
import useSWR from "swr";
|
||||
import { useUser } from "./common";
|
||||
|
||||
export function usePlus(initialData: Suggestions) {
|
||||
export function usePlus({
|
||||
suggestions: suggestionsInitial,
|
||||
statuses: statusesInitial,
|
||||
}: {
|
||||
suggestions: Suggestions;
|
||||
statuses: PlusStatuses;
|
||||
}) {
|
||||
const [user] = useUser();
|
||||
const [suggestionsFilter, setSuggestionsFilter] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
|
||||
const { data: plusStatusData } = useSWR<PlusStatus>(
|
||||
user ? "/api/plus" : null
|
||||
const { data: plusStatusData } = useSWR<PlusStatuses>(
|
||||
user ? "/api/plus" : null,
|
||||
{ initialData: statusesInitial }
|
||||
);
|
||||
const { data: suggestionsData } = useSWR<Suggestions>(
|
||||
"/api/plus/suggestions",
|
||||
{ initialData }
|
||||
{ initialData: suggestionsInitial }
|
||||
);
|
||||
|
||||
const suggestions = suggestionsData ?? [];
|
||||
|
||||
return {
|
||||
plusStatusData: plusStatusData?.status,
|
||||
plusStatusData: plusStatusData?.find(
|
||||
(status) => status.user.id === user?.id
|
||||
),
|
||||
vouchedPlusStatusData: plusStatusData?.find(
|
||||
(status) => status.voucher?.id === user?.id
|
||||
),
|
||||
suggestionsData: suggestions.filter(
|
||||
(suggestion) =>
|
||||
!suggestionsFilter || suggestion.tier === suggestionsFilter
|
||||
),
|
||||
suggestionsLoading: !suggestionsData,
|
||||
suggestionCounts: suggestions.reduce(
|
||||
(counts, suggestion) => {
|
||||
const tierString = [null, "ONE", "TWO", "THREE"][
|
||||
|
||||
7
lib/validators/vouch.ts
Normal file
7
lib/validators/vouch.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const vouchSchema = z.object({
|
||||
vouchedId: z.number().int(),
|
||||
tier: z.number().int().min(1).max(3),
|
||||
region: z.enum(["NA", "EU"]),
|
||||
});
|
||||
@@ -3,8 +3,6 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import plusService from "services/plus";
|
||||
|
||||
const plusHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getMySession(req);
|
||||
|
||||
switch (req.method) {
|
||||
case "GET":
|
||||
await getHandler(req, res);
|
||||
@@ -14,10 +12,8 @@ const plusHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
|
||||
async function getHandler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!user) return res.status(401).end();
|
||||
|
||||
try {
|
||||
res.status(200).json(await plusService.getPlusStatus(user.id));
|
||||
res.status(200).json(await plusService.getPlusStatuses());
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
res.status(500).end();
|
||||
|
||||
40
pages/api/plus/vouch.ts
Normal file
40
pages/api/plus/vouch.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getMySession } from "lib/api";
|
||||
import { UserError } from "lib/errors";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import plusService from "services/plus";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
const vouchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getMySession(req);
|
||||
|
||||
switch (req.method) {
|
||||
case "POST":
|
||||
await postHandler(req, res);
|
||||
break;
|
||||
default:
|
||||
res.status(405).end();
|
||||
}
|
||||
|
||||
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!user) return res.status(401).end();
|
||||
|
||||
try {
|
||||
await plusService.addVouch({ data: req.body, userId: user.id });
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
res.status(400).json({ message: e.message });
|
||||
} else if (e instanceof UserError) {
|
||||
res.status(400).json({ message: e.message });
|
||||
} else {
|
||||
console.error(e.message);
|
||||
res.status(500).end();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default vouchHandler;
|
||||
@@ -4,10 +4,16 @@ import { GetStaticProps } from "next";
|
||||
import plusService from "services/plus";
|
||||
|
||||
export const getStaticProps: GetStaticProps<PlusHomePageProps> = async () => {
|
||||
const suggestions = await plusService.getSuggestions();
|
||||
const [suggestions, statuses] = await Promise.all([
|
||||
plusService.getSuggestions(),
|
||||
plusService.getPlusStatuses(),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: { suggestions: JSON.parse(JSON.stringify(suggestions)) },
|
||||
props: {
|
||||
suggestions: JSON.parse(JSON.stringify(suggestions)),
|
||||
statuses: JSON.parse(JSON.stringify(statuses)),
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,11 +57,14 @@ export const getPlusStatusesData = (): Prisma.PlusStatusCreateManyInput[] => {
|
||||
userId: 11,
|
||||
region: "EU",
|
||||
membershipTier: 1,
|
||||
canVouchFor: 1,
|
||||
},
|
||||
{
|
||||
userId: 12,
|
||||
region: "EU",
|
||||
membershipTier: 2,
|
||||
canVouchAgainAfter: new Date(Date.UTC(2030, 1, 1)),
|
||||
canVouchFor: 2,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -3,13 +3,13 @@ import { UserError } from "lib/errors";
|
||||
import { getPercentageFromCounts } from "lib/plus";
|
||||
import { userBasicSelection } from "lib/prisma";
|
||||
import { suggestionFullSchema } from "lib/validators/suggestion";
|
||||
import { vouchSchema } from "lib/validators/vouch";
|
||||
import prisma from "prisma/client";
|
||||
|
||||
export type PlusStatus = Prisma.PromiseReturnType<typeof getPlusStatus>;
|
||||
export type PlusStatuses = Prisma.PromiseReturnType<typeof getPlusStatuses>;
|
||||
|
||||
const getPlusStatus = async (userId: number) => {
|
||||
const status = await prisma.plusStatus.findUnique({
|
||||
where: { userId },
|
||||
const getPlusStatuses = async () => {
|
||||
return prisma.plusStatus.findMany({
|
||||
select: {
|
||||
canVouchAgainAfter: true,
|
||||
vouchTier: true,
|
||||
@@ -17,10 +17,9 @@ const getPlusStatus = async (userId: number) => {
|
||||
membershipTier: true,
|
||||
region: true,
|
||||
voucher: { select: userBasicSelection },
|
||||
user: { select: userBasicSelection },
|
||||
},
|
||||
});
|
||||
|
||||
return { status: status ?? null };
|
||||
};
|
||||
|
||||
export type Suggestions = Prisma.PromiseReturnType<typeof getSuggestions>;
|
||||
@@ -237,11 +236,62 @@ const addSuggestion = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const addVouch = async ({
|
||||
data,
|
||||
userId,
|
||||
}: {
|
||||
data: unknown;
|
||||
userId: number;
|
||||
}) => {
|
||||
const parsedData = vouchSchema.parse(data);
|
||||
const plusStatuses = await prisma.plusStatus.findMany({});
|
||||
|
||||
const suggesterPlusStatus = plusStatuses.find(
|
||||
(status) => status.userId === userId
|
||||
);
|
||||
|
||||
if ((suggesterPlusStatus?.canVouchFor ?? Infinity) > parsedData.tier) {
|
||||
throw new UserError(
|
||||
"not a member of high enough tier to vouch for this tier"
|
||||
);
|
||||
}
|
||||
|
||||
if (vouchedUserAlreadyHasAccess()) {
|
||||
throw new UserError("vouched user already has access");
|
||||
}
|
||||
|
||||
// TODO voting has started
|
||||
|
||||
return prisma.$transaction([
|
||||
prisma.plusStatus.upsert({
|
||||
where: { userId: parsedData.vouchedId },
|
||||
create: { region: parsedData.region, userId: parsedData.vouchedId },
|
||||
update: { voucherId: userId, vouchTier: parsedData.tier },
|
||||
}),
|
||||
prisma.plusStatus.update({
|
||||
where: { userId },
|
||||
data: { canVouchFor: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
function vouchedUserAlreadyHasAccess() {
|
||||
const suggestedPlusStatus = plusStatuses.find(
|
||||
(status) => status.userId === parsedData.vouchedId
|
||||
);
|
||||
return Boolean(
|
||||
suggestedPlusStatus &&
|
||||
((suggestedPlusStatus.membershipTier ?? 999) <= parsedData.tier ||
|
||||
(suggestedPlusStatus.vouchTier ?? 999) <= parsedData.tier)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
getPlusStatus,
|
||||
getPlusStatuses,
|
||||
getSuggestions,
|
||||
getVotingSummariesByMonthAndTier,
|
||||
getMostRecentVotingWithResultsMonth,
|
||||
getDistinctSummaryMonths,
|
||||
addSuggestion,
|
||||
addVouch,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user