mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 02:06:34 +02:00
Profile page (#190)
This commit is contained in:
parent
d7043891fa
commit
3384c937c0
18 changed files with 441 additions and 38 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 <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"
|
||||
}
|
||||
|
|
|
@ -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<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",
|
||||
|
@ -111,7 +135,7 @@ export const DropdownItem: React.VoidFunctionComponent<{
|
|||
/>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
</Element>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
|
20
src/components/empty-state.tsx
Normal file
20
src/components/empty-state.tsx
Normal file
|
@ -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
src/components/icons/user-circle.svg
Normal file
3
src/components/icons/user-circle.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 326 B |
97
src/components/profile.tsx
Normal file
97
src/components/profile.tsx
Normal file
|
@ -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
src/components/profile/user-details.tsx
Normal file
109
src/components/profile/user-details.tsx
Normal file
|
@ -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="jessie.jackson@example.com"
|
||||
disabled={true}
|
||||
{...register("email", { validate: validEmail })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{user.isGuest ? (
|
||||
<span className="absolute right-0 top-0 h-1 w-1 rounded-full bg-indigo-500" />
|
||||
) : null}
|
||||
<User className="w-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
|
||||
<UserCircle className="w-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="hidden max-w-[120px] truncate font-medium xs:block">
|
||||
{user.shortName}
|
||||
|
@ -195,6 +193,9 @@ const UserDropdown: React.VoidFunctionComponent<
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!user.isGuest ? (
|
||||
<DropdownItem href="/profile" icon={User} label="Your profile" />
|
||||
) : null}
|
||||
{user.isGuest ? (
|
||||
<DropdownItem icon={Login} label="Login" onClick={openLoginModal} />
|
||||
) : null}
|
||||
|
@ -300,10 +301,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
>
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
<div className="relative">
|
||||
{user.isGuest ? (
|
||||
<span className="absolute right-0 top-0 h-1 w-1 rounded-full bg-indigo-500" />
|
||||
) : null}
|
||||
<User className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
|
||||
<UserCircle className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
<div className="truncate font-medium leading-snug text-slate-600">
|
||||
|
|
26
src/components/text-input.tsx
Normal file
26
src/components/text-input.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
export interface TextInputProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
||||
function TextInput({ className, error, ...forwardProps }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
className={clsx("input", className, {
|
||||
"input-error": error,
|
||||
"bg-slate-50 text-slate-500": forwardProps.disabled,
|
||||
})}
|
||||
{...forwardProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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;
|
||||
|
|
40
src/pages/profile.tsx
Normal file
40
src/pages/profile.tsx
Normal file
|
@ -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 (
|
||||
<SessionProvider session={user}>
|
||||
<Head>
|
||||
<title>Profile - {name}</title>
|
||||
</Head>
|
||||
<StandardLayout>
|
||||
<Profile />
|
||||
</StandardLayout>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
|
@ -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;
|
||||
|
|
68
src/server/routers/user.ts
Normal file
68
src/server/routers/user.ts
Normal file
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"');
|
||||
|
||||
|
|
13
yarn.lock
13
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"
|
||||
|
|
Loading…
Add table
Reference in a new issue