feat(dashboard): Log in, reset password, and accept invite pages (#6310)

This commit is contained in:
Kasper Fabricius Kristensen
2024-02-05 11:16:10 +01:00
committed by GitHub
parent b1276cfcd5
commit 73fd92a1af
23 changed files with 1342 additions and 128 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/ui": minor
---
feat(ui): Add Alert component.

View File

@@ -28,6 +28,7 @@
"@uiw/react-json-view": "2.0.0-alpha.10",
"cmdk": "^0.2.0",
"date-fns": "^3.2.0",
"framer-motion": "^11.0.3",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
@@ -36,6 +37,7 @@
"react-dom": "18.2.0",
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
"react-router-dom": "6.20.1",
"zod": "3.22.4"
},
@@ -48,8 +50,8 @@
"@types/react": "18.2.43",
"@types/react-dom": "18.2.17",
"@vitejs/plugin-react": "4.2.1",
"autoprefixer": "10.4.16",
"postcss": "8.4.32",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"prettier": "^3.1.1",
"tailwindcss": "^3.4.1",
"typescript": "5.2.2",

View File

@@ -239,6 +239,43 @@
"createdBy": "Created by",
"revokedBy": "Revoked by"
},
"login": {
"forgotPassword": "Forgot password? - <0>Reset</0>",
"title": "Log in",
"hint": "to continue to Medusa"
},
"invite": {
"title": "Create your account",
"hint": "to continue to Medusa",
"createAccount": "Create account",
"alreadyHaveAccount": "Already have an account? - <0>Log in</0>",
"emailTooltip": "Your email cannot be changed. If you would like to use another email, a new invite must be sent.",
"invalidInvite": "The invite is invalid or has expired.",
"successTitle": "Your account has been created",
"successHint": "Get started with Medusa Admin right away.",
"successAction": "Start using Medusa",
"invalidTokenTitle": "Your invite token is invalid",
"invalidTokenHint": "Try requesting a new invite link."
},
"resetPassword": {
"title": "Reset password",
"hint": "Enter your email below, and we will send you instructions on how to reset your password.",
"email": "Email",
"sendResetInstructions": "Send reset instructions",
"backToLogin": "You can always go back - <0>Log in</0>",
"newPasswordHint": "Choose a new password below.",
"invalidTokenTitle": "Your reset token is invalid",
"invalidTokenHint": "Try requesting a new reset link.",
"expiredTokenTitle": "Your reset token has expired",
"goToResetPassword": "Go to Reset Password",
"resetPassword": "Reset password",
"tokenExpiresIn": "Token expires in <0>{{time}}</0> minutes",
"successfulRequest": "We have sent you an email with instructions on how to reset your password. If you don't receive an email, please check your spam folder or try again."
},
"errors": {
"serverError": "Server error - Try again later.",
"invalidCredentials": "Wrong email or password"
},
"fields": {
"name": "Name",
"lastName": "Last Name",
@@ -247,7 +284,10 @@
"description": "Description",
"email": "Email",
"password": "Password",
"repeatPassword": "Repeat Password",
"confirmPassword": "Confirm Password",
"newPassword": "New Password",
"repeatNewPassword": "Repeat New Password",
"categories": "Categories",
"category": "Category",
"collection": "Collection",

View File

@@ -177,7 +177,7 @@ const ErrorMessage = forwardRef<
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg) {
if (!msg || msg === "undefined") {
return null
}

View File

@@ -0,0 +1 @@
export * from "./logo-box"

View File

@@ -0,0 +1,74 @@
import { clx } from "@medusajs/ui"
import { Transition, motion } from "framer-motion"
type LogoBoxProps = {
className?: string
checked?: boolean
containerTransition?: Transition
pathTransition?: Transition
}
export const LogoBox = ({
className,
checked,
containerTransition = {
duration: 0.8,
delay: 0.5,
ease: [0, 0.71, 0.2, 1.01],
},
pathTransition = {
duration: 0.8,
delay: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
},
}: LogoBoxProps) => {
return (
<div
className={clx(
"size-14 bg-ui-button-neutral shadow-buttons-neutral relative flex items-center justify-center rounded-xl",
"after:button-neutral-gradient after:inset-0 after:content-['']",
className
)}
>
{checked && (
<motion.div
className="size-5 absolute -right-[5px] -top-1 flex items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={containerTransition}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<motion.path
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={pathTransition}
/>
</svg>
</motion.div>
)}
<svg
width="36"
height="38"
viewBox="0 0 36 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M30.85 6.16832L22.2453 1.21782C19.4299 -0.405941 15.9801 -0.405941 13.1648 1.21782L4.52043 6.16832C1.74473 7.79208 0 10.802 0 14.0099V23.9505C0 27.198 1.74473 30.1683 4.52043 31.7921L13.1251 36.7822C15.9405 38.4059 19.3903 38.4059 22.2056 36.7822L30.8103 31.7921C33.6257 30.1683 35.3307 27.198 35.3307 23.9505V14.0099C35.41 10.802 33.6653 7.79208 30.85 6.16832ZM17.6852 27.8317C12.8079 27.8317 8.8426 23.8713 8.8426 19C8.8426 14.1287 12.8079 10.1683 17.6852 10.1683C22.5625 10.1683 26.5674 14.1287 26.5674 19C26.5674 23.8713 22.6022 27.8317 17.6852 27.8317Z"
className="fill-ui-button-inverted relative drop-shadow-sm"
/>
</svg>
</div>
)
}

View File

@@ -24,6 +24,7 @@ import {
import { Skeleton } from "../../common/skeleton"
import { queryClient } from "../../../lib/medusa"
import { useSearch } from "../../../providers/search-provider"
import { useSidebar } from "../../../providers/sidebar-provider"
import { useTheme } from "../../../providers/theme-provider"
@@ -35,7 +36,7 @@ export const Shell = ({ children }: PropsWithChildren) => {
<MobileSidebarContainer>{children}</MobileSidebarContainer>
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
</div>
<div className="flex flex-col h-screen w-full overflow-auto">
<div className="flex h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<Gutter>
@@ -73,7 +74,7 @@ const Breadcrumbs = () => {
})
return (
<ol className={clx("text-ui-fg-muted flex items-center select-none")}>
<ol className={clx("text-ui-fg-muted flex select-none items-center")}>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
const isSingle = crumbs.length === 1
@@ -106,7 +107,7 @@ const Breadcrumbs = () => {
</div>
)}
{/* {!isLast && <TriangleRightMini className="-mt-0.5 mx-2" />} */}
{!isLast && <span className="-mt-0.5 mx-2"></span>}
{!isLast && <span className="mx-2 -mt-0.5"></span>}
</li>
)
})}
@@ -115,18 +116,22 @@ const Breadcrumbs = () => {
}
const UserBadge = () => {
const { user, isError, error } = useAdminGetSession()
const { user, isLoading, isError, error } = useAdminGetSession()
const displayName = user
? user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.first_name
? user.first_name
: user.email
: null
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ")
const displayName = name || user?.email
const fallback = displayName ? displayName[0].toUpperCase() : null
if (isLoading) {
return (
<button className="shadow-borders-base flex max-w-[192px] select-none items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-[9px] w-[70px]" />
</button>
)
}
if (isError) {
throw error
}
@@ -136,13 +141,13 @@ const UserBadge = () => {
<button
disabled={!user}
className={clx(
"shadow-borders-base flex max-w-[192px] items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5 select-none"
"shadow-borders-base flex max-w-[192px] select-none items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5"
)}
>
{fallback ? (
<Avatar size="xsmall" fallback={fallback} />
) : (
<Skeleton className="w-5 h-5 rounded-full" />
<Skeleton className="h-5 w-5 rounded-full" />
)}
{displayName ? (
<Text
@@ -154,7 +159,7 @@ const UserBadge = () => {
{displayName}
</Text>
) : (
<Skeleton className="w-[70px] h-[9px]" />
<Skeleton className="h-[9px] w-[70px]" />
)}
</button>
</DropdownMenu.Trigger>
@@ -203,6 +208,11 @@ const Logout = () => {
const handleLayout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
/**
* When the user logs out, we want to clear the query cache
*/
queryClient.clear()
navigate("/login")
},
})
@@ -297,7 +307,7 @@ const Searchbar = () => {
return (
<button
onClick={toggleSearch}
className="shadow-borders-base bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover transition-fg focus-visible:shadow-borders-focus text-ui-fg-muted flex w-full max-w-[280px] items-center gap-x-2 rounded-full py-1.5 pl-2 pr-1.5 outline-none select-none"
className="shadow-borders-base bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover transition-fg focus-visible:shadow-borders-focus text-ui-fg-muted flex w-full max-w-[280px] select-none items-center gap-x-2 rounded-full py-1.5 pl-2 pr-1.5 outline-none"
>
<MagnifyingGlass />
<div className="flex-1 text-left">
@@ -335,7 +345,7 @@ const ToggleSidebar = () => {
const Topbar = () => {
return (
<div className="w-full grid-cols-3 border-b p-3 grid">
<div className="grid w-full grid-cols-3 border-b p-3">
<div className="flex items-center gap-x-1.5">
<ToggleSidebar />
<Breadcrumbs />
@@ -374,8 +384,8 @@ const MobileSidebarContainer = ({ children }: PropsWithChildren) => {
return (
<Dialog.Root open={mobile} onOpenChange={() => toggle("mobile")}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-ui-bg-overlay" />
<Dialog.Content className="h-screen fixed left-0 inset-y-0 w-[220px] border-r bg-ui-bg-subtle">
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-subtle fixed inset-y-0 left-0 h-screen w-[220px] border-r">
{children}
</Dialog.Content>
</Dialog.Portal>

View File

@@ -18,7 +18,6 @@ import {
import { ProtectedRoute } from "../../components/authentication/require-auth"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout/main-layout"
import { PublicLayout } from "../../components/layout/public-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import routes from "medusa-admin:routes/pages"
@@ -46,14 +45,28 @@ const settingsExtensions: RouteObject[] = settings.pages.map((ext) => {
const router = createBrowserRouter([
{
element: <PublicLayout />,
path: "/login",
lazy: () => import("../../routes/login"),
},
{
path: "/reset-password",
element: <Outlet />,
children: [
{
path: "/login",
lazy: () => import("../../routes/login"),
index: true,
lazy: () =>
import("../../routes/reset-password/reset-password-request"),
},
{
path: ":token",
lazy: () => import("../../routes/reset-password/reset-password-token"),
},
],
},
{
path: "/invite",
lazy: () => import("../../routes/invite"),
},
{
element: <ProtectedRoute />,
errorElement: <ErrorBoundary />,

View File

@@ -0,0 +1 @@
export { Invite as Component } from "./invite"

View File

@@ -0,0 +1,386 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { UserRoles } from "@medusajs/medusa"
import { Alert, Button, Heading, Input, Text, Tooltip } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import { useAdminAcceptInvite } from "medusa-react"
import { Trans, useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { decodeToken } from "react-jwt"
import { Form } from "../../components/common/form"
import { LogoBox } from "../../components/common/logo-box"
import { isAxiosError } from "../../lib/is-axios-error"
const CreateAccountSchema = z
.object({
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
password: z.string().min(1),
repeat_password: z.string().min(1),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["repeat_password"],
})
}
})
type DecodedInvite = {
invite_id: string
role: UserRoles
user_email: string
iat: number
}
export const Invite = () => {
const [searchParams] = useSearchParams()
const [success, setSuccess] = useState(false)
const token = searchParams.get("token")
const invite: DecodedInvite | null = token ? decodeToken(token) : null
const isValidInvite = invite && validateDecodedInvite(invite)
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base relative flex items-center justify-center p-4">
<div className="flex w-full max-w-[300px] flex-col items-center">
<LogoBox
className="mb-4"
checked={success}
containerTransition={{
duration: 1.2,
delay: 0.8,
ease: [0, 0.71, 0.2, 1.01],
}}
pathTransition={{
duration: 1.3,
delay: 1.1,
bounce: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
}}
/>
<div className="max-h-[557px] w-full will-change-contents">
{isValidInvite ? (
<AnimatePresence>
{!success ? (
<motion.div
key="create-account"
initial={false}
animate={{
height: "557px",
y: 0,
}}
exit={{
height: 0,
y: 40,
}}
transition={{
duration: 0.8,
delay: 0.6,
ease: [0, 0.71, 0.2, 1.01],
}}
className="w-full will-change-transform"
>
<motion.div
initial={false}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.7,
}}
transition={{
duration: 0.6,
delay: 0,
ease: [0, 0.71, 0.2, 1.01],
}}
key="inner-create-account"
>
<CreateView
onSuccess={() => setSuccess(true)}
token={token!}
invite={invite}
/>
</motion.div>
</motion.div>
) : (
<motion.div
key="success-view"
initial={{
opacity: 0,
scale: 0.4,
}}
animate={{
opacity: 1,
scale: 1,
}}
transition={{
duration: 1,
delay: 0.6,
ease: [0, 0.71, 0.2, 1.01],
}}
className="w-full"
>
<SuccessView />
</motion.div>
)}
</AnimatePresence>
) : (
<InvalidView />
)}
</div>
</div>
</div>
)
}
const LoginLink = () => {
const { t } = useTranslation()
return (
<div className="flex w-full flex-col items-center">
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
t={t}
i18nKey="invite.alreadyHaveAccount"
components={[
<Link
key="login-link"
to="/login"
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
/>,
]}
/>
</span>
</div>
)
}
const InvalidView = () => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center">
<div className="flex flex-col items-center gap-y-1">
<Heading>{t("invite.invalidTokenTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.invalidTokenHint")}
</Text>
</div>
<LoginLink />
</div>
)
}
const CreateView = ({
onSuccess,
token,
invite,
}: {
onSuccess: () => void
token: string
invite: DecodedInvite
}) => {
const { t } = useTranslation()
const [invalid, setInvalid] = useState(false)
const form = useForm<z.infer<typeof CreateAccountSchema>>({
resolver: zodResolver(CreateAccountSchema),
defaultValues: {
email: invite.user_email,
first_name: "",
last_name: "",
password: "",
repeat_password: "",
},
})
const { mutateAsync, isLoading } = useAdminAcceptInvite()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
user: {
first_name: data.first_name,
last_name: data.last_name,
password: data.password,
},
token: token!,
},
{
onSuccess,
onError: (error) => {
if (isAxiosError(error) && error.response?.status === 400) {
form.setError("root", {
type: "manual",
message: t("invite.invalidInvite"),
})
setInvalid(true)
return
}
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
return (
<div className="flex w-full flex-col items-center">
<div className="mb-4 flex flex-col items-center">
<Heading>{t("invite.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.hint")}
</Text>
</div>
<Form {...form}>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-y-6">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Tooltip content={t("invite.emailTooltip")}>
<Input autoComplete="off" {...field} disabled />
</Tooltip>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="first_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.firstName")}</Form.Label>
<Form.Control>
<Input autoComplete="given-name" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="last_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.lastName")}</Form.Label>
<Form.Control>
<Input autoComplete="family-name" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.password")}</Form.Label>
<Form.Control>
<Input
autoComplete="new-password"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="repeat_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.repeatPassword")}</Form.Label>
<Form.Control>
<Input autoComplete="off" type="password" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{form.formState.errors.root && (
<Alert
variant="error"
dismissible={false}
className="text-balance"
>
{form.formState.errors.root.message}
</Alert>
)}
</div>
<Button
className="w-full"
type="submit"
isLoading={isLoading}
disabled={invalid}
>
{t("invite.createAccount")}
</Button>
</form>
</Form>
<LoginLink />
</div>
)
}
const SuccessView = () => {
const { t } = useTranslation()
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="flex flex-col items-center gap-y-1">
<Heading>{t("invite.successTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.successHint")}
</Text>
</div>
<Button variant="secondary" asChild className="w-full">
<Link to="/orders" replace>
{t("invite.successAction")}
</Link>
</Button>
</div>
)
}
const InviteSchema = z.object({
invite_id: z.string(),
role: z.string(),
user_email: z.string().email(),
iat: z.number(),
})
const validateDecodedInvite = (decoded: any): decoded is DecodedInvite => {
return InviteSchema.safeParse(decoded).success
}

View File

@@ -1,11 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminGetSession, useAdminLogin } from "medusa-react"
import { useAdminLogin } from "medusa-react"
import { useForm } from "react-hook-form"
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"
import { Trans, useTranslation } from "react-i18next"
import { Link, useLocation, useNavigate } from "react-router-dom"
import * as z from "zod"
import { Form } from "../../components/common/form"
import { LogoBox } from "../../components/common/logo-box"
import { isAxiosError } from "../../lib/is-axios-error"
const LoginSchema = z.object({
@@ -14,10 +16,11 @@ const LoginSchema = z.object({
})
export const Login = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const from = location.state?.from?.pathname || "/"
const from = location.state?.from?.pathname || "/orders"
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
@@ -27,94 +30,112 @@ export const Login = () => {
},
})
const { user, isLoading } = useAdminGetSession()
const { mutateAsync } = useAdminLogin({
const { mutateAsync, isLoading } = useAdminLogin({
retry: false,
})
const onSubmit = form.handleSubmit(async ({ email, password }) => {
const handleSubmit = form.handleSubmit(async ({ email, password }) => {
await mutateAsync(
{
email,
password,
},
{ email, password },
{
onSuccess: () => {
navigate(from, { replace: true })
},
onError: (e) => {
if (isAxiosError(e)) {
if (e.response?.status === 401) {
onError: (error) => {
if (isAxiosError(error)) {
if (error.response?.status === 401) {
form.setError("email", {
type: "manual",
})
form.setError("password", {
type: "manual",
message: "Invalid email or password",
message: t("errors.invalidCredentials"),
})
return
}
}
form.setError("password", {
form.setError("root.serverError", {
type: "manual",
message: "Something went wrong",
message: t("errors.serverError"),
})
},
}
)
})
if (user && !isLoading) {
return <Navigate to={from} replace />
}
return (
<div className="flex flex-col gap-y-12">
<div className="flex flex-col items-center">
<Heading>Login</Heading>
<Text>Welcome back. Login to get started!</Text>
<div className="min-h-dvh w-dvw bg-ui-bg-base flex items-center justify-center">
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
<LogoBox className="mb-4" />
<div className="mb-4 flex flex-col items-center">
<Heading>{t("login.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("login.hint")}
</Text>
</div>
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="email" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.password")}</Form.Label>
<Form.Control>
<Input
type="password"
autoComplete="current-password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Button className="w-full" type="submit" isLoading={isLoading}>
{t("general.continue")}
</Button>
</form>
</Form>
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
i18nKey="login.forgotPassword"
components={[
<Link
key="reset-password-link"
to="/reset-password"
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
/>,
]}
/>
</span>
</div>
<Form {...form}>
<form onSubmit={onSubmit} className="flex flex-col gap-y-12">
<Form.Field
control={form.control}
name="email"
render={({ field }) => (
<Form.Item>
<Form.Label>Email</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>Password</Form.Label>
<Link
to={"/forgot-password"}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover transition-fg outline-none"
>
<Text leading="compact">Forgot password?</Text>
</Link>
</div>
<Form.Control>
<Input type="password" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Button size="large" variant="secondary" className="w-full">
Submit
</Button>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1 @@
export { ResetPassword as Component } from "../reset-password-request/reset-password"

View File

@@ -0,0 +1,143 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Alert, Button, Heading, Input, Text } from "@medusajs/ui"
import { motion } from "framer-motion"
import { useAdminSendResetPasswordToken } from "medusa-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod"
import { Form } from "../../../components/common/form"
import { LogoBox } from "../../../components/common/logo-box"
const ResetPasswordSchema = z.object({
email: z.string().email(),
})
export const ResetPassword = () => {
const { t } = useTranslation()
const [success, setSuccess] = useState(false)
const [searchParams] = useSearchParams()
const defaultValue = searchParams.get("email") || ""
const form = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: defaultValue,
},
})
const { mutateAsync } = useAdminSendResetPasswordToken({
retry: false,
})
const handleSubmit = form.handleSubmit(async ({ email }) => {
await mutateAsync(
{ email },
{
onSuccess: () => {
setSuccess(true)
},
onError: (_error) => {
/**
* This endpoint is not supposed to return any errors.
* If it does, we can assume it's a server error.
*/
setSuccess(false)
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base flex items-center justify-center">
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
<LogoBox className="mb-4" checked={success} />
<div className="mb-4 flex flex-col items-center">
<Heading>{t("resetPassword.title")}</Heading>
<Text
size="small"
className="text-ui-fg-subtle text-pretty text-center"
>
{t("resetPassword.hint")}
</Text>
</div>
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="email" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{success && (
<motion.div
initial={{
opacity: 0,
height: 0,
y: 20,
}}
animate={{
opacity: 1,
height: "auto",
y: 0,
}}
transition={{
duration: 0.5,
delay: 0.2,
ease: [0, 0.71, 0.2, 1.01],
}}
layout
>
<Alert variant="success" dismissible className="mt-4">
{t("resetPassword.successfulRequest")}
</Alert>
</motion.div>
)}
{form.formState.errors.root && (
<Alert variant="error" dismissible className="mt-4">
{form.formState.errors.root.message}
</Alert>
)}
</div>
<Button className="w-full" type="submit">
{t("resetPassword.sendResetInstructions")}
</Button>
</form>
</Form>
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
i18nKey="resetPassword.backToLogin"
components={[
<Link
key="login-link"
to="/login"
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
/>,
]}
/>
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { ResetPasswordToken as Component } from "./reset-password-token"

View File

@@ -0,0 +1,258 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminResetPassword } from "medusa-react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { decodeToken } from "react-jwt"
import { Link, Navigate, useNavigate, useParams } from "react-router-dom"
import * as z from "zod"
import { Clock } from "@medusajs/icons"
import { useEffect, useState } from "react"
import { Form } from "../../../components/common/form"
import { LogoBox } from "../../../components/common/logo-box"
const ResetPasswordTokenSchema = z
.object({
email: z.string().email(),
new_password: z.string(),
repeat_new_password: z.string(),
})
.superRefine(({ new_password, repeat_new_password }, ctx) => {
if (new_password !== repeat_new_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["repeat_new_password"],
})
}
})
export const ResetPasswordToken = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { token } = useParams()
let tokenStatus: "valid" | "invalid" | "expired" = "valid"
const decodedToken: { email: string; exp: number } | null = token
? decodeToken(token)
: null
if (!decodedToken) {
tokenStatus = "invalid"
}
const expiresAt = decodedToken ? new Date(decodedToken.exp * 1000) : null
if (expiresAt && expiresAt < new Date()) {
tokenStatus = "expired"
}
const redirectLink = `/reset-password${
decodedToken ? `?email=${decodedToken.email}` : ""
}`
const form = useForm<z.infer<typeof ResetPasswordTokenSchema>>({
resolver: zodResolver(ResetPasswordTokenSchema),
defaultValues: {
email: decodedToken?.email || "",
new_password: "",
repeat_new_password: "",
},
})
const { mutateAsync, isLoading } = useAdminResetPassword({
retry: false,
})
const handleSubmit = form.handleSubmit(async ({ new_password }) => {
await mutateAsync(
{ password: new_password, token: token! },
{
onSuccess: () => {
navigate("/orders")
},
onError: (_error) => {
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
const [title, hint] = {
valid: [t("resetPassword.title"), t("resetPassword.newPasswordHint")],
invalid: [
t("resetPassword.invalidTokenTitle"),
t("resetPassword.invalidTokenHint"),
],
expired: [
t("resetPassword.expiredTokenTitle"),
t("resetPassword.invalidTokenHint"),
],
}[tokenStatus]
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base flex items-center justify-center">
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
<LogoBox className="mb-4" />
<div className="mb-4 flex flex-col items-center">
<Heading>{title}</Heading>
<Text
size="small"
className="text-ui-fg-subtle text-pretty text-center"
>
{hint}
</Text>
</div>
{tokenStatus === "valid" ? (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="off" {...field} disabled />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="new_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.newPassword")}</Form.Label>
<Form.Control>
<Input
autoComplete="new-password"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="repeat_new_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.repeatNewPassword")}</Form.Label>
<Form.Control>
<Input
autoComplete="off"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{form.formState.errors.root && (
<span>{form.formState.errors.root.message}</span>
)}
</div>
<Button className="w-full" type="submit" isLoading={isLoading}>
{t("resetPassword.resetPassword")}
</Button>
{expiresAt && <Countdown expiresAt={expiresAt} />}
</form>
</Form>
) : (
<Button variant="secondary" asChild className="w-full">
<Link to={redirectLink} replace>
{t("resetPassword.goToResetPassword")}
</Link>
</Button>
)}
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
t={t}
i18nKey="resetPassword.backToLogin"
components={[
<Link
key="login-link"
to="/login"
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
/>,
]}
/>
</span>
</div>
</div>
)
}
const Countdown = ({ expiresAt }: { expiresAt: Date }) => {
const { t } = useTranslation()
const [timeLeft, setTimeLeft] = useState<number>(
Math.floor((expiresAt.getTime() - Date.now()) / 1000)
)
// Total time is 15 minutes
const totalTime = 15 * 60
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft(Math.floor((expiresAt.getTime() - Date.now()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [expiresAt])
const minutes = Math.floor(timeLeft / 60)
const seconds = timeLeft % 60
const timespan = `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`
const percentageLeft = ((timeLeft / totalTime) * 100).toFixed(2)
const isExpired = timeLeft <= 0
if (isExpired) {
return <Navigate to="." replace />
}
return (
<div className="bg-ui-bg-subtle border-ui-border-base txt-compact-small relative w-full rounded-lg border p-2">
<div className="absolute inset-0 flex overflow-hidden rounded-lg">
<div
className="bg-ui-bg-subtle-hover h-full transition-[width]"
style={{
width: `${percentageLeft}%`,
}}
/>
</div>
<div className="text-ui-fg-subtle relative z-10 flex items-center gap-x-2">
<Clock className="text-ui-tag-neutral-icon" />
<span>
<Trans
t={t}
i18nKey={"resetPassword.tokenExpiresIn"}
values={{ time: timespan }}
components={[<span key="countdown" className="tabular-nums" />]}
/>
</span>
</div>
</div>
)
}

View File

@@ -60,12 +60,12 @@
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^0.32.2",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.17",
"chromatic": "^6.20.0",
"eslint": "^7.32.0",
"eslint-plugin-storybook": "^0.6.12",
"jsdom": "^22.1.0",
"postcss": "^8.4.24",
"postcss": "^8.4.33",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -107,7 +107,7 @@
"prism-react-renderer": "^2.0.6",
"react-currency-input-field": "^3.6.11",
"react-day-picker": "^8.8.0",
"tailwind-merge": "^1.13.2"
"tailwind-merge": "^2.2.1"
},
"peerDependencies": {
"react": "^18.0.0",

View File

@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Alert } from "./alert"
const meta: Meta<typeof Alert> = {
title: "Components/Alert",
component: Alert,
argTypes: {
variant: {
control: {
type: "select",
options: ["info", "error", "success", "warning"],
},
},
dismissible: {
control: {
type: "boolean",
},
},
},
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Alert>
const Text =
"We have sent you an email with instructions on how to reset your password. If you don't receive an email, please check your spam folder or try again."
export const Info: Story = {
args: {
variant: "info",
children: Text,
},
render: (args) => (
<div className="max-w-[300px]">
<Alert {...args} />
</div>
),
}
export const Error: Story = {
args: {
variant: "error",
children: Text,
},
render: (args) => (
<div className="max-w-[300px]">
<Alert {...args} />
</div>
),
}
export const Success: Story = {
args: {
variant: "success",
children: Text,
},
render: (args) => (
<div className="max-w-[300px]">
<Alert {...args} />
</div>
),
}
export const Warning: Story = {
args: {
variant: "warning",
children: Text,
},
render: (args) => (
<div className="max-w-[300px]">
<Alert {...args} />
</div>
),
}
export const Dismissible: Story = {
args: {
dismissible: true,
children: Text,
},
render: (args) => (
<div className="max-w-[300px]">
<Alert {...args} />
</div>
),
}

View File

@@ -0,0 +1,92 @@
import { clx } from "@/utils/clx"
import {
CheckCircleSolid,
ExclamationCircleSolid,
InformationCircleSolid,
XCircleSolid,
XMarkMini,
} from "@medusajs/icons"
import * as React from "react"
import { IconButton } from "@/components/icon-button"
interface AlertProps extends React.ComponentPropsWithoutRef<"div"> {
variant?: "error" | "success" | "warning" | "info"
dismissible?: boolean
}
/**
* This component is based on the div element and supports all of its props
*
* @excludeExternal
*/
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
(
{
/**
* The variant of the alert
*/
variant = "info",
/**
* Whether the alert is dismissible
*/
dismissible = false,
className,
children,
...props
},
ref
) => {
const [dismissed, setDismissed] = React.useState(false)
const Icon = {
info: InformationCircleSolid,
error: XCircleSolid,
success: CheckCircleSolid,
warning: ExclamationCircleSolid,
}[variant]
const handleDismiss = () => {
setDismissed(true)
}
if (dismissed) {
return null
}
return (
<div
ref={ref}
className={clx(
"bg-ui-bg-subtle text-pretty txt-compact-small grid items-start gap-x-3 rounded-lg border p-3",
{
"grid-cols-[20px_1fr]": !dismissible,
"grid-cols-[20px_1fr_20px]": dismissible,
},
className
)}
{...props}
>
<Icon
className={clx({
"text-ui-tag-red-icon": variant === "error",
"text-ui-tag-green-icon": variant === "success",
"text-ui-tag-orange-icon": variant === "warning",
"text-ui-tag-neutral-icon": variant === "info",
})}
/>
<div>{children}</div>
{dismissible && (
<IconButton
size="2xsmall"
variant="transparent"
type="button"
onClick={handleDismiss}
>
<XMarkMini className="text-ui-fg-muted" />
</IconButton>
)}
</div>
)
}
)

View File

@@ -0,0 +1 @@
export * from "./alert"

View File

@@ -5,7 +5,7 @@ import * as React from "react"
import { clx } from "../../utils/clx"
const hintVariants = cva({
base: "txt-small inline-flex items-center gap-x-2",
base: "txt-small inline-flex items-start gap-x-2",
variants: {
variant: {
info: "text-ui-fg-subtle",

View File

@@ -28,6 +28,8 @@ const iconButtonVariants = cva({
),
},
size: {
"2xsmall": "h-5 w-5",
xsmall: "h-6 w-6 p-1",
small: "h-7 w-7 p-1",
base: "h-8 w-8 p-1.5",
large: "h-10 w-10 p-2.5",

View File

@@ -1,3 +1,4 @@
export { Alert } from "./components/alert"
export { Avatar } from "./components/avatar"
export { Badge } from "./components/badge"
export { Button } from "./components/button"
@@ -21,6 +22,7 @@ export { IconButton } from "./components/icon-button"
export { Input } from "./components/input"
export { Kbd } from "./components/kbd"
export { Label } from "./components/label"
export { Popover } from "./components/popover"
export { ProgressAccordion } from "./components/progress-accordion"
export { ProgressTabs } from "./components/progress-tabs"
export { Prompt } from "./components/prompt"
@@ -35,7 +37,6 @@ export { Textarea } from "./components/textarea"
export { Toast } from "./components/toast"
export { Toaster } from "./components/toaster"
export { Tooltip } from "./components/tooltip"
export { Popover } from "./components/popover"
// Hooks
export { usePrompt } from "./hooks/use-prompt"

132
yarn.lock
View File

@@ -3848,6 +3848,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.23.7":
version: 7.23.9
resolution: "@babel/runtime@npm:7.23.9"
dependencies:
regenerator-runtime: ^0.14.0
checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d
languageName: node
linkType: hard
"@babel/template@npm:^7.12.13, @babel/template@npm:^7.12.7, @babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3":
version: 7.22.5
resolution: "@babel/template@npm:7.22.5"
@@ -8058,19 +8067,21 @@ __metadata:
"@types/react-dom": 18.2.17
"@uiw/react-json-view": 2.0.0-alpha.10
"@vitejs/plugin-react": 4.2.1
autoprefixer: 10.4.16
autoprefixer: ^10.4.17
cmdk: ^0.2.0
date-fns: ^3.2.0
framer-motion: ^11.0.3
i18next: 23.7.11
i18next-browser-languagedetector: 7.2.0
i18next-http-backend: 2.4.2
medusa-react: "workspace:^"
postcss: 8.4.32
postcss: ^8.4.33
prettier: ^3.1.1
react: 18.2.0
react-dom: 18.2.0
react-hook-form: 7.49.1
react-i18next: 13.5.0
react-jwt: ^1.2.0
react-router-dom: 6.20.1
tailwindcss: ^3.4.1
typescript: 5.2.2
@@ -8708,7 +8719,7 @@ __metadata:
"@types/react-dom": ^18.2.0
"@vitejs/plugin-react": ^4.0.1
"@vitest/coverage-v8": ^0.32.2
autoprefixer: ^10.4.14
autoprefixer: ^10.4.17
chromatic: ^6.20.0
clsx: ^1.2.1
copy-to-clipboard: ^3.3.3
@@ -8717,7 +8728,7 @@ __metadata:
eslint: ^7.32.0
eslint-plugin-storybook: ^0.6.12
jsdom: ^22.1.0
postcss: ^8.4.24
postcss: ^8.4.33
prism-react-renderer: ^2.0.6
prop-types: ^15.8.1
react: ^18.2.0
@@ -8727,7 +8738,7 @@ __metadata:
resize-observer-polyfill: ^1.5.1
rimraf: ^5.0.1
storybook: ^7.0.23
tailwind-merge: ^1.13.2
tailwind-merge: ^2.2.1
tailwindcss: ^3.4.1
tsc-alias: ^1.8.7
typescript: ^5.1.6
@@ -20344,7 +20355,25 @@ __metadata:
languageName: node
linkType: hard
"autoprefixer@npm:10.4.16, autoprefixer@npm:^10.4.16":
"autoprefixer@npm:^10.1.0, autoprefixer@npm:^10.4.0, autoprefixer@npm:^10.4.13, autoprefixer@npm:^10.4.14":
version: 10.4.14
resolution: "autoprefixer@npm:10.4.14"
dependencies:
browserslist: ^4.21.5
caniuse-lite: ^1.0.30001464
fraction.js: ^4.2.0
normalize-range: ^0.1.2
picocolors: ^1.0.0
postcss-value-parser: ^4.2.0
peerDependencies:
postcss: ^8.1.0
bin:
autoprefixer: bin/autoprefixer
checksum: 66ce961b86acd2a46e05ac1eece8657b3d9edfd2ee3abddd6cfcb32755e6865409f57acf11fe05990d6f166afda85a603678435916267a09652265cfff7b5706
languageName: node
linkType: hard
"autoprefixer@npm:^10.4.16":
version: 10.4.16
resolution: "autoprefixer@npm:10.4.16"
dependencies:
@@ -20362,13 +20391,13 @@ __metadata:
languageName: node
linkType: hard
"autoprefixer@npm:^10.1.0, autoprefixer@npm:^10.4.0, autoprefixer@npm:^10.4.13, autoprefixer@npm:^10.4.14":
version: 10.4.14
resolution: "autoprefixer@npm:10.4.14"
"autoprefixer@npm:^10.4.17":
version: 10.4.17
resolution: "autoprefixer@npm:10.4.17"
dependencies:
browserslist: ^4.21.5
caniuse-lite: ^1.0.30001464
fraction.js: ^4.2.0
browserslist: ^4.22.2
caniuse-lite: ^1.0.30001578
fraction.js: ^4.3.7
normalize-range: ^0.1.2
picocolors: ^1.0.0
postcss-value-parser: ^4.2.0
@@ -20376,7 +20405,7 @@ __metadata:
postcss: ^8.1.0
bin:
autoprefixer: bin/autoprefixer
checksum: 66ce961b86acd2a46e05ac1eece8657b3d9edfd2ee3abddd6cfcb32755e6865409f57acf11fe05990d6f166afda85a603678435916267a09652265cfff7b5706
checksum: 1d21cc8edb7bf993682094ceed03a32c18f5293f071182a64c2c6defb44bbe91d576ad775d2347469a81997b80cea0bbc4ad3eeb5b12710f9feacf2e6c04bb51
languageName: node
linkType: hard
@@ -22292,6 +22321,13 @@ __metadata:
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001578":
version: 1.0.30001583
resolution: "caniuse-lite@npm:1.0.30001583"
checksum: 9ce61437e7a0c8e69bdb3480962dca7d7a9e9fa8d7c56ae411454d8b101d7c83d71f6c7fa74450c131f6c14e35e347042b3e1787b15edbfaba0e05fd75ab2625
languageName: node
linkType: hard
"capital-case@npm:^1.0.4":
version: 1.0.4
resolution: "capital-case@npm:1.0.4"
@@ -28530,7 +28566,7 @@ __metadata:
languageName: node
linkType: hard
"fraction.js@npm:^4.3.6":
"fraction.js@npm:^4.3.6, fraction.js@npm:^4.3.7":
version: 4.3.7
resolution: "fraction.js@npm:4.3.7"
checksum: df291391beea9ab4c263487ffd9d17fed162dbb736982dee1379b2a8cc94e4e24e46ed508c6d278aded9080ba51872f1bc5f3a5fd8d7c74e5f105b508ac28711
@@ -28546,6 +28582,27 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^11.0.3":
version: 11.0.3
resolution: "framer-motion@npm:11.0.3"
dependencies:
"@emotion/is-prop-valid": ^0.8.2
tslib: ^2.4.0
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependenciesMeta:
"@emotion/is-prop-valid":
optional: true
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
checksum: a9a70196ea5d264f749a7dea1ccb7c33decf969680a907c595eb48d84510b60fb51e3e0d16d5bcac0997eea041f796f2cba4a5505e0a00f373ffbadfb5f36564
languageName: node
linkType: hard
"framer-motion@npm:^9.1.6":
version: 9.1.7
resolution: "framer-motion@npm:9.1.7"
@@ -42103,7 +42160,7 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:8.4.31, postcss@npm:^8.2.14, postcss@npm:^8.4.23, postcss@npm:^8.4.24, postcss@npm:^8.4.27":
"postcss@npm:8.4.31, postcss@npm:^8.2.14, postcss@npm:^8.4.23, postcss@npm:^8.4.27":
version: 8.4.31
resolution: "postcss@npm:8.4.31"
dependencies:
@@ -42114,17 +42171,6 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:8.4.32, postcss@npm:^8.4.32":
version: 8.4.32
resolution: "postcss@npm:8.4.32"
dependencies:
nanoid: ^3.3.7
picocolors: ^1.0.0
source-map-js: ^1.0.2
checksum: 39308a9195fa34d4dbdd7b58a896cff0c7809f84f7a4ac1b95b68ca86c9138a395addff33075668ed3983d41b90aac05754c445237a9365eb1c3a5602ebd03ad
languageName: node
linkType: hard
"postcss@npm:^7.0.14, postcss@npm:^7.0.26, postcss@npm:^7.0.32, postcss@npm:^7.0.36, postcss@npm:^7.0.5, postcss@npm:^7.0.6":
version: 7.0.39
resolution: "postcss@npm:7.0.39"
@@ -42146,6 +42192,28 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.32":
version: 8.4.32
resolution: "postcss@npm:8.4.32"
dependencies:
nanoid: ^3.3.7
picocolors: ^1.0.0
source-map-js: ^1.0.2
checksum: 39308a9195fa34d4dbdd7b58a896cff0c7809f84f7a4ac1b95b68ca86c9138a395addff33075668ed3983d41b90aac05754c445237a9365eb1c3a5602ebd03ad
languageName: node
linkType: hard
"postcss@npm:^8.4.33":
version: 8.4.33
resolution: "postcss@npm:8.4.33"
dependencies:
nanoid: ^3.3.7
picocolors: ^1.0.0
source-map-js: ^1.0.2
checksum: 16eda83458fcd8a91bece287b5920c7f57164c3ea293e6c80d0ea71ce7843007bcd8592260a5160b9a7f02693e6ac93e2495b02d8c7596d3f3f72c1447e3ba79
languageName: node
linkType: hard
"postgres-array@npm:~2.0.0":
version: 2.0.0
resolution: "postgres-array@npm:2.0.0"
@@ -43577,7 +43645,7 @@ __metadata:
languageName: node
linkType: hard
"react-jwt@npm:^1.1.4":
"react-jwt@npm:^1.1.4, react-jwt@npm:^1.2.0":
version: 1.2.0
resolution: "react-jwt@npm:1.2.0"
dependencies:
@@ -47873,10 +47941,12 @@ __metadata:
languageName: node
linkType: hard
"tailwind-merge@npm:^1.13.2":
version: 1.13.2
resolution: "tailwind-merge@npm:1.13.2"
checksum: 0b4bac1c45769c099336ec767fad6e3ed559e9037d3e70bfafd820b2f8139dbaf6dec03e24b1a60ad38dceaa0272573175472ae7498fd745e97792195ea184f6
"tailwind-merge@npm:^2.2.1":
version: 2.2.1
resolution: "tailwind-merge@npm:2.2.1"
dependencies:
"@babel/runtime": ^7.23.7
checksum: 14ab965ec897e9377484b7593f7a700dde09b8035b762ad42652622a3ed1f202b203f48c0f235c0b1b38e9390470d94458f6f9010d33a5a18d71b15f38b986a6
languageName: node
linkType: hard