Skip to content

Commit

Permalink
feat: Require credit card on sign ups (#302)
Browse files Browse the repository at this point in the history
* feat: Require credit card on sign ups

* Remove from pricing page

* remove heroku

* wording
  • Loading branch information
amaury1093 authored Aug 19, 2022
1 parent 91f4aa9 commit 12b9bb0
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 76 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@geist-ui/react-icons": "^1.0.1",
"@reacherhq/api": "^0.3.2",
"@sentry/nextjs": "^7.10.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.32.0",
"@supabase/supabase-js": "^1.35.4",
"@types/cors": "^2.8.12",
Expand Down
1 change: 0 additions & 1 deletion src/components/ProductCard/FreeTrial.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export function FreeTrial({
</a>
.
</span>,
'No credit card required.',
]}
header="Free Forever"
subtitle={
Expand Down
2 changes: 1 addition & 1 deletion src/components/ProductCard/Sub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ function licenseFeatures(): (string | React.ReactElement)[] {
>
self-host guides
</a>{' '}
(Heroku, Docker).
(Docker, OVH).
</span>,
<span key="licenseFeatures-5">
See{' '}
Expand Down
8 changes: 8 additions & 0 deletions src/pages/signup.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Make the stripe CC input same style as the Geist inputs.
*/
.inputWrapper {
border: 1px solid #eaeaea;
border-radius: 5px;
padding: 12px;
}
292 changes: 219 additions & 73 deletions src/pages/signup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { Input, Link as GLink, Spacer, Text } from '@geist-ui/react';
import { Input, Link as GLink, Note, Spacer, Text } from '@geist-ui/react';
import {
CardElement,
Elements,
useElements,
useStripe,
} from '@stripe/react-stripe-js';
import type {
ApiError,
Provider,
Session,
User as GoTrueUser,
UserCredentials,
} from '@supabase/gotrue-js';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';

Expand All @@ -9,46 +22,122 @@ import {
SigninMessage,
} from '../components';
import { sentryException } from '../util/sentry';
import { updateUserName } from '../util/supabaseClient';
import { getStripe } from '../util/stripeClient';
import { useUser } from '../util/useUser';
import styles from './signup.module.css';

function SignUp(): React.ReactElement {
const stripe = useStripe();
const elements = useElements();
const router = useRouter();
const { user, signUp } = useUser();

export default function SignUp(): React.ReactElement {
// Input form state.
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<SigninMessage | undefined>(
undefined
);
const router = useRouter();
const { user, signUp } = useUser();

const handleSignup = async () => {
setLoading(true);
setMessage(undefined);
const {
error,
session,
user: newUser,
} = await signUp({
email,
password,
});
if (error) {
setMessage({ type: 'error', content: error?.message });
} else {
// "If "Email Confirmations" is turned on, a user is returned but session will be null"
// https://supabase.io/docs/reference/javascript/auth-signup#notes
if (session && newUser) {
await updateUserName(newUser, name);
// Whether or not to show the "Why Credit Card?" info box.
const [showWhyCC, setShowWhyCC] = useState(false);

// These states serve as cache. We do multiple steps during sign up:
// - sign up on supabase
// - confirm card number on Stripe
// To avoid hitting the first endpoint again (on failed sign up attempts)
// we cache the results here.
const [signedUpUser, setSignedUpUser] = useState<GoTrueUser | undefined>();

const handleSignup = async (e: React.FormEvent<HTMLFormElement>) => {
try {
// We don't want to let default form submission happen here,
// which would refresh the page.
e.preventDefault();

if (!stripe || !elements) {
// Stripe.js has not yet loaded.
// Make sure to disable form submission until Stripe.js has loaded.
return;
}

setLoading(true);
setMessage(undefined);

const attemptSignUp = async (
creds: UserCredentials
): Promise<{
session: Session | null;
user: GoTrueUser | null;
provider?: Provider;
url?: string | null;
error: ApiError | null;
}> => {
if (signedUpUser) {
return {
session: null,
error: null,
user: signedUpUser,
};
}

const res = await signUp(creds);

if (!res.user) {
throw new Error('No new user returned.');
}

setSignedUpUser(res.user);

return res;
};

const { error, user: newUser } = await attemptSignUp({
email,
password,
});
if (error) {
throw error;
}

if (!newUser) {
throw new Error('No new user returned.');
}

// Verify cards details.
const card = elements.getElement('card');
if (!card) {
throw new Error('No card element found.');
}
// We only use `createPaymentMethod` to verify payment methods. A
// more correct way would be to use SetupIntents, and attach that
// payment method to the customer. But we don't do that here.
//
// This also doesn't actually verify the card works (i.e. do an
// actual card confirmation), I think, so it's just a quick check.
const { error: stripeError } = await stripe.createPaymentMethod({
type: 'card',
card,
});

if (stripeError) {
throw stripeError;
}

setMessage({
type: 'success',
content:
'Signed up successfully. Check your email for the confirmation link.',
});
} catch (error) {
setMessage({
type: 'error',
content: (error as Error)?.message,
});
} finally {
setLoading(false);
}
setLoading(false);
};

useEffect(() => {
Expand All @@ -57,59 +146,116 @@ export default function SignUp(): React.ReactElement {
}
}, [router, user]);

// Did the user successfully sign up?
const isSuccessfulSignUp = message?.type === 'success';

return (
<SigninLayout title="Sign Up">
<Input
placeholder="Name"
onChange={(e) => setName(e.currentTarget.value)}
width="100%"
>
Name
</Input>
<Spacer />
<Input
type="email"
placeholder="Email"
onChange={(e) => setEmail(e.currentTarget.value)}
required
size="large"
status={message?.type}
width="100%"
>
Email
</Input>
<Spacer />
<Input.Password
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.currentTarget.value)}
required
size="large"
status={message?.type}
width="100%"
<form
onSubmit={(e) => {
handleSignup(e).catch(sentryException);
}}
>
Password
</Input.Password>
{message && <SigninLayoutMessage message={message} />}
<Input
disabled={isSuccessfulSignUp}
type="email"
placeholder="Email"
onChange={(e) => setEmail(e.currentTarget.value)}
required
size="large"
status={message?.type}
width="100%"
>
Email
</Input>
<Spacer />
<Input.Password
disabled={isSuccessfulSignUp}
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.currentTarget.value)}
required
size="large"
status={message?.type}
width="100%"
>
Password
</Input.Password>

<Spacer />
{/* Stripe credit card input */}
<Spacer y={0.5} />
<Text>
Credit Card (
<GLink
color
onClick={(e) => {
e.preventDefault();
setShowWhyCC(!showWhyCC);
}}
underline
>
{showWhyCC ? 'Hide ▲' : 'Why? ▼'}
</GLink>
)
{showWhyCC && (
<>
<Spacer />
<Note label={false} small>
💡 For better verification results, Reacher
needs to maintain its servers&apos; IP
reputation. Requiring credit card info here
reduces spam sign-ups, which helps maintaining
the IP health.
</Note>
</>
)}
</Text>

<SigninButton
disabled={loading}
loading={loading}
onClick={() => {
handleSignup().catch(sentryException);
}}
>
{loading ? 'Signing up...' : 'Sign up'}
</SigninButton>

<Text p className="text-center">
Already have an account?{' '}
<GLink color href="/login" underline>
Log in.
</GLink>
</Text>
<Spacer y={0.5} />
<div className={styles.inputWrapper}>
<CardElement options={{ disabled: isSuccessfulSignUp }} />
</div>

<Spacer />
<Text em small>
We won&apos;t charge you until you <u>manually</u> upgrade
to a paid plan.
</Text>

{message && <SigninLayoutMessage message={message} />}

<Spacer />

<SigninButton
disabled={loading || isSuccessfulSignUp}
loading={loading}
htmlType="submit"
type={isSuccessfulSignUp ? 'success' : undefined}
>
{isSuccessfulSignUp
? 'Success'
: loading
? 'Signing up...'
: 'Sign up'}
</SigninButton>

<Text p className="text-center">
Already have an account?{' '}
<GLink color href="/login" underline>
Log in.
</GLink>
</Text>
</form>
</SigninLayout>
);
}

// Same as the SignUp components, but we wrap it inside the Stripe Elements
// provider, so that we can use the CardElement component inside.
export default function SignUpPage() {
return (
<Elements stripe={getStripe()}>
<SignUp />
</Elements>
);
}
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,13 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==

"@stripe/react-stripe-js@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.10.0.tgz#5412874b5ed4732e917c6d9bb2b6721ee25615ab"
integrity sha512-vuIjJUZJ3nyiaGa5z5iyMCzZfGGsgzOOjWjqknbbhkNsewyyginfeky9EZLSz9+iSAsgC9K6MeNOTLKVGcMycQ==
dependencies:
prop-types "^15.7.2"

"@stripe/stripe-js@^1.32.0":
version "1.32.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.32.0.tgz#4ecdd298db61ad9b240622eafed58da974bd210e"
Expand Down Expand Up @@ -4851,7 +4858,7 @@ promisify-call@^2.0.2:
dependencies:
with-callback "^1.0.2"

prop-types@^15.8.1:
prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
Expand Down

1 comment on commit 12b9bb0

@vercel
Copy link

@vercel vercel bot commented on 12b9bb0 Aug 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.