Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add updated error UI + custom error rendering #705

Merged
merged 10 commits into from
Jun 5, 2024
45 changes: 28 additions & 17 deletions examples/ui-demo/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AuthType,
DemoSet,
useAuthModal,
useAuthError,
useUser,
} from "@alchemy/aa-alchemy/react";
// eslint-disable-next-line import/extensions
Expand All @@ -14,20 +15,18 @@ import { Input, useLogout } from "@alchemy/aa-alchemy/react";
import { useMemo } from "react";

export default function Home() {
// const [darkMode, setDarkMode] = useState(false)
const sections = useMemo<AuthType[][]>(
() => [[{ type: "email", hideButton: true }], [{ type: "passkey" }]],
[]
);
const { openAuthModal } = useAuthModal();
const error = useAuthError();
const user = useUser();
const { logout } = useLogout();

return (
<>
<main
className="flex min-h-screen p-24 basis-2/4 light:bg-[#F9F9F9] dark:bg-[#020617] dark:text-white justify-center"
>
<main className="flex min-h-screen p-24 basis-2/4 light:bg-[#F9F9F9] dark:bg-[#020617] dark:text-white justify-center">
<div className="flex flex-col gap-8 max-w-[50%] w-full">
<div className="flex flex-col gap-4">
<h1 className="text-4xl font-bold">Buttons</h1>
Expand Down Expand Up @@ -65,20 +64,32 @@ export default function Home() {
<div className="flex flex-col gap-4">
<h1 className="text-4xl font-bold">Auth</h1>
<div className="flex flex-row gap-6">
<div className="modal w-[368px] shadow-md">
{!user ? (
<AuthCard sections={sections} />
) : (
<div className="flex flex-col gap-2 p-2">
Logged in as {user.email ?? "anon"}
<button
className="btn btn-primary"
onClick={() => logout()}
>
Log out
</button>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 w-[368px]">
<div className="modal shadow-md">
{!user ? (
<AuthCard hideError sections={sections} />
) : (
<div className="flex flex-col gap-2 p-2">
Logged in as {user.email ?? "anon"}
<button
className="btn btn-primary"
onClick={() => logout()}
>
Log out
</button>
</div>
)}
</div>
)}
{error && error.message && (
<div
key="custom-error-boundary"
className="btn-primary text-xs rounded-xl p-2"
>
{error.message}
</div>
)}
</div>
</div>
<button className="btn btn-primary" onClick={openAuthModal}>
Open Auth Modal
Expand Down
2 changes: 1 addition & 1 deletion examples/ui-demo/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const uiConfig: AlchemyAccountsProviderProps["uiConfig"] = {
auth: {
sections: [[{type: "email"}], [{type: "passkey"}]],
addPasskeyOnSignup: true,
}
},
};

export const Providers = (props: PropsWithChildren<{}>) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { useAddPasskey } from "../../../hooks/useAddPasskey.js";
import { PasskeyIcon } from "../../../icons/passkey.js";
import { Button } from "../../button.js";
import { ErrorContainer } from "../../error.js";
import { PoweredBy } from "../../poweredby.js";
import { useAuthContext } from "../context.js";

// eslint-disable-next-line jsdoc/require-jsdoc
export const AddPasskey = () => {
const { setAuthStep } = useAuthContext();
const { addPasskey, isAddingPasskey, error } = useAddPasskey({
const { addPasskey, isAddingPasskey } = useAddPasskey({
onSuccess: () => {
setAuthStep({ type: "complete" });
},
Expand All @@ -26,7 +25,6 @@ export const AddPasskey = () => {
Passkeys allow for a simple and secure user experience. Login in and
sign transactions in seconds
</p>
{error && <ErrorContainer error={error} />}
<Button
variant="primary"
className="w-full"
Expand Down
3 changes: 0 additions & 3 deletions packages/alchemy/src/react/components/auth/card/content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { ErrorContainer } from "../../error.js";
import { PoweredBy } from "../../poweredby.js";

interface CardContentProps {
Expand All @@ -19,7 +18,6 @@ export const CardContent = ({
icon,
description,
support,
error,
}: CardContentProps) => {
return (
<div className="flex flex-col gap-5 items-center">
Expand All @@ -36,7 +34,6 @@ export const CardContent = ({
) : (
description
)}
{error && <ErrorContainer error={error} />}
{support && (
<div className="flex flex-row gap-2 text-xs font-normal">
<span className="text-fg-secondary">{support.text}</span>
Expand Down
31 changes: 18 additions & 13 deletions packages/alchemy/src/react/components/auth/card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useLayoutEffect, useState, type ReactNode } from "react";
import { useLayoutEffect, type ReactNode } from "react";
import { useSignerStatus } from "../../../hooks/useSignerStatus.js";
import { IS_SIGNUP_QP } from "../../constants.js";
import { AuthModalContext, type AuthStep } from "../context.js";
import { useAuthContext } from "../context.js";
import type { AuthType } from "../types.js";
import { Step } from "./steps.js";
import { Notification } from "../../notification.js";
import { useAuthError } from "../../../hooks/useAuthError.js";

export type AuthCardProps = {
hideError?: boolean;
header?: ReactNode;
// Each section can contain multiple auth types which will be grouped together
// and separated by an OR divider
Expand All @@ -24,9 +27,8 @@ export type AuthCardProps = {
*/
export const AuthCard = (props: AuthCardProps) => {
const { status, isAuthenticating } = useSignerStatus();
const [authStep, setAuthStep] = useState<AuthStep>({
type: isAuthenticating ? "email_completing" : "initial",
});
const { authStep, setAuthStep } = useAuthContext();
const error = useAuthError();

useLayoutEffect(() => {
if (authStep.type === "complete") {
Expand All @@ -39,18 +41,21 @@ export const AuthCard = (props: AuthCardProps) => {
createPasskeyAfter: urlParams.get(IS_SIGNUP_QP) === "true",
});
}
}, [authStep, status, props, isAuthenticating]);
}, [authStep, status, props, isAuthenticating, setAuthStep]);

return (
<AuthModalContext.Provider
value={{
authStep,
setAuthStep,
}}
>
<div className="relative">
<div
id="akui-default-error-container"
className="absolute bottom-[calc(100%+8px)] w-full"
>
{!props.hideError && error && error.message && (
<Notification message={error.message} type="error" />
)}
</div>
<div className="modal-box flex flex-col items-center gap-5">
<Step {...props} />
</div>
</AuthModalContext.Provider>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const LoadingAuth = ({ context }: LoadingAuthProps) => {
case "email_verify":
return <LoadingEmail context={context} />;
case "passkey_verify":
return <LoadingPasskeyAuth context={context} />;
return <LoadingPasskeyAuth />;
case "email_completing":
return <CompletingEmailAuth context={context} />;
default: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import { PasskeyIcon } from "../../../../icons/passkey.js";
import { Button } from "../../../button.js";
import { ErrorContainer } from "../../../error.js";
import { PoweredBy } from "../../../poweredby.js";
import { type AuthStep } from "../../context.js";
import { usePasskeyVerify } from "../../hooks/usePasskeyVerify.js";

interface LoadingPasskeyAuthProps {
context: Extract<AuthStep, { type: "passkey_verify" }>;
}

// eslint-disable-next-line jsdoc/require-jsdoc
export const LoadingPasskeyAuth = ({
context: { error },
}: LoadingPasskeyAuthProps) => {
const { authenticate } = usePasskeyVerify();

export const LoadingPasskeyAuth = () => {
return (
<div className="flex flex-col gap-5 items-center">
<span className="text-lg text-fg-primary font-semibold">
Expand All @@ -26,14 +15,6 @@ export const LoadingPasskeyAuth = ({
<p className="text-fg-secondary text-center font-normal text-sm">
Follow the steps on your device to create or verify your passkey
</p>
{error && (
<>
<ErrorContainer error={error} />
<Button className="btn btn-primary w-full" onClick={authenticate}>
Retry
</Button>
</>
)}
<div className="flex flex-row rounded-lg bg-bg-surface-inset justify-between py-2 px-4 w-full items-center text-xs">
<span className="font-normal text-fg-secondary">Having trouble?</span>
<Button variant="link" className="text-xs font-semibold">
Expand Down
17 changes: 0 additions & 17 deletions packages/alchemy/src/react/components/error.tsx

This file was deleted.

29 changes: 29 additions & 0 deletions packages/alchemy/src/react/components/notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type NotificationProps = {
type: "success" | "warning" | "error";
message: string;
className?: string;
};

// this isn't used externally
// eslint-disable-next-line jsdoc/require-jsdoc
export function Notification({ className, type, message }: NotificationProps) {
const bgColor = (() => {
switch (type) {
case "success":
return "bg-bg-surface-success";
case "warning":
return "bg-bg-surface-warning";
case "error":
return "bg-bg-surface-error";
}
})();
return (
<div
className={`${bgColor} text-sm py-1 px-2 rounded-lg text-white ${
className ?? ""
}`}
>
{message}
</div>
);
}
65 changes: 46 additions & 19 deletions packages/alchemy/src/react/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@

import type { NoUndefined } from "@alchemy/aa-core";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { AlchemyAccountsConfig, AlchemyClientState } from "../config";
import { AuthCard, type AuthCardProps } from "./components/auth/card/index.js";
import { IS_SIGNUP_QP } from "./components/constants.js";
import { NoAlchemyAccountContextError } from "./errors.js";
import { useSignerStatus } from "./hooks/useSignerStatus.js";
import { Hydrate } from "./hydrate.js";
import { AuthModalContext, type AuthStep } from "./components/auth/context.js";

export type AlchemyAccountContextProps =
| {
Expand All @@ -35,6 +43,11 @@ export type AlchemyAccountsProviderProps = {
* to the DOM and can be controlled via the `useAuthModal` hook
*/
auth?: AuthCardProps & { addPasskeyOnSignup?: boolean };
/**
* If hideError is true, then the auth component will not
* render the global error component
*/
hideError?: boolean;
};
};

Expand Down Expand Up @@ -73,6 +86,7 @@ export const AlchemyAccountProvider = (
props: React.PropsWithChildren<AlchemyAccountsProviderProps>
) => {
const { config, queryClient, children, uiConfig } = props;

const ref = useRef<HTMLDialogElement>(null);
const openAuthModal = () => ref.current?.showModal();
const closeAuthModal = () => ref.current?.close();
Expand All @@ -91,7 +105,10 @@ export const AlchemyAccountProvider = (
[config, queryClient, uiConfig]
);

const { status } = useSignerStatus(initialContext);
const { status, isAuthenticating } = useSignerStatus(initialContext);
const [authStep, setAuthStep] = useState<AuthStep>({
type: isAuthenticating ? "email_completing" : "initial",
});

useEffect(() => {
if (
Expand All @@ -109,23 +126,33 @@ export const AlchemyAccountProvider = (
<Hydrate {...props}>
<AlchemyAccountContext.Provider value={initialContext}>
<QueryClientProvider client={queryClient}>
{children}
{uiConfig?.auth && (
<dialog
ref={ref}
className={`modal w-[368px] ${uiConfig.auth.className ?? ""}`}
>
<AuthCard
header={uiConfig.auth.header}
sections={uiConfig.auth.sections}
onAuthSuccess={() => closeAuthModal()}
/>
<div
className="modal-backdrop"
onClick={() => closeAuthModal()}
></div>
</dialog>
)}
<AuthModalContext.Provider
value={{
authStep,
setAuthStep,
}}
>
{children}
{uiConfig?.auth && (
<dialog
ref={ref}
className={`modal overflow-visible relative w-[368px] ${
uiConfig.auth.className ?? ""
}`}
>
<AuthCard
hideError={uiConfig.hideError}
header={uiConfig.auth.header}
sections={uiConfig.auth.sections}
onAuthSuccess={() => closeAuthModal()}
/>
<div
className="modal-backdrop"
onClick={() => closeAuthModal()}
></div>
</dialog>
)}
</AuthModalContext.Provider>
</QueryClientProvider>
</AlchemyAccountContext.Provider>
</Hydrate>
Expand Down
Loading
Loading