Skip to content

Commit

Permalink
Profile page (lukevella#190)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored May 25, 2022
1 parent d704389 commit 3384c93
Show file tree
Hide file tree
Showing 18 changed files with 441 additions and 38 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions public/locales/en/app.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"next": "Continue",
"back": "Back",
"newPoll": "New Poll",
"newPoll": "New poll",
"eventDetails": "Poll Details",
"options": "Options",
"finish": "Finish",
Expand All @@ -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",
Expand Down Expand Up @@ -57,5 +57,7 @@
"areYouSure": "Are you sure?",
"deletePollDescription": "All data related to this poll will be deleted. To confirm, please type <s>“{{confirmText}}”</s> 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"
}
30 changes: 27 additions & 3 deletions src/components/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,16 +83,39 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
);
};

const AnchorLink: React.VoidFunctionComponent<{
href?: string;
children?: React.ReactNode;
className?: string;
}> = ({ href = "", className, children, ...forwardProps }) => {
return (
<Link href={href} passHref>
<a
className={clsx(
"font-normal hover:text-white hover:no-underline",
className,
)}
{...forwardProps}
>
{children}
</a>
</Link>
);
};

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 (
<Menu.Item disabled={disabled}>
{({ active }) => (
<button
<Element
href={href}
onClick={onClick}
className={clsx(
"group flex w-full items-center rounded py-2 pl-2 pr-4",
Expand All @@ -111,7 +135,7 @@ export const DropdownItem: React.VoidFunctionComponent<{
/>
)}
{label}
</button>
</Element>
)}
</Menu.Item>
);
Expand Down
20 changes: 20 additions & 0 deletions src/components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -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<EmptyStateProps> = ({
icon: Icon,
text,
}) => {
return (
<div className="flex h-full items-center justify-center py-12">
<div className="text-center font-medium text-slate-500/50">
<Icon className="mb-2 inline-block h-12 w-12" />
<div>{text}</div>
</div>
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/icons/user-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 97 additions & 0 deletions src/components/profile.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-3xl py-4 lg:mx-0">
<div className="mb-4 flex items-center px-4">
<div className="mr-4 inline-flex h-14 w-14 items-center justify-center rounded-lg bg-indigo-50">
<User className="h-7 text-indigo-500" />
</div>
<div>
<div
data-testid="user-name"
className="mb-0 text-xl font-medium leading-tight"
>
{user.shortName}
</div>
<div className="text-slate-500">
{user.isGuest ? "Guest" : "User"}
</div>
</div>
</div>

<UserDetails userId={user.id} name={user.name} email={user.email} />
{createdPolls ? (
<div className="card p-0">
<div className="flex items-center justify-between border-b p-4 shadow-sm">
<div className="text-lg text-slate-700">{t("yourPolls")}</div>
<Link href="/new">
<a className="btn-default">
<Pencil className="mr-1 h-5" />
{t("newPoll")}
</a>
</Link>
</div>
{createdPolls.length > 0 ? (
<div className="w-full sm:table sm:border-collapse">
<div className="divide-y sm:table-row-group">
{createdPolls.map((poll, i) => (
<div className="p-4 sm:table-row sm:p-0" key={i}>
<div className="sm:table-cell sm:p-4">
<div>
<div className="flex">
<Calendar className="mr-2 mt-[1px] h-5 text-indigo-500" />
<Link href={`/p/${poll.links[0].urlId}`}>
<a className="text-slate-700 hover:text-indigo-500 hover:no-underline">
<div>{poll.title}</div>
</a>
</Link>
</div>
<div className="ml-7 text-sm text-slate-500">
{formatRelative(poll.createdAt, new Date())}
</div>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<EmptyState icon={Pencil} text="No polls created" />
)}
</div>
) : null}
</div>
);
};
109 changes: 109 additions & 0 deletions src/components/profile/user-details.tsx
Original file line number Diff line number Diff line change
@@ -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<UserDetailsProps> = ({
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 (
<form
onSubmit={handleSubmit(async (data) => {
if (dirtyFields.name) {
await changeName.mutateAsync({ userId, name: data.name });
}
reset(data);
})}
className="card mb-4 p-0"
>
<div className="flex items-center justify-between border-b p-4 shadow-sm">
<div className="text-lg text-slate-700 ">{t("yourDetails")}</div>
<MotionButton
variants={{
hidden: { opacity: 0, x: 10 },
visible: { opacity: 1, x: 0 },
}}
transition={{ duration: 0.1 }}
initial="hidden"
animate={formState.isDirty ? "visible" : "hidden"}
htmlType="submit"
loading={formState.isSubmitting}
type="primary"
>
{t("save")}
</MotionButton>
</div>
<div className="divide-y">
<div className="flex p-4 pr-8">
<label htmlFor="name" className="w-1/3 text-slate-500">
{t("name")}
</label>
<div className="w-2/3">
<TextInput
id="name"
className="input w-full"
placeholder="Jessie"
readOnly={formState.isSubmitting}
error={!!formState.errors.name}
{...register("name", {
validate: requiredString,
})}
/>
{formState.errors.name ? (
<div className="mt-1 text-sm text-rose-500">
{t("requiredNameError")}
</div>
) : null}
</div>
</div>
<div className="flex p-4 pr-8">
<label htmlFor="random-8904" className="w-1/3 text-slate-500">
{t("email")}
</label>
<div className="w-2/3">
<TextInput
id="random-8904"
className="input w-full"
placeholder="[email protected]"
disabled={true}
{...register("email", { validate: validEmail })}
/>
</div>
</div>
</div>
</form>
);
};
17 changes: 13 additions & 4 deletions src/components/session.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IronSessionData } from "iron-session";
import React from "react";
import toast from "react-hot-toast";

import { trpc } from "@/utils/trpc";

Expand All @@ -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;
Expand All @@ -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);
},
});
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit 3384c93

Please sign in to comment.