diff --git a/package.json b/package.json index 8f3183d3..e62a413e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ProductCard/FreeTrial.tsx b/src/components/ProductCard/FreeTrial.tsx index 67503cd3..96fcaa4e 100644 --- a/src/components/ProductCard/FreeTrial.tsx +++ b/src/components/ProductCard/FreeTrial.tsx @@ -49,7 +49,6 @@ export function FreeTrial({ . , - 'No credit card required.', ]} header="Free Forever" subtitle={ diff --git a/src/components/ProductCard/Sub.tsx b/src/components/ProductCard/Sub.tsx index 74777bd0..970d36fd 100644 --- a/src/components/ProductCard/Sub.tsx +++ b/src/components/ProductCard/Sub.tsx @@ -209,7 +209,7 @@ function licenseFeatures(): (string | React.ReactElement)[] { > self-host guides {' '} - (Heroku, Docker). + (Docker, OVH). , See{' '} diff --git a/src/pages/signup.module.css b/src/pages/signup.module.css new file mode 100644 index 00000000..8d39705c --- /dev/null +++ b/src/pages/signup.module.css @@ -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; +} \ No newline at end of file diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx index 8e244ff3..a6a3951a 100644 --- a/src/pages/signup.tsx +++ b/src/pages/signup.tsx @@ -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'; @@ -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( 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(); + + const handleSignup = async (e: React.FormEvent) => { + 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(() => { @@ -57,59 +146,116 @@ export default function SignUp(): React.ReactElement { } }, [router, user]); + // Did the user successfully sign up? + const isSuccessfulSignUp = message?.type === 'success'; + return ( - setName(e.currentTarget.value)} - width="100%" - > - Name - - - setEmail(e.currentTarget.value)} - required - size="large" - status={message?.type} - width="100%" - > - Email - - - setPassword(e.currentTarget.value)} - required - size="large" - status={message?.type} - width="100%" +
{ + handleSignup(e).catch(sentryException); + }} > - Password - - {message && } + setEmail(e.currentTarget.value)} + required + size="large" + status={message?.type} + width="100%" + > + Email + + + setPassword(e.currentTarget.value)} + required + size="large" + status={message?.type} + width="100%" + > + Password + - + {/* Stripe credit card input */} + + + Credit Card ( + { + e.preventDefault(); + setShowWhyCC(!showWhyCC); + }} + underline + > + {showWhyCC ? 'Hide ▲' : 'Why? ▼'} + + ) + {showWhyCC && ( + <> + + + 💡 For better verification results, Reacher + needs to maintain its servers' IP + reputation. Requiring credit card info here + reduces spam sign-ups, which helps maintaining + the IP health. + + + )} + - { - handleSignup().catch(sentryException); - }} - > - {loading ? 'Signing up...' : 'Sign up'} - - - - Already have an account?{' '} - - Log in. - - + +
+ +
+ + + + We won't charge you until you manually upgrade + to a paid plan. + + + {message && } + + + + + {isSuccessfulSignUp + ? 'Success' + : loading + ? 'Signing up...' + : 'Sign up'} + + + + Already have an account?{' '} + + Log in. + + +
); } + +// 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 ( + + + + ); +} diff --git a/yarn.lock b/yarn.lock index bf232f3e..d39548a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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==