diff --git a/components/plus/PlusHomePage.tsx b/components/plus/PlusHomePage.tsx index f636e22da..1c5552241 100644 --- a/components/plus/PlusHomePage.tsx +++ b/components/plus/PlusHomePage.tsx @@ -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 && ( - )} + {plusStatusData && + plusStatusData.canVouchFor && + !plusStatusData.canVouchAgainAfter && ( + + )} + {plusStatusData && + (plusStatusData.canVouchAgainAfter || + plusStatusData.voucher || + vouchedPlusStatusData) && ( + + + + Vouching status + + {plusStatusData?.canVouchAgainAfter && ( + + Can vouch again after:{" "} + {new Date( + plusStatusData.canVouchAgainAfter + ).toLocaleDateString()} + + )} + {plusStatusData?.voucher && ( + + Vouched for +{plusStatusData.vouchTier} by{" "} + {getFullUsername(plusStatusData.voucher)} + + )} + {vouchedPlusStatusData && ( + + Vouched {getFullUsername(vouchedPlusStatusData.user)} to{" "} + +{vouchedPlusStatusData.vouchTier} + + )} + + + )}
{
- {!suggestionsLoading && - suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE === - 0 ? ( + {suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE === + 0 ? ( No suggestions yet for this month ) : ( <> diff --git a/components/plus/SuggestionVouchModal.tsx b/components/plus/SuggestionModal.tsx similarity index 89% rename from components/plus/SuggestionVouchModal.tsx rename to components/plus/SuggestionModal.tsx index b32459e2e..7f1b853ec 100644 --- a/components/plus/SuggestionVouchModal.tsx +++ b/components/plus/SuggestionModal.tsx @@ -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; -const SuggestionVouchModal: React.FC = ({ - canVouch, - canSuggest, - userPlusMembershipTier, -}) => { +const SuggestionModal: React.FC = ({ userPlusMembershipTier }) => { const [isOpen, setIsOpen] = useState(false); const { handleSubmit, errors, register, watch, control } = useForm({ resolver: zodResolver(suggestionFullSchema), @@ -51,17 +45,6 @@ const SuggestionVouchModal: React.FC = ({ 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 ( <> {isOpen && ( = ({ > - Adding a new suggestion or vouch + Adding a new suggestion
@@ -181,4 +164,4 @@ const SuggestionVouchModal: React.FC = ({ ); }; -export default SuggestionVouchModal; +export default SuggestionModal; diff --git a/components/plus/VouchModal.tsx b/components/plus/VouchModal.tsx new file mode 100644 index 000000000..05afb95de --- /dev/null +++ b/components/plus/VouchModal.tsx @@ -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; + +const VouchModal: React.FC = ({ canVouchFor }) => { + const [isOpen, setIsOpen] = useState(false); + const { handleSubmit, errors, register, control } = useForm({ + resolver: zodResolver(vouchSchema), + }); + const { onSubmit, sending } = useMutation({ + onSuccess: () => setIsOpen(false), + route: "plus/vouch", + mutationKey: "plus", + successText: "Successfully vouched", + }); + + return ( + <> + + {isOpen && ( + setIsOpen(false)} + size="xl" + closeOnOverlayClick={false} + > + + + Vouching + + + + Tier + ( + + )} + /> + + + User + ( + + )} + /> + + {errors.vouchedId?.message} + + + + + Region + + + If the player isn't from either region then choose the one + they play most commonly with. + + + + + + + + + + + + )} + + ); +}; + +export default VouchModal; diff --git a/cypress/integration/plus.spec.ts b/cypress/integration/plus.spec.ts index 8d0f82485..4b9022e9d 100644 --- a/cypress/integration/plus.spec.ts +++ b/cypress/integration/plus.spec.ts @@ -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:"); + }); }); diff --git a/hooks/plus.ts b/hooks/plus.ts index ae92c43df..6b39047b2 100644 --- a/hooks/plus.ts +++ b/hooks/plus.ts @@ -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( - user ? "/api/plus" : null + const { data: plusStatusData } = useSWR( + user ? "/api/plus" : null, + { initialData: statusesInitial } ); const { data: suggestionsData } = useSWR( "/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"][ diff --git a/lib/validators/vouch.ts b/lib/validators/vouch.ts new file mode 100644 index 000000000..aa9d97d6d --- /dev/null +++ b/lib/validators/vouch.ts @@ -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"]), +}); diff --git a/pages/api/plus/index.ts b/pages/api/plus/index.ts index deaf207d6..47b968023 100644 --- a/pages/api/plus/index.ts +++ b/pages/api/plus/index.ts @@ -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(); diff --git a/pages/api/plus/vouch.ts b/pages/api/plus/vouch.ts new file mode 100644 index 000000000..7b644d75f --- /dev/null +++ b/pages/api/plus/vouch.ts @@ -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; diff --git a/pages/plus/index.tsx b/pages/plus/index.tsx index ee7db4496..f54213fc4 100644 --- a/pages/plus/index.tsx +++ b/pages/plus/index.tsx @@ -4,10 +4,16 @@ import { GetStaticProps } from "next"; import plusService from "services/plus"; export const getStaticProps: GetStaticProps = 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, }; }; diff --git a/prisma/mocks/plus.ts b/prisma/mocks/plus.ts index 461ed32dd..676d4156f 100644 --- a/prisma/mocks/plus.ts +++ b/prisma/mocks/plus.ts @@ -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, }, ]; }; diff --git a/services/plus.ts b/services/plus.ts index 3c9b1b82e..d6a37a5ea 100644 --- a/services/plus.ts +++ b/services/plus.ts @@ -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; +export type PlusStatuses = Prisma.PromiseReturnType; -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; @@ -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, };