Add self-serve sign-up + Stripe Checkout (#31) #34
@@ -2,6 +2,9 @@ import React, { Component, useContext } from 'react';
|
||||
import './App.css';
|
||||
import { Routes, Route, Outlet, Navigate } from 'react-router-dom';
|
||||
import SignIn from './llm-fe/pages/SignIn/SignIn';
|
||||
import SignUp from './llm-fe/pages/SignUp/SignUp';
|
||||
import BillingSuccess from './llm-fe/pages/BillingSuccess/BillingSuccess';
|
||||
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
|
||||
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
|
||||
import NotFound from './llm-fe/pages/NotFound/NotFound';
|
||||
import { AuthContext } from './llm-fe/contexts/AuthContext';
|
||||
@@ -46,6 +49,9 @@ class App extends Component {
|
||||
<Route path='*' element={<NotFound />} />
|
||||
|
||||
<Route path={"/signin/"} Component={SignIn} />
|
||||
<Route path={"/signup/"} Component={SignUp} />
|
||||
<Route path={"/billing/success"} Component={BillingSuccess} />
|
||||
<Route path={"/billing/cancel"} Component={BillingCancel} />
|
||||
<Route path={"/password_reset/"} Component={PasswordReset} />
|
||||
<Route path={"/password_reset_confirmation/"} Component={PasswordResetConfirmation} />
|
||||
<Route path={'/set_password/'} Component={SetPassword} />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 5;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 3rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
const BodyText = styled.p`
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const NavLink = styled(Link)`
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const BillingCancel = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ParticleBackground />
|
||||
<ContentWrapper>
|
||||
<GlassCard>
|
||||
<CardTitle>Checkout canceled</CardTitle>
|
||||
<BodyText>
|
||||
No charge was made. Your account may already exist — sign in and open billing again, or
|
||||
return to sign up to retry Stripe Checkout.
|
||||
</BodyText>
|
||||
<PrimaryButton type="button" onClick={() => navigate('/signup/')}>
|
||||
Retry sign up
|
||||
</PrimaryButton>
|
||||
<NavLink to="/signin/">Back to sign in</NavLink>
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingCancel;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 5;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 3rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
const BodyText = styled.p`
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const NavLink = styled(Link)`
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const BillingSuccess = (): JSX.Element => {
|
||||
const { authenticated, loading } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [sessionId] = useState(() => searchParams.get('session_id') || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && authenticated) {
|
||||
const timer = window.setTimeout(() => navigate('/'), 2500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [authenticated, loading, navigate]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ParticleBackground />
|
||||
<ContentWrapper>
|
||||
<GlassCard>
|
||||
<CardTitle>Payment received</CardTitle>
|
||||
<BodyText>
|
||||
Thanks — your subscription checkout completed
|
||||
{sessionId ? ` (session ${sessionId.slice(0, 12)}…)` : ''}. You can continue into the app.
|
||||
</BodyText>
|
||||
{authenticated ? (
|
||||
<PrimaryButton type="button" onClick={() => navigate('/')}>
|
||||
Continue to app
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<>
|
||||
<BodyText>Sign in with the account you just created to get started.</BodyText>
|
||||
<PrimaryButton type="button" onClick={() => navigate('/signin/')}>
|
||||
Sign in
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/signin/">Back to sign in</NavLink>
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingSuccess;
|
||||
@@ -51,9 +51,10 @@ describe('SignIn', () => {
|
||||
localStorage.clear();
|
||||
mockPost.mockReset();
|
||||
mockGet.mockReset();
|
||||
mockGet.mockResolvedValue({ data: { enable_account_registration: false } });
|
||||
});
|
||||
|
||||
it('renders sign-in form fields', () => {
|
||||
it('renders sign-in form fields', async () => {
|
||||
renderSignIn();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
|
||||
@@ -61,6 +62,16 @@ describe('SignIn', () => {
|
||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Forgot Password?' })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /Don't have an account/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows sign-up link when registration enabled', async () => {
|
||||
mockGet.mockResolvedValue({ data: { enable_account_registration: true } });
|
||||
renderSignIn();
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Don't have an account\? Sign up/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not submit when required fields empty', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { setTokens } from '../../auth/tokenStorage';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
@@ -126,6 +126,21 @@ const ForgotPasswordLink = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const SignUpLink = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export type SignInValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -141,6 +156,26 @@ const SignIn = (): JSX.Element => {
|
||||
const { setAccount } = useContext(AccountContext);
|
||||
const navigate = useNavigate();
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/public/settings/');
|
||||
if (!cancelled) {
|
||||
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setRegistrationEnabled(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSignIn = async ({ email, password }: SignInValues): Promise<void> => {
|
||||
try {
|
||||
@@ -223,6 +258,11 @@ const SignIn = (): JSX.Element => {
|
||||
<ForgotPasswordLink onClick={() => navigate('/password_reset')}>
|
||||
Forgot Password?
|
||||
</ForgotPasswordLink>
|
||||
{registrationEnabled && (
|
||||
<SignUpLink onClick={() => navigate('/signup/')}>
|
||||
Don't have an account? Sign up
|
||||
</SignUpLink>
|
||||
)}
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import SignUp from './SignUp';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockGet = jest.fn();
|
||||
const assignMock = jest.fn();
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { headers: { common: {} as Record<string, string | null> } },
|
||||
},
|
||||
applyAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
const renderSignUp = () => {
|
||||
const setAuthentication = jest.fn();
|
||||
const setNeedsNewPassword = jest.fn();
|
||||
const setAccount = jest.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
authenticated: false,
|
||||
setAuthentication,
|
||||
needsNewPassword: false,
|
||||
setNeedsNewPassword,
|
||||
loading: false,
|
||||
}}
|
||||
>
|
||||
<AccountContext.Provider value={{ account: undefined, setAccount }}>
|
||||
<SignUp />
|
||||
</AccountContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
return { setAuthentication, setNeedsNewPassword, setAccount };
|
||||
};
|
||||
|
||||
describe('SignUp', () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockPost.mockReset();
|
||||
mockGet.mockReset();
|
||||
assignMock.mockReset();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, assign: assignMock, origin: 'http://localhost' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows disabled message when registration flag is off', async () => {
|
||||
mockGet.mockResolvedValue({ data: { enable_account_registration: false } });
|
||||
renderSignUp();
|
||||
|
||||
expect(await screen.findByText(/not available right now/i)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Email Address')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not submit when required fields empty', async () => {
|
||||
mockGet.mockResolvedValue({ data: { enable_account_registration: true } });
|
||||
const user = userEvent.setup();
|
||||
renderSignUp();
|
||||
|
||||
expect(await screen.findByPlaceholderText('Email Address')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Sign Up & Pay/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('registers then redirects to Stripe Checkout URL', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: { enable_account_registration: true } })
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
email: 'new@example.com',
|
||||
first_name: 'New',
|
||||
last_name: 'User',
|
||||
is_company_manager: true,
|
||||
has_signed_tos: false,
|
||||
has_usable_password: true,
|
||||
company: { id: 1, name: 'New Co', state: 'NA', zipcode: '00000', address: 'N/A' },
|
||||
},
|
||||
});
|
||||
mockPost
|
||||
.mockResolvedValueOnce({
|
||||
data: { access: 'access-token', refresh: 'refresh-token', email: 'new@example.com' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
checkout_url: 'https://checkout.stripe.com/c/pay/cs_test_abc',
|
||||
session_id: 'cs_test_abc',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { setAuthentication } = renderSignUp();
|
||||
|
||||
await screen.findByPlaceholderText('Email Address');
|
||||
await user.type(screen.getByPlaceholderText('Email Address'), 'new@example.com');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'securepass1');
|
||||
await user.type(screen.getByPlaceholderText('Confirm password'), 'securepass1');
|
||||
await user.click(screen.getByRole('button', { name: /Sign Up & Pay/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/user/create/', {
|
||||
email: 'new@example.com',
|
||||
password: 'securepass1',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company_name: '',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/finance/checkout/', {
|
||||
success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}',
|
||||
cancel_url: 'http://localhost/billing/cancel',
|
||||
});
|
||||
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.com/c/pay/cs_test_abc');
|
||||
expect(setAuthentication).toHaveBeenCalledWith(true);
|
||||
expect(localStorage.getItem('access_token')).toBe('access-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { setTokens } from '../../auth/tokenStorage';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Account, AccountType } from '../../data';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 5;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 3rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const CardSubtitle = styled.p`
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.95rem;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(100, 149, 237, 0.5);
|
||||
box-shadow: 0 0 10px rgba(100, 149, 237, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled.button`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
padding: 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const NavLink = styled(Link)`
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const FieldError = styled.div`
|
||||
color: #ff6b6b;
|
||||
font-size: 0.8rem;
|
||||
margin-top: -1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
export type SignUpValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
email: Yup.string().email('Invalid email').required('Required'),
|
||||
password: Yup.string().min(8, 'Password must be at least 8 characters').required('Required'),
|
||||
confirmPassword: Yup.string()
|
||||
.oneOf([Yup.ref('password')], 'Passwords must match')
|
||||
.required('Required'),
|
||||
first_name: Yup.string(),
|
||||
last_name: Yup.string(),
|
||||
company_name: Yup.string(),
|
||||
});
|
||||
|
||||
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
|
||||
const origin = window.location.origin;
|
||||
return {
|
||||
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/billing/cancel`,
|
||||
};
|
||||
}
|
||||
|
||||
const SignUp = (): JSX.Element => {
|
||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||
const { setAccount } = useContext(AccountContext);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/public/settings/');
|
||||
if (!cancelled) {
|
||||
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setRegistrationEnabled(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadAccount = async (): Promise<Account> => {
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
|
||||
const account = new Account({
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
is_company_manager: get_user_response.data.is_company_manager,
|
||||
has_signed_tos: get_user_response.data.has_signed_tos,
|
||||
company: {
|
||||
id: get_user_response.data.company?.id,
|
||||
name: get_user_response.data.company?.name,
|
||||
state: get_user_response.data.company?.state,
|
||||
zipcode: get_user_response.data.company?.zipcode,
|
||||
address: get_user_response.data.company?.address,
|
||||
},
|
||||
});
|
||||
setAccount(account);
|
||||
setAuthentication(true);
|
||||
setNeedsNewPassword(get_user_response.data.has_usable_password);
|
||||
return account;
|
||||
};
|
||||
|
||||
const handleSignUp = async (values: SignUpValues): Promise<void> => {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const registerResponse = await axiosInstance.post('/user/create/', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
first_name: values.first_name,
|
||||
last_name: values.last_name,
|
||||
company_name: values.company_name,
|
||||
});
|
||||
|
||||
await setTokens(registerResponse.data.access, registerResponse.data.refresh);
|
||||
applyAccessToken(registerResponse.data.access);
|
||||
await loadAccount();
|
||||
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||
success_url,
|
||||
cancel_url,
|
||||
});
|
||||
|
||||
const checkoutUrl = checkoutResponse.data?.checkout_url;
|
||||
if (!checkoutUrl) {
|
||||
setErrorMessage('Account created, but checkout could not be started. Try signing in.');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(checkoutUrl);
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { detail?: string; email?: string[] } };
|
||||
};
|
||||
if (axiosError.response?.status === 403) {
|
||||
setErrorMessage('Account registration is currently disabled.');
|
||||
} else if (axiosError.response?.data?.email?.[0]) {
|
||||
setErrorMessage(axiosError.response.data.email[0]);
|
||||
} else if (axiosError.response?.data?.detail) {
|
||||
setErrorMessage(String(axiosError.response.data.detail));
|
||||
} else {
|
||||
setErrorMessage('Could not create account. Try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (registrationEnabled === null) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ParticleBackground />
|
||||
<ContentWrapper>
|
||||
<GlassCard>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<CardSubtitle>Loading…</CardSubtitle>
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ParticleBackground />
|
||||
<ContentWrapper>
|
||||
<GlassCard>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<ErrorMessage>Self-serve registration is not available right now.</ErrorMessage>
|
||||
<NavLink to="/signin/">Already have an account? Sign in</NavLink>
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ParticleBackground />
|
||||
<ContentWrapper>
|
||||
<GlassCard>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<CardSubtitle>Create an account, then complete payment on Stripe.</CardSubtitle>
|
||||
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company_name: '',
|
||||
}}
|
||||
onSubmit={handleSignUp}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
{(formik) => (
|
||||
<Form style={{ width: '100%' }}>
|
||||
<Field as={StyledInput} name="email" placeholder="Email Address" type="email" />
|
||||
{formik.touched.email && formik.errors.email && (
|
||||
<FieldError>{formik.errors.email}</FieldError>
|
||||
)}
|
||||
<Field as={StyledInput} name="first_name" placeholder="First name (optional)" type="text" />
|
||||
<Field as={StyledInput} name="last_name" placeholder="Last name (optional)" type="text" />
|
||||
<Field
|
||||
as={StyledInput}
|
||||
name="company_name"
|
||||
placeholder="Company / workspace (optional)"
|
||||
type="text"
|
||||
/>
|
||||
<Field as={StyledInput} name="password" placeholder="Password" type="password" />
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<FieldError>{formik.errors.password}</FieldError>
|
||||
)}
|
||||
<Field
|
||||
as={StyledInput}
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<FieldError>{formik.errors.confirmPassword}</FieldError>
|
||||
)}
|
||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||
{formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'}
|
||||
</StyledButton>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<NavLink to="/signin/">Already have an account? Sign in</NavLink>
|
||||
</GlassCard>
|
||||
</ContentWrapper>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
Reference in New Issue
Block a user