From 89de9fed6c46cb3e44c1ca7827628e922766c350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pinheiro?= Date: Sat, 30 Nov 2024 15:51:22 +0000 Subject: [PATCH] feat(web): allow a player to manage their balance (#62) Refs: closes #31 ## Summary Allow a player to manage their balance. --- web/package-lock.json | 33 ++ web/package.json | 1 + .../components/account/account-details.tsx | 351 ++++++++++++ web/src/components/account/add-funds.tsx | 148 +++++ web/src/components/account/sign-out.tsx | 20 + web/src/components/account/user-avatar.tsx | 90 +++ web/src/components/ui/radio-group.tsx | 43 ++ web/src/lib/query-keys.ts | 25 + web/src/routes/_layout/account.tsx | 518 +----------------- web/src/routes/_layout/browse.tsx | 3 +- web/src/routes/_layout/cart.tsx | 3 +- web/src/routes/_layout/index.tsx | 3 +- .../distribute/_layout/games/$gameId.tsx | 3 +- .../_layout/games_/$gameId/edit.tsx | 3 +- 14 files changed, 734 insertions(+), 510 deletions(-) create mode 100644 web/src/components/account/account-details.tsx create mode 100644 web/src/components/account/add-funds.tsx create mode 100644 web/src/components/account/sign-out.tsx create mode 100644 web/src/components/account/user-avatar.tsx create mode 100644 web/src/components/ui/radio-group.tsx create mode 100644 web/src/lib/query-keys.ts diff --git a/web/package-lock.json b/web/package-lock.json index 5663b92..937e134 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -1743,6 +1744,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", + "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index d016736..1e30965 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", diff --git a/web/src/components/account/account-details.tsx b/web/src/components/account/account-details.tsx new file mode 100644 index 0000000..3f74fdb --- /dev/null +++ b/web/src/components/account/account-details.tsx @@ -0,0 +1,351 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@radix-ui/react-popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@radix-ui/react-select"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { CalendarIcon } from "lucide-react"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { User } from "@/domain/user"; +import { useToast } from "@/hooks/use-toast"; +import { updateUser } from "@/lib/api"; +import { COUNTRIES } from "@/lib/constants"; +import { Conflict } from "@/lib/errors"; +import { userQueryKey } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import { accountDetailsSchema } from "@/lib/zod"; + +export function AccountDetails(props: { user: User; country: string }) { + const [isEditMode, setEditMode] = useState(false); + + if (isEditMode) { + return ( + setEditMode(false)} + onSave={() => setEditMode(false)} + /> + ); + } + + return ( + setEditMode(true)} + /> + ); +} + +function ViewAccountDetails(props: { + user: User; + country: string; + onEdit: () => void; +}) { + return ( +
+
+

Account Details

+ +
+
+
+

Username

+

{props.user.username}

+
+
+

Email

+

{props.user.email}

+
+
+

Full Name

+

{props.user.displayName}

+
+
+

+ Date of Birth +

+

+ {format(props.user.dateOfBirth, "dd/MM/yyyy")} +

+
+
+

Country

+

{props.country}

+
+
+

VAT

+

{props.user.vatin}

+
+
+
+

Address

+

{props.user.address}

+
+
+ ); +} + +type AccountDetailsSchemaType = z.infer; + +function EditAccountDetails(props: { + user: User; + onCancel: () => void; + onSave: () => void; +}) { + const queryClient = useQueryClient(); + const form = useForm({ + resolver: zodResolver(accountDetailsSchema), + defaultValues: { + username: props.user.username, + email: props.user.email, + displayName: props.user.displayName, + dateOfBirth: new Date(props.user.dateOfBirth), + country: props.user.country.toUpperCase(), + address: props.user.address, + vatin: props.user.vatin, + }, + }); + const { toast } = useToast(); + const mutation = useMutation({ + async mutationFn(data: AccountDetailsSchemaType) { + await updateUser(props.user.id, { + username: data.username, + email: data.email, + displayName: data.displayName, + dateOfBirth: format(data.dateOfBirth, "yyyy-MM-dd"), + country: data.country.toUpperCase(), + address: data.address, + vatin: data.vatin, + }); + }, + async onSuccess() { + await queryClient.invalidateQueries({ queryKey: userQueryKey }); + props.onSave(); + }, + onError(error) { + if (error instanceof Conflict) { + switch (error.code) { + case "user_username_already_exists": + form.setError("username", { message: "Username already exists" }); + break; + + case "user_email_already_exists": + form.setError("email", { message: "Email already exists" }); + break; + + case "user_vatin_already_exists": + form.setError("vatin", { message: "VAT already exists" }); + break; + } + return; + } + + toast({ + variant: "destructive", + title: "Oops! An unexpected error occurred", + description: "Please try again later or contact the support team.", + }); + }, + }); + + /** + * Handles form submission. + * @param data Form data. + */ + function onSubmit(data: AccountDetailsSchemaType) { + mutation.mutate(data); + } + + return ( + <> +
+

Account Details

+
+
+ +
+ ( + + Username + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Full Name + + + + + + )} + /> + + ( + + Date of Birth + + + + + + + + date > new Date()} + mode="single" + selected={field.value} + onSelect={field.onChange} + /> + + + + + )} + /> + + ( + + Country + + + + )} + /> + + ( + + VAT + + + + + + )} + /> +
+ + ( + + Address + + + + + + )} + /> + +
+ + +
+ + + + ); +} diff --git a/web/src/components/account/add-funds.tsx b/web/src/components/account/add-funds.tsx new file mode 100644 index 0000000..ae8e45b --- /dev/null +++ b/web/src/components/account/add-funds.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { useToast } from "@/hooks/use-toast"; +import { updateUser } from "@/lib/api"; +import { userQueryKey } from "@/lib/query-keys"; +import { formatCurrency } from "@/lib/utils"; + +const amounts = [5, 10, 25, 50, 100]; + +const formSchema = z.object({ + amount: z.coerce.number({ message: "Select an amount" }), +}); + +type AddFundsSchemaType = z.infer; + +export function AddFunds(props: { id: string; balance: number }) { + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const form = useForm({ + resolver: zodResolver(formSchema), + }); + const { toast } = useToast(); + const mutation = useMutation({ + async mutationFn(data: AddFundsSchemaType) { + await updateUser(props.id, { + balance: props.balance + data.amount, + }); + }, + async onSuccess() { + await queryClient.invalidateQueries({ queryKey: userQueryKey }); + form.resetField("amount"); + setOpen(false); + }, + onError() { + toast({ + variant: "destructive", + title: "Oops! An unexpected error occurred", + description: "Please try again later or contact the support team.", + }); + }, + }); + + /** + * Handles form submission. + * @param data Form data. + */ + function onSubmit(data: AddFundsSchemaType) { + mutation.mutate(data); + } + + return ( + { + if (!isOpen) { + form.resetField("amount"); + } + setOpen(isOpen); + }} + > + + + + + + Add Funds to Your Account + + Select the amount you'd like to add to your account balance. + + +
+ + ( + + + + {amounts.map((amount) => { + const isSelected = + String(field.value) === String(amount); + + return ( + + + + + + + ); + })} + + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/web/src/components/account/sign-out.tsx b/web/src/components/account/sign-out.tsx new file mode 100644 index 0000000..e857bde --- /dev/null +++ b/web/src/components/account/sign-out.tsx @@ -0,0 +1,20 @@ +import { LogOut } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { clearToken } from "@/lib/auth"; + +export function SignOut() { + /** + * Signs out a user and reloads the current page. + */ + function handleClick() { + clearToken(); + window.location.reload(); + } + + return ( + + ); +} diff --git a/web/src/components/account/user-avatar.tsx b/web/src/components/account/user-avatar.tsx new file mode 100644 index 0000000..553bc1c --- /dev/null +++ b/web/src/components/account/user-avatar.tsx @@ -0,0 +1,90 @@ +import { ChangeEvent } from "react"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { LoaderCircle, Upload } from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/hooks/use-toast"; +import { updateUser, uploadMultimedia } from "@/lib/api"; +import { ContentTooLarge } from "@/lib/errors"; +import { userQueryKey } from "@/lib/query-keys"; +import { getInitials } from "@/lib/utils"; + +export function UserAvatar(props: { + id: string; + displayName: string; + url?: string; +}) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + async mutationFn(file: File) { + const multimedia = await uploadMultimedia(file); + + await updateUser(props.id, { pictureMultimediaId: multimedia.id }); + }, + async onSuccess() { + await queryClient.invalidateQueries({ queryKey: userQueryKey }); + }, + onError(error) { + if (error instanceof ContentTooLarge) { + toast({ + variant: "destructive", + title: "Picture size must be smaller than 2MB", + }); + return; + } + + toast({ + variant: "destructive", + title: "Oops! An unexpected error occurred", + description: "Please try again later or contact the support team.", + }); + }, + }); + + /** + * Handles file upload. + * @param event Input change event. + */ + function handleFileUpload(event: ChangeEvent) { + const files = event.target.files; + if (!files) { + return; + } + + mutation.mutate(files[0]); + } + + return ( + +