better looking SettingsSection

This commit is contained in:
m5r 2021-09-30 23:36:47 +02:00
parent 49f11a16e2
commit 13ac4a5580
9 changed files with 327 additions and 371 deletions

View File

@ -2,10 +2,10 @@ import { useRef, useState } from "react";
import { useMutation } from "blitz"; import { useMutation } from "blitz";
import clsx from "clsx"; import clsx from "clsx";
import Button from "./button"; import Button from "../button";
import SettingsSection from "./settings-section"; import SettingsSection from "../settings-section";
import Modal, { ModalTitle } from "./modal"; import Modal, { ModalTitle } from "../modal";
import deleteUser from "../mutations/delete-user"; import deleteUser from "../../mutations/delete-user";
export default function DangerZone() { export default function DangerZone() {
const deleteUserMutation = useMutation(deleteUser)[0]; const deleteUserMutation = useMutation(deleteUser)[0];
@ -26,20 +26,18 @@ export default function DangerZone() {
}; };
return ( return (
<SettingsSection title="Danger Zone" description="Highway to the Danger Zone 𝅘𝅥𝅮"> <SettingsSection className="border border-red-300">
<div className="shadow border border-red-300 sm:rounded-md sm:overflow-hidden"> <div className="flex justify-between items-center flex-row space-x-2">
<div className="flex justify-between items-center flex-row px-4 py-5 space-x-2 bg-white sm:p-6"> <p>
<p> Once you delete your account, all of its data will be permanently deleted and any ongoing
Once you delete your account, all of its data will be permanently deleted and any ongoing subscription will be cancelled.
subscription will be cancelled. </p>
</p>
<span className="text-base font-medium"> <span className="text-base font-medium">
<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}> <Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
Delete my account Delete my account
</Button> </Button>
</span> </span>
</div>
</div> </div>
<Modal initialFocus={modalCancelButtonRef} isOpen={isConfirmationModalOpen} onClose={closeModal}> <Modal initialFocus={modalCancelButtonRef} isOpen={isConfirmationModalOpen} onClose={closeModal}>

View File

@ -0,0 +1,115 @@
import type { FunctionComponent } from "react";
import { useEffect } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import updateUser from "../../mutations/update-user";
import Alert from "../../../core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
import useCurrentUser from "../../../core/hooks/use-current-user";
import appLogger from "../../../../integrations/logger";
type Form = {
fullName: string;
email: string;
};
const logger = appLogger.child({ module: "profile-settings" });
const ProfileInformations: FunctionComponent = () => {
const { user } = useCurrentUser();
const [updateUserMutation, { error, isError, isSuccess }] = useMutation(updateUser);
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
useEffect(() => {
setValue("fullName", user?.fullName ?? "");
setValue("email", user?.email ?? "");
}, [setValue, user]);
const onSubmit = handleSubmit(async ({ fullName, email }) => {
if (isSubmitting) {
return;
}
await updateUserMutation({ email, fullName });
});
const errorMessage = parseErrorMessage(error as Error | null);
return (
<form onSubmit={onSubmit}>
<SettingsSection
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={errorMessage} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div className="col-span-3 sm:col-span-2">
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
Full name
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="fullName"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("fullName")}
required
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
type="email"
tabIndex={2}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("email")}
required
/>
</div>
</div>
</SettingsSection>
</form>
);
};
export default ProfileInformations;
function parseErrorMessage(error: Error | null): string {
if (!error) {
return "";
}
if (error.name === "ZodError") {
return JSON.parse(error.message)[0].message;
}
return error.message;
}

View File

@ -0,0 +1,112 @@
import type { FunctionComponent } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import Alert from "../../../core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
import appLogger from "../../../../integrations/logger";
import changePassword from "../../mutations/change-password";
const logger = appLogger.child({ module: "update-password" });
type Form = {
currentPassword: string;
newPassword: string;
};
const UpdatePassword: FunctionComponent = () => {
const [changePasswordMutation, { error, isError, isSuccess }] = useMutation(changePassword);
const {
register,
handleSubmit,
formState: { isSubmitting },
} = useForm<Form>();
const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => {
if (isSubmitting) {
return;
}
await changePasswordMutation({ currentPassword, newPassword });
});
const errorMessage = parseErrorMessage(error as Error | null);
return (
<form onSubmit={onSubmit}>
<SettingsSection
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={errorMessage} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div>
<label
htmlFor="currentPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Current password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="currentPassword"
type="password"
tabIndex={3}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("currentPassword")}
required
/>
</div>
</div>
<div>
<label
htmlFor="newPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>New password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPassword"
type="password"
tabIndex={4}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("newPassword")}
required
/>
</div>
</div>
</SettingsSection>
</form>
);
};
export default UpdatePassword;
function parseErrorMessage(error: Error | null): string {
if (!error) {
return "";
}
if (error.name === "ZodError") {
return JSON.parse(error.message)[0].message;
}
return error.message;
}

View File

@ -1,9 +0,0 @@
export default function Divider() {
return (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
</div>
);
}

View File

@ -1,114 +0,0 @@
import type { FunctionComponent } from "react";
import { useEffect, useState } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import updateUser from "../mutations/update-user";
import Alert from "../../core/components/alert";
import Button from "./button";
import SettingsSection from "./settings-section";
import useCurrentUser from "../../core/hooks/use-current-user";
import appLogger from "../../../integrations/logger";
type Form = {
fullName: string;
email: string;
};
const logger = appLogger.child({ module: "profile-settings" });
const ProfileInformations: FunctionComponent = () => {
const { user } = useCurrentUser();
const updateUserMutation = useMutation(updateUser)[0];
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting, isSubmitSuccessful },
} = useForm<Form>();
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
setValue("fullName", user?.fullName ?? "");
setValue("email", user?.email ?? "");
}, [setValue, user]);
const onSubmit = handleSubmit(async ({ fullName, email }) => {
if (isSubmitting) {
return;
}
try {
await updateUserMutation({ email, fullName });
} catch (error: any) {
logger.error(error.response, "error updating user infos");
setErrorMessage(error.response.data.errorMessage);
}
});
return (
<SettingsSection
title="Profile Information"
description="Update your account's profile information and email address."
>
<form onSubmit={onSubmit}>
{errorMessage ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={errorMessage} variant="error" />
</div>
) : null}
{isSubmitSuccessful ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 py-5 bg-white space-y-6 sm:p-6">
<div className="col-span-3 sm:col-span-2">
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
Full name
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="fullName"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("fullName")}
required
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
type="email"
tabIndex={2}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("email")}
required
/>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</form>
</SettingsSection>
);
};
export default ProfileInformations;

View File

@ -1,18 +1,16 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import clsx from "clsx";
type Props = { type Props = {
title: string; className?: string;
description?: ReactNode; footer?: ReactNode;
}; };
const SettingsSection: FunctionComponent<Props> = ({ children, title, description }) => ( const SettingsSection: FunctionComponent<Props> = ({ children, footer, className }) => (
<div className="px-4 sm:px-6 lg:px-0 lg:grid lg:grid-cols-4 lg:gap-6"> <section className={clsx(className, "shadow sm:rounded-md sm:overflow-hidden")}>
<div className="lg:col-span-1"> <div className="bg-white space-y-6 py-6 px-4 sm:p-6">{children}</div>
<h3 className="text-lg font-medium leading-6 text-gray-900">{title}</h3> {footer ?? null}
{description ? <p className="mt-1 text-sm text-gray-600">{description}</p> : null} </section>
</div>
<div className="mt-5 lg:mt-0 lg:col-span-3">{children}</div>
</div>
); );
export default SettingsSection; export default SettingsSection;

View File

@ -1,114 +0,0 @@
import type { FunctionComponent } from "react";
import { useState } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import Alert from "../../core/components/alert";
import Button from "./button";
import SettingsSection from "./settings-section";
import appLogger from "../../../integrations/logger";
import changePassword from "../mutations/change-password";
const logger = appLogger.child({ module: "update-password" });
type Form = {
currentPassword: string;
newPassword: string;
};
const UpdatePassword: FunctionComponent = () => {
const changePasswordMutation = useMutation(changePassword)[0];
const {
register,
handleSubmit,
formState: { isSubmitting, isSubmitSuccessful },
} = useForm<Form>();
const [errorMessage, setErrorMessage] = useState("");
const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => {
if (isSubmitting) {
return;
}
setErrorMessage("");
try {
await changePasswordMutation({ currentPassword, newPassword });
} catch (error: any) {
logger.error(error, "error updating user infos");
setErrorMessage(error.message);
}
});
return (
<SettingsSection
title="Update Password"
description="Make sure you are using a long, random password to stay secure."
>
<form onSubmit={onSubmit}>
{errorMessage ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={errorMessage} variant="error" />
</div>
) : null}
{!isSubmitting && isSubmitSuccessful && !errorMessage ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 py-5 bg-white space-y-6 sm:p-6">
<div>
<label
htmlFor="currentPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Current password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="currentPassword"
type="password"
tabIndex={3}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("currentPassword")}
required
/>
</div>
</div>
<div>
<label
htmlFor="newPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>New password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPassword"
type="password"
tabIndex={4}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("newPassword")}
required
/>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</form>
</SettingsSection>
);
};
export default UpdatePassword;

View File

@ -1,5 +1,7 @@
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { GetServerSideProps, Link, Routes } from "blitz"; import { GetServerSideProps, Link, Routes } from "blitz";
import * as Panelbear from "@panelbear/panelbear-js";
import clsx from "clsx";
import useSubscription from "../../hooks/use-subscription"; import useSubscription from "../../hooks/use-subscription";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
@ -7,10 +9,7 @@ import SettingsLayout from "../../components/settings-layout";
import appLogger from "../../../../integrations/logger"; import appLogger from "../../../../integrations/logger";
import PaddleLink from "../../components/paddle-link"; import PaddleLink from "../../components/paddle-link";
import SettingsSection from "../../components/settings-section"; import SettingsSection from "../../components/settings-section";
import Divider from "../../components/divider";
import { HiCheck } from "react-icons/hi"; import { HiCheck } from "react-icons/hi";
import * as Panelbear from "@panelbear/panelbear-js";
import clsx from "clsx";
const logger = appLogger.child({ page: "/account/settings/billing" }); const logger = appLogger.child({ page: "/account/settings/billing" });
@ -76,111 +75,91 @@ const Billing: BlitzPage = () => {
if (!subscription) { if (!subscription) {
return ( return (
<section aria-labelledby="subscribe-heading"> <SettingsSection>
<div className="shadow sm:rounded-md sm:overflow-hidden"> <div>
<div className="bg-white py-6 px-4 sm:p-6"> <h2 className="text-lg leading-6 font-medium text-gray-900">Subscribe</h2>
<div> <p className="mt-1 text-sm text-gray-500">
<h2 id="subscribe-heading" className="text-lg leading-6 font-medium text-gray-900"> Update your billing information. Please note that updating your location could affect your tax
Subscribe rates.
</h2> </p>
<p className="mt-1 text-sm text-gray-500">
Update your billing information. Please note that updating your location could affect
your tax rates.
</p>
</div>
<div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4">
{pricing.tiers.map((tier) => (
<div
key={tier.title}
className="relative p-4 mb-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-grow w-1/3 flex-col"
>
<div className="flex-1">
<h3 className="text-xl font-mackinac font-semibold text-gray-900">
{tier.title}
</h3>
{tier.yearly ? (
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
Get 2 months free!
</p>
) : null}
<p className="mt-4 flex items-baseline text-gray-900">
<span className="text-2xl font-extrabold tracking-tight">
{tier.price}
</span>
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
</p>
{tier.yearly ? (
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12})</p>
) : null}
<p className="mt-6 text-gray-500">{tier.description}</p>
<ul role="list" className="mt-6 space-y-6">
{tier.features.map((feature) => (
<li key={feature} className="flex">
<HiCheck
className="flex-shrink-0 w-6 h-6 text-[#0eb56f]"
aria-hidden="true"
/>
<span className="ml-3 text-gray-500">{feature}</span>
</li>
))}
{tier.unavailableFeatures.map((feature) => (
<li key={feature} className="flex">
<span className="ml-9 text-gray-400">
{~feature.indexOf("(coming soon)")
? feature.slice(0, feature.indexOf("(coming soon)"))
: feature}
</span>
</li>
))}
</ul>
</div>
<Link href={Routes.LandingPage({ join_waitlist: "" })}>
<a
onClick={() => Panelbear.track("redirect-to-join-waitlist")}
className={clsx(
tier.yearly
? "bg-primary-500 text-white hover:bg-primary-600"
: "bg-primary-50 text-primary-700 hover:bg-primary-100",
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
)}
>
{tier.cta}
</a>
</Link>
</div>
))}
</div>
</div>
</div> </div>
</section>
<div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4">
{pricing.tiers.map((tier) => (
<div
key={tier.title}
className="relative p-4 mb-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-grow w-1/3 flex-col"
>
<div className="flex-1">
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
{tier.yearly ? (
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
Get 2 months free!
</p>
) : null}
<p className="mt-4 flex items-baseline text-gray-900">
<span className="text-2xl font-extrabold tracking-tight">{tier.price}</span>
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
</p>
{tier.yearly ? (
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12})</p>
) : null}
<p className="mt-6 text-gray-500">{tier.description}</p>
<ul role="list" className="mt-6 space-y-6">
{tier.features.map((feature) => (
<li key={feature} className="flex">
<HiCheck
className="flex-shrink-0 w-6 h-6 text-[#0eb56f]"
aria-hidden="true"
/>
<span className="ml-3 text-gray-500">{feature}</span>
</li>
))}
{tier.unavailableFeatures.map((feature) => (
<li key={feature} className="flex">
<span className="ml-9 text-gray-400">
{~feature.indexOf("(coming soon)")
? feature.slice(0, feature.indexOf("(coming soon)"))
: feature}
</span>
</li>
))}
</ul>
</div>
<Link href={Routes.LandingPage({ join_waitlist: "" })}>
<a
onClick={() => Panelbear.track("redirect-to-join-waitlist")}
className={clsx(
tier.yearly
? "bg-primary-500 text-white hover:bg-primary-600"
: "bg-primary-50 text-primary-700 hover:bg-primary-100",
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
)}
>
{tier.cta}
</a>
</Link>
</div>
))}
</div>
</SettingsSection>
); );
} }
return ( return (
<> <>
<SettingsSection title="Payment method"> <SettingsSection>
<PaddleLink <PaddleLink
onClick={() => updatePaymentMethod({ updateUrl: subscription.updateUrl })} onClick={() => updatePaymentMethod({ updateUrl: subscription.updateUrl })}
text="Update payment method on Paddle" text="Update payment method on Paddle"
/> />
</SettingsSection> </SettingsSection>
<div className="hidden lg:block"> <SettingsSection>{/*<BillingPlans activePlanId={subscription.paddlePlanId} />*/}</SettingsSection>
<Divider />
</div>
<SettingsSection title="Plan"> <SettingsSection>
{/*<BillingPlans activePlanId={subscription.paddlePlanId} />*/}
</SettingsSection>
<div className="hidden lg:block">
<Divider />
</div>
<SettingsSection title="Cancel subscription">
<PaddleLink <PaddleLink
onClick={() => cancelSubscription({ cancelUrl: subscription.cancelUrl })} onClick={() => cancelSubscription({ cancelUrl: subscription.cancelUrl })}
text="Cancel subscription on Paddle" text="Cancel subscription on Paddle"

View File

@ -2,10 +2,9 @@ import type { BlitzPage } from "blitz";
import { Routes } from "blitz"; import { Routes } from "blitz";
import SettingsLayout from "../../components/settings-layout"; import SettingsLayout from "../../components/settings-layout";
import ProfileInformations from "../../components/profile-informations"; import ProfileInformations from "../../components/account/profile-informations";
import Divider from "../../components/divider"; import UpdatePassword from "../../components/account/update-password";
import UpdatePassword from "../../components/update-password"; import DangerZone from "../../components/account/danger-zone";
import DangerZone from "../../components/danger-zone";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
const Account: BlitzPage = () => { const Account: BlitzPage = () => {
@ -15,16 +14,8 @@ const Account: BlitzPage = () => {
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
<ProfileInformations /> <ProfileInformations />
<div className="hidden lg:block">
<Divider />
</div>
<UpdatePassword /> <UpdatePassword />
<div className="hidden lg:block">
<Divider />
</div>
<DangerZone /> <DangerZone />
</div> </div>
); );