diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21649db25..a8926bb53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,12 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - - name: ESLint + - name: Check linting rules run: yarn lint + - name: Check types + run: yarn lint:tsc + # Label of the container job integration-tests: name: Run tests diff --git a/package.json b/package.json index 0bffdcf40..de1fe3137 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "react-big-calendar": "^0.38.9", "react-dom": "17.0.2", "react-github-btn": "^1.2.2", - "react-hook-form": "^7.27.0", + "react-hook-form": "^7.31.2", "react-hot-toast": "^2.2.0", "react-i18next": "^11.15.4", "react-linkify": "^1.0.0-alpha", @@ -62,7 +62,6 @@ "devDependencies": { "@playwright/test": "^1.20.1", "@types/lodash": "^4.14.178", - "@types/mixpanel-browser": "^2.38.0", "@types/nodemailer": "^6.4.4", "@types/react": "^17.0.5", "@types/react-big-calendar": "^0.31.0", diff --git a/public/locales/en/app.json b/public/locales/en/app.json index a5a33e1f6..349eb3a90 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -1,7 +1,7 @@ { "next": "Continue", "back": "Back", - "newPoll": "New Poll", + "newPoll": "New poll", "eventDetails": "Poll Details", "options": "Options", "finish": "Finish", @@ -11,8 +11,8 @@ "titlePlaceholder": "Monthly Meetup", "locationPlaceholder": "Joe's Coffee Shop", "descriptionPlaceholder": "Hey everyone, please choose the dates that work for you!", - "namePlaceholder": "John Doe", - "emailPlaceholder": "john.doe@email.com", + "namePlaceholder": "Jessie Smith", + "emailPlaceholder": "jessie.smith@email.com", "createPoll": "Create poll", "location": "Location", "description": "Description", @@ -57,5 +57,7 @@ "areYouSure": "Are you sure?", "deletePollDescription": "All data related to this poll will be deleted. To confirm, please type “{{confirmText}}” in to the input below:", "deletePoll": "Delete poll", - "demoPollNotice": "Demo polls are automatically deleted after 1 day" + "demoPollNotice": "Demo polls are automatically deleted after 1 day", + "yourDetails": "Your details", + "yourPolls": "Your polls" } diff --git a/src/components/dropdown.tsx b/src/components/dropdown.tsx index 182bd67b8..a8b8bbdd6 100644 --- a/src/components/dropdown.tsx +++ b/src/components/dropdown.tsx @@ -9,6 +9,7 @@ import { import { Menu } from "@headlessui/react"; import clsx from "clsx"; import { motion } from "framer-motion"; +import Link from "next/link"; import * as React from "react"; import { transformOriginByPlacement } from "@/utils/constants"; @@ -82,16 +83,39 @@ const Dropdown: React.VoidFunctionComponent = ({ ); }; +const AnchorLink: React.VoidFunctionComponent<{ + href?: string; + children?: React.ReactNode; + className?: string; +}> = ({ href = "", className, children, ...forwardProps }) => { + return ( + + + {children} + + + ); +}; + export const DropdownItem: React.VoidFunctionComponent<{ icon?: React.ComponentType<{ className?: string }>; label?: React.ReactNode; disabled?: boolean; + href?: string; onClick?: () => void; -}> = ({ icon: Icon, label, onClick, disabled }) => { +}> = ({ icon: Icon, label, onClick, disabled, href }) => { + const Element = href ? AnchorLink : "button"; return ( {({ active }) => ( - + )} ); diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx new file mode 100644 index 000000000..b6f8d9412 --- /dev/null +++ b/src/components/empty-state.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +export interface EmptyStateProps { + icon: React.ComponentType<{ className?: string }>; + text: React.ReactNode; +} + +export const EmptyState: React.VoidFunctionComponent = ({ + icon: Icon, + text, +}) => { + return ( +
+
+ +
{text}
+
+
+ ); +}; diff --git a/src/components/icons/user-circle.svg b/src/components/icons/user-circle.svg new file mode 100644 index 000000000..3e9a1175d --- /dev/null +++ b/src/components/icons/user-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/profile.tsx b/src/components/profile.tsx new file mode 100644 index 000000000..ea8ab79dd --- /dev/null +++ b/src/components/profile.tsx @@ -0,0 +1,97 @@ +import { formatRelative } from "date-fns"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; +import * as React from "react"; + +import Calendar from "@/components/icons/calendar.svg"; +import Pencil from "@/components/icons/pencil.svg"; +import User from "@/components/icons/user.svg"; + +import { trpc } from "../utils/trpc"; +import { EmptyState } from "./empty-state"; +import { UserDetails } from "./profile/user-details"; +import { useSession } from "./session"; + +export const Profile: React.VoidFunctionComponent = () => { + const { user } = useSession(); + + const { t } = useTranslation("app"); + const { data: userPolls } = trpc.useQuery(["user.getPolls"]); + + const router = useRouter(); + const createdPolls = userPolls?.polls; + + React.useEffect(() => { + if (!user) { + router.replace("/new"); + } + }, [user, router]); + + if (!user || user.isGuest) { + return null; + } + + return ( +
+
+
+ +
+
+
+ {user.shortName} +
+
+ {user.isGuest ? "Guest" : "User"} +
+
+
+ + + {createdPolls ? ( +
+
+
{t("yourPolls")}
+ + + + {t("newPoll")} + + +
+ {createdPolls.length > 0 ? ( +
+
+ {createdPolls.map((poll, i) => ( +
+
+
+ +
+ {formatRelative(poll.createdAt, new Date())} +
+
+
+
+ ))} +
+
+ ) : ( + + )} +
+ ) : null} +
+ ); +}; diff --git a/src/components/profile/user-details.tsx b/src/components/profile/user-details.tsx new file mode 100644 index 000000000..f3687abe2 --- /dev/null +++ b/src/components/profile/user-details.tsx @@ -0,0 +1,109 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "next-i18next"; +import * as React from "react"; +import { useForm } from "react-hook-form"; + +import { requiredString, validEmail } from "../../utils/form-validation"; +import { trpc } from "../../utils/trpc"; +import { Button } from "../button"; +import { useSession } from "../session"; +import { TextInput } from "../text-input"; + +export interface UserDetailsProps { + userId: string; + name: string; + email: string; +} + +const MotionButton = motion(Button); + +export const UserDetails: React.VoidFunctionComponent = ({ + userId, + name, + email, +}) => { + const { t } = useTranslation("app"); + const { register, formState, handleSubmit, reset } = useForm<{ + name: string; + email: string; + }>({ + defaultValues: { name, email }, + }); + + const { refresh } = useSession(); + + const changeName = trpc.useMutation("user.changeName", { + onSuccess: () => { + refresh(); + }, + }); + + const { dirtyFields } = formState; + return ( +
{ + if (dirtyFields.name) { + await changeName.mutateAsync({ userId, name: data.name }); + } + reset(data); + })} + className="card mb-4 p-0" + > +
+
{t("yourDetails")}
+ + {t("save")} + +
+
+
+ +
+ + {formState.errors.name ? ( +
+ {t("requiredNameError")} +
+ ) : null} +
+
+
+ +
+ +
+
+
+
+ ); +}; diff --git a/src/components/session.tsx b/src/components/session.tsx index 9e332313c..00e266e59 100644 --- a/src/components/session.tsx +++ b/src/components/session.tsx @@ -1,5 +1,6 @@ import { IronSessionData } from "iron-session"; import React from "react"; +import toast from "react-hot-toast"; import { trpc } from "@/utils/trpc"; @@ -16,9 +17,13 @@ type ParticipantOrComment = { guestId: string | null; }; +export type UserSessionDataExtended = UserSessionData & { + shortName: string; +}; + type SessionContextValue = { logout: () => void; - user: (UserSessionData & { shortName: string }) | null; + user: UserSessionDataExtended | null; refresh: () => void; ownsObject: (obj: ParticipantOrComment) => boolean; isLoading: boolean; @@ -40,8 +45,8 @@ export const SessionProvider: React.VoidFunctionComponent<{ isLoading, } = trpc.useQuery(["session.get"]); - const { mutate: logout } = trpc.useMutation(["session.destroy"], { - onMutate: () => { + const logout = trpc.useMutation(["session.destroy"], { + onSuccess: () => { queryClient.setQueryData(["session.get"], null); }, }); @@ -65,7 +70,11 @@ export const SessionProvider: React.VoidFunctionComponent<{ }, isLoading, logout: () => { - logout(); + toast.promise(logout.mutateAsync(), { + loading: "Logging out…", + success: "Logged out", + error: "Failed to log out", + }); }, ownsObject: (obj) => { if (!user) { diff --git a/src/components/standard-layout.tsx b/src/components/standard-layout.tsx index bff625784..07755fdde 100644 --- a/src/components/standard-layout.tsx +++ b/src/components/standard-layout.tsx @@ -5,6 +5,7 @@ import React from "react"; import Menu from "@/components/icons/menu.svg"; import User from "@/components/icons/user.svg"; +import UserCircle from "@/components/icons/user-circle.svg"; import Logo from "~/public/logo.svg"; import Dropdown, { DropdownItem, DropdownProps } from "./dropdown"; @@ -71,10 +72,7 @@ const MobileNavigation: React.VoidFunctionComponent<{ className="group inline-flex w-full items-center space-x-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20" >
- {user.isGuest ? ( - - ) : null} - +
{user.shortName} @@ -195,6 +193,9 @@ const UserDropdown: React.VoidFunctionComponent< }} /> ) : null} + {!user.isGuest ? ( + + ) : null} {user.isGuest ? ( ) : null} @@ -300,10 +301,7 @@ const StandardLayout: React.VoidFunctionComponent<{ >
- {user.isGuest ? ( - - ) : null} - +
diff --git a/src/components/text-input.tsx b/src/components/text-input.tsx new file mode 100644 index 000000000..a204fd2d2 --- /dev/null +++ b/src/components/text-input.tsx @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import * as React from "react"; + +export interface TextInputProps + extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > { + error?: boolean; +} + +export const TextInput = React.forwardRef( + function TextInput({ className, error, ...forwardProps }, ref) { + return ( + + ); + }, +); diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index 15a717b20..512e0937e 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -6,13 +6,15 @@ import { createRouter } from "../../../server/createRouter"; import { login } from "../../../server/routers/login"; import { polls } from "../../../server/routers/polls"; import { session } from "../../../server/routers/session"; +import { user } from "../../../server/routers/user"; import { withSessionRoute } from "../../../utils/auth"; export const appRouter = createRouter() .transformer(superjson) .merge("session.", session) .merge("polls.", polls) - .merge(login); + .merge(login) + .merge("user.", user); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx new file mode 100644 index 000000000..689e28541 --- /dev/null +++ b/src/pages/profile.tsx @@ -0,0 +1,40 @@ +import { NextPage } from "next"; +import Head from "next/head"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +import { withSessionSsr } from "@/utils/auth"; + +import { Profile } from "../components/profile"; +import { SessionProvider, UserSessionData } from "../components/session"; +import StandardLayout from "../components/standard-layout"; + +const Page: NextPage<{ user: UserSessionData }> = ({ user }) => { + const name = user.isGuest ? user.id : user.name; + return ( + + + Profile - {name} + + + + + + ); +}; + +export const getServerSideProps = withSessionSsr( + async ({ locale = "en", query, req }) => { + if (!req.session.user || req.session.user.isGuest) { + return { redirect: { destination: "/new" }, props: {} }; + } + return { + props: { + ...(await serverSideTranslations(locale, ["app"])), + ...query, + user: req.session.user, + }, + }; + }, +); + +export default Page; diff --git a/src/server/routers/session.ts b/src/server/routers/session.ts index d6210e59f..9de43fc03 100644 --- a/src/server/routers/session.ts +++ b/src/server/routers/session.ts @@ -15,12 +15,14 @@ export const session = createRouter() return null; } - return { + ctx.session.user = { id: user.id, name: user.name, email: user.email, isGuest: false, }; + + await ctx.session.save(); } return ctx.session.user; diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts new file mode 100644 index 000000000..3012d7f21 --- /dev/null +++ b/src/server/routers/user.ts @@ -0,0 +1,68 @@ +import { TRPCError } from "@trpc/server"; +import { IronSessionData } from "iron-session"; +import { z } from "zod"; + +import { prisma } from "~/prisma/db"; + +import { createRouter } from "../createRouter"; + +const requireUser = (user: IronSessionData["user"]) => { + if (!user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Tried to access user route without a session", + }); + } + return user; +}; + +export const user = createRouter() + .query("getPolls", { + resolve: async ({ ctx }) => { + const user = requireUser(ctx.session.user); + const userPolls = await prisma.user.findUnique({ + where: { + id: user.id, + }, + select: { + polls: { + where: { + deleted: false, + }, + select: { + title: true, + closed: true, + verified: true, + createdAt: true, + links: { + where: { + role: "admin", + }, + }, + }, + take: 5, + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + return userPolls; + }, + }) + .mutation("changeName", { + input: z.object({ + userId: z.string(), + name: z.string().min(1).max(100), + }), + resolve: async ({ input }) => { + await prisma.user.update({ + where: { + id: input.userId, + }, + data: { + name: input.name, + }, + }); + }, + }); diff --git a/style.css b/style.css index d76c558d2..00a87927a 100644 --- a/style.css +++ b/style.css @@ -69,12 +69,16 @@ @apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all active:scale-95; } a.btn { - @apply hover:no-underline; + @apply cursor-pointer hover:no-underline; } .btn-default { @apply btn border-slate-300 bg-white text-slate-700 hover:bg-indigo-50/10 active:bg-slate-100; } + + a.btn-default { + @apply hover:text-indigo-500; + } .btn-danger { @apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500; } @@ -123,8 +127,7 @@ } .card { - @apply rounded-lg border bg-white p-6 - shadow-sm; + @apply border-t border-b bg-white p-6 shadow-sm sm:rounded-lg sm:border-l sm:border-r; } } diff --git a/tests/create-delete-poll.spec.ts b/tests/create-delete-poll.spec.ts index f480d2345..de04465a4 100644 --- a/tests/create-delete-poll.spec.ts +++ b/tests/create-delete-poll.spec.ts @@ -23,8 +23,11 @@ test("should be able to create a new poll and delete it", async ({ page }) => { await page.click('text="Continue"'); - await page.type('[placeholder="John Doe"]', "John"); - await page.type('[placeholder="john.doe@email.com"]', "john.doe@email.com"); + await page.type('[placeholder="Jessie Smith"]', "John"); + await page.type( + '[placeholder="jessie.smith@email.com"]', + "john.doe@email.com", + ); await page.click('text="Create poll"'); diff --git a/yarn.lock b/yarn.lock index 0e6af4f06..d841f5750 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1826,11 +1826,6 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== -"@types/mixpanel-browser@^2.38.0": - version "2.38.0" - resolved "https://registry.yarnpkg.com/@types/mixpanel-browser/-/mixpanel-browser-2.38.0.tgz#b3e28e1ba06c10a9f88510b88f1ac9d1b2adfc42" - integrity sha512-TR8rvsILnqXA7oiiGOxuMGXwvDeCoQDonXJB5UR+TYvEAFpiK8ReFj5LhZT+Xhm3NpI9aPoju30jB2ssorSUww== - "@types/node@*": version "17.0.21" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" @@ -5073,10 +5068,10 @@ react-github-btn@^1.2.2: dependencies: github-buttons "^2.21.1" -react-hook-form@^7.27.0: - version "7.27.0" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.27.0.tgz#2c05e54ca557f71c55f645311ff612ec936c6c7c" - integrity sha512-NEh3Qbz1Rg3w95SRZv0kHorHN3frtMKasplznMBr8RkFrE4pVxjd/zo3clnFXpD0FppUVHBMfsTMtTsa6wyQrA== +react-hook-form@^7.31.2: + version "7.31.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.31.2.tgz#efb7ac469810954488b7cf40be4e5017122c6e5e" + integrity sha512-oPudn3YuyzWg//IsT9z2cMEjWocAgHWX/bmueDT8cmsYQnGY5h7/njjvMDfLVv3mbdhYBjslTRnII2MIT7eNCA== react-hot-toast@^2.2.0: version "2.2.0"