Skip to content

Commit

Permalink
Migrate signup to new lightweight login modal
Browse files Browse the repository at this point in the history
  • Loading branch information
pimterry committed Dec 4, 2024
1 parent 6687e1c commit da2c2d9
Show file tree
Hide file tree
Showing 11 changed files with 407 additions and 633 deletions.
602 changes: 21 additions & 581 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"homepage": "https://github.com/httptoolkit/httptoolkit-website",
"dependencies": {
"@docsearch/react": "^3.6.0",
"@httptoolkit/accounts": "^2.2.0",
"@httptoolkit/accounts": "^3.0.0",
"@httptoolkit/util": "^0.1.5",
"@phosphor-icons/react": "^2.1.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.6",
Expand Down
2 changes: 2 additions & 0 deletions src/app/(pricing)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { CaretRight } from '@/components/elements/icon';
import { Layout } from '@/components/layout';
import { PricingComparison } from '@/components/sections/pricing/comparison';
import { TextWithAccordion } from '@/components/sections/text-with-accordion';
import { LoginModal } from '@/components/modules/login-modal';

export default function PricingLayout({ children }: { children: React.ReactNode }) {
return (
<Layout>
<LoginModal />
<Suspense>{children}</Suspense>
<PricingComparison
title="Features"
Expand Down
335 changes: 335 additions & 0 deletions src/components/modules/login-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
"use client";

import React, { useState } from 'react';
import styled, { keyframes } from 'styled-components';

import { asErrorLike } from '@httptoolkit/util';
import { sendAuthCode, loginWithCode } from '@httptoolkit/accounts';

import { accountStore } from '@/lib/store/account-store';
import { observer } from 'mobx-react-lite';
import { Heading } from '@/components/elements/heading';
import { Logo, X, CaretLeft } from '@/components/elements/icon';
import { Button } from '@/components/elements/button';
import { Link } from '@/components/elements/link';

const Modal = styled.dialog`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
margin: 0;
width: 90%;
@media (min-width: ${({ theme }) => theme.screens.lg}) {
width: auto;
max-width: 340px;
}
background: white;
color: black;
border-radius: 16px;
padding: 0;
box-shadow: 0 0 0 1px var(--button-border) inset;
outline: none;
border: none;
background-color: var(--dark-grey);
&::backdrop {
opacity: 0.9;
background: radial-gradient(circle, var(--medium-grey), var(--light-grey));
}
`;

const CtaButton = styled(Button)`
margin: 20px;
width: calc(100% - 40px);
box-sizing: border-box;
`;

const CloseDialogButton = styled.button`
position: absolute;
top: 0;
right: 0;
padding: 16px;
background: none;
border: none;
color: var(--light-grey);
cursor: pointer;
&:hover {
color: var(--white);
}
`;

const BackButton = styled.button`
position: absolute;
top: 0;
left: 0;
padding: 16px;
background: none;
border: none;
color: var(--light-grey);
cursor: pointer;
&:hover {
color: var(--white);
}
`;

const Form = styled.form`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`

const HeadingLogo = styled(Logo)`
margin: 48px 16px 16px;
width: 30%;
fill: var(--cinnabar-red);
`;

const Title = styled(Heading)`
margin: 16px 32px;
text-align: center;
`;

const Subtitle = styled(Heading)`
margin: -16px 32px 16px;
text-align: center;
`;

const Email = styled.span`
white-space: break-spaces;
word-break: break-word;
hyphens: auto;
`;

const Input = styled.input`
padding: 16px;
margin: 16px 0 0;
width: 100%;
border-style: solid;
border-color: var(--medium-grey);
background-color: var(--ink-black);
border-width: 1px 0 1px 0;
z-index: 1;
font-size: ${({ theme }) => theme.fontSizes.text.m};
&:focus {
border-color: var(--white);
}
`;

const SmallPrint = styled.p`
margin: 0;
padding: 10px 16px 12px;
width: 100%;
font-size: ${({ theme }) => theme.fontSizes.text.s};
font-style: italic;
background-color: var(--darkish-grey);
color: var(--light-grey);
`;

const spin = keyframes`
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
`;

const Spinner = styled.div`
border: 4px solid rgba(0, 0, 0, 0.1);
border-top: 4px solid #007bff;
border-radius: 50%;
width: 24px;
height: 24px;
animation: ${spin} 1s linear infinite;
margin: 10px 0;
`;

const ErrorMessage = styled.div`
color: red;
margin: 16px 20px 0;
`;

export const LoginModal = observer(() => {
const handleDialogClose = React.useCallback(() => {
accountStore.endLogin();
}, []);

if (!accountStore.loginModalVisible) return null;

return <Modal
ref={(dialog) => dialog?.showModal()}
onClose={handleDialogClose}
>
<CloseDialogButton
onClick={handleDialogClose}
aria-label="Close dialog"
>
<X size="24" />
</CloseDialogButton>
<LoginFields />
</Modal>;
});

const focusInput = (input: HTMLInputElement | null) => {
requestAnimationFrame(() =>
input?.focus()
);
}

const LoginFields = () => {
const [email, setEmail] = useState('');
const [code, setCode] = useState('');

const [isEmailSent, setIsEmailSent] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | false>(false);

const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();

setIsLoading(true);
setError(false);

try {
await sendAuthCode(email, 'website');
setIsLoading(false);
setIsEmailSent(true);
} catch (e) {
setIsLoading(false);
setError(asErrorLike(e).message || 'An error occurred');
}
};

const handleBackButton = () => {
setIsEmailSent(false);
setError(false);
};

const handleCodeSubmit = async (e: React.FormEvent) => {
e.preventDefault();

setIsLoading(true);
setError(false);

try {
await loginWithCode(email, code);
await accountStore.finalizeLogin();
// We never unset isLoading - the modal disappears entirely when the
// account store state is fully updated, and we want to spin till then.
} catch (e) {
setIsLoading(false);
setError(asErrorLike(e).message || 'An error occurred');
}
};

return !isEmailSent
? <Form onSubmit={handleEmailSubmit}>
<HeadingLogo />
<Title fontSize='m'>
Enter your email
</Title>
<Input
name="email"
type="email"
required
placeholder="you@email.example"
ref={focusInput}
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>

{error &&
<ErrorMessage>{error}</ErrorMessage>
}

<CtaButton type="submit" disabled={isLoading}>
{isLoading ? <Spinner /> : 'Send Code'}
</CtaButton>

<SmallPrint>
By creating an account you accept the <Link
href="/terms-of-service"
target="_blank"
>
Terms of Service
</Link> & <Link
href="/privacy-policy"
target="_blank"
>
Privacy Policy
</Link>.
</SmallPrint>
</Form>
:
<Form onSubmit={handleCodeSubmit}>
<BackButton
type="button"
onClick={handleBackButton}
aria-label="Go back"
>
<CaretLeft size='24' />
</BackButton>
<HeadingLogo />
<Title fontSize='m'>
Enter the code
</Title>
<Subtitle fontSize='xs'>
sent to you at<br/><Email>
{ email }
</Email>
</Subtitle>
<Input
name="otp"
type="text"
inputMode="numeric"
pattern="\d{6}"
required
placeholder="Enter the 6 digit code"
ref={focusInput}
value={code}
onChange={(e) => {
const input = e.target.value;
const numberInput = input.replace(/\D/g, '').slice(0, 6);
setCode(numberInput);
}}
disabled={isLoading}
/>

{error &&
<ErrorMessage>{error}</ErrorMessage>
}

<CtaButton type="submit" disabled={isLoading}>
{isLoading ? <Spinner /> : 'Login'}
</CtaButton>

<SmallPrint>
By creating an account you accept the <Link
href="/terms-of-service"
target="_blank"
>
Terms of Service
</Link> & <Link
href="/privacy-policy"
target="_blank"
>
Privacy Policy
</Link>.
</SmallPrint>
</Form>
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const HeadingPlan = observer(({ id, title, downloadButton }: HeadingPlanP
<Heading as="h3" fontSize="xs" color="lightGrey" textAlign="center">
{title}
</Heading>
{getPlanCTA(id, accountStore, accountStore.waitingForPurchase, 'monthly')}
{getPlanCTA(id, accountStore, 'monthly')}
</StyledHeadingPlanWrapper>
);
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
'use client';

import { logOut } from '@httptoolkit/accounts';

import { StyledLoginInfoWrapper } from './login-info.styles';
import type { LoginInfoProps } from './login-info.types';

import { Button } from '@/components/elements/button';
import { Link } from '@/components/elements/link';
import { Text } from '@/components/elements/text';

export const LoginInfo = ({ isLoggedIn, email }: LoginInfoProps) => {
export const LoginInfo = ({
isLoggedIn,
logOut,
email
}: LoginInfoProps) => {
if (!isLoggedIn) {
return (
<Text fontSize="m" textAlign="center" color="darkGrey">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface LoginInfoProps {
email?: string;
logOut: () => void;
isLoggedIn: boolean;
}
Loading

0 comments on commit da2c2d9

Please sign in to comment.