move to webapp
This commit is contained in:
330
src/__tests__/pages/__snapshots__/index.tsx.snap
Normal file
330
src/__tests__/pages/__snapshots__/index.tsx.snap
Normal file
@ -0,0 +1,330 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`/ landing page snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<section
|
||||
class="bg-white"
|
||||
>
|
||||
<section
|
||||
class="bg-primary-700 bg-opacity-5"
|
||||
>
|
||||
<header
|
||||
class="max-w-screen-lg mx-auto px-3 py-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap justify-between items-center"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
<div
|
||||
class="relative h-8 w-8"
|
||||
>
|
||||
<div
|
||||
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||
>
|
||||
<noscript />
|
||||
<img
|
||||
alt="app logo"
|
||||
decoding="async"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<nav
|
||||
class="flex items-center justify-end flex-1 w-0"
|
||||
>
|
||||
<a
|
||||
class="whitespace-nowrap text-base font-medium text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out"
|
||||
href="/auth/sign-in"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
class="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out"
|
||||
href="/auth/sign-up"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
class="max-w-screen-lg mx-auto px-3 pt-16 pb-24 text-center"
|
||||
>
|
||||
<h2
|
||||
class="text-5xl tracking-tight font-extrabold text-gray-900"
|
||||
>
|
||||
Welcome to your
|
||||
<br />
|
||||
<span
|
||||
class="text-primary-600"
|
||||
>
|
||||
serverless
|
||||
</span>
|
||||
web app
|
||||
</h2>
|
||||
<p
|
||||
class="mt-3 text-lg text-gray-800 sm:mt-5 sm:max-w-xl sm:mx-auto"
|
||||
>
|
||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet fugiat veniam occaecat fugiat aliqua.
|
||||
</p>
|
||||
<div
|
||||
class="mt-12 space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:flex-row-reverse sm:justify-center"
|
||||
>
|
||||
<div
|
||||
class="rounded-md shadow"
|
||||
>
|
||||
<a
|
||||
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out md:py-4 md:text-lg"
|
||||
href="/auth/sign-up"
|
||||
>
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out md:py-4 md:text-lg"
|
||||
href="/auth/sign-in"
|
||||
>
|
||||
I'm already a user
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
<div
|
||||
class="py-20"
|
||||
>
|
||||
<div
|
||||
class="max-w-screen-lg mx-auto space-y-32 px-3"
|
||||
>
|
||||
<div
|
||||
class="text-center"
|
||||
>
|
||||
<h3
|
||||
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||
>
|
||||
A better way to bootstrap your app
|
||||
</h3>
|
||||
<p
|
||||
class="mt-4 max-w-2xl text-lg leading-7 text-gray-600 lg:mx-auto"
|
||||
>
|
||||
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis in accusamus quisquam.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col-reverse items-center justify-between md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex-1 text-center"
|
||||
>
|
||||
<h3
|
||||
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||
>
|
||||
Feature #1
|
||||
</h3>
|
||||
<div
|
||||
class="mt-6 text-lg leading-9 text-gray-800"
|
||||
>
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-96 h-60"
|
||||
>
|
||||
<div
|
||||
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||
>
|
||||
<noscript />
|
||||
<img
|
||||
alt="Feature Feature #1 illustration"
|
||||
decoding="async"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col-reverse items-center justify-between md:flex-row-reverse"
|
||||
>
|
||||
<div
|
||||
class="flex-1 text-center"
|
||||
>
|
||||
<h3
|
||||
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||
>
|
||||
Feature #2
|
||||
</h3>
|
||||
<div
|
||||
class="mt-6 text-lg leading-9 text-gray-800"
|
||||
>
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-96 h-60"
|
||||
>
|
||||
<div
|
||||
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||
>
|
||||
<noscript />
|
||||
<img
|
||||
alt="Feature Feature #2 illustration"
|
||||
decoding="async"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col-reverse items-center justify-between md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex-1 text-center"
|
||||
>
|
||||
<h3
|
||||
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
|
||||
>
|
||||
Feature #3
|
||||
</h3>
|
||||
<div
|
||||
class="mt-6 text-lg leading-9 text-gray-800"
|
||||
>
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-96 h-60"
|
||||
>
|
||||
<div
|
||||
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
|
||||
>
|
||||
<noscript />
|
||||
<img
|
||||
alt="Feature Feature #3 illustration"
|
||||
decoding="async"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-primary-700 bg-opacity-5"
|
||||
>
|
||||
<div
|
||||
class="max-w-screen-lg mx-auto px-3 py-16 xl:flex xl:items-center"
|
||||
>
|
||||
<div
|
||||
class="xl:w-0 xl:flex-1"
|
||||
>
|
||||
<h2
|
||||
class="text-3xl font-extrabold tracking-tight"
|
||||
>
|
||||
Want to know when we launch?
|
||||
</h2>
|
||||
<p
|
||||
class="mt-3 max-w-3xl text-lg leading-6 text-gray-600"
|
||||
>
|
||||
Lorem ipsum, dolor sit amet.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mt-8 sm:w-full sm:max-w-md xl:mt-0 xl:ml-8"
|
||||
>
|
||||
<form
|
||||
class="sm:flex"
|
||||
>
|
||||
<input
|
||||
autocomplete=""
|
||||
class="w-full border-gray-300 px-5 py-3 placeholder-gray-500 rounded-md"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
required=""
|
||||
type="email"
|
||||
/>
|
||||
<button
|
||||
class="mt-3 w-full flex items-center justify-center px-5 py-3 border border-transparent shadow text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out sm:mt-0 sm:ml-3 sm:w-auto sm:flex-shrink-0"
|
||||
type="submit"
|
||||
>
|
||||
💌 Notify me
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-w-screen-xl mx-auto py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center md:order-2"
|
||||
>
|
||||
<a
|
||||
class="ml-6 text-gray-500 hover:text-gray-600"
|
||||
href="https://twitter.com/m5r_m"
|
||||
>
|
||||
<span
|
||||
class="sr-only"
|
||||
>
|
||||
Twitter
|
||||
</span>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
class="ml-6 text-gray-500 hover:text-gray-600"
|
||||
href="https://github.com/m5r"
|
||||
>
|
||||
<span
|
||||
class="sr-only"
|
||||
>
|
||||
GitHub
|
||||
</span>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="mt-8 md:mt-0 md:order-1"
|
||||
>
|
||||
<p
|
||||
class="text-center text-base leading-6 text-gray-600"
|
||||
>
|
||||
© 2021
|
||||
<a
|
||||
href="https://www.capsulecorp.dev"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Capsule Corp.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DocumentFragment>
|
||||
`;
|
171
src/__tests__/pages/account/settings/index.tsx
Normal file
171
src/__tests__/pages/account/settings/index.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
jest.mock("next/router", () => ({
|
||||
useRouter: jest.fn(),
|
||||
withRouter: (element: ComponentType) => element,
|
||||
}));
|
||||
|
||||
import type { ComponentType, FunctionComponent } from "react";
|
||||
import { rest } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useRouter } from "next/router";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { render, waitFor, screen } from "../../../../../jest/testing-library";
|
||||
|
||||
import type { SerializedSession } from "../../../../../lib/session";
|
||||
import SettingsPage from "../../../../pages/account/settings";
|
||||
import { SessionProvider } from "../../../../session-context";
|
||||
import { SidebarProvider } from "../../../../components/layout/sidebar";
|
||||
|
||||
const consoleError = console.error;
|
||||
|
||||
describe("/account/settings", () => {
|
||||
type RequestBody = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
console.error = jest.fn();
|
||||
const mockedUseRouter = useRouter as jest.Mock<
|
||||
Partial<ReturnType<typeof useRouter>>
|
||||
>;
|
||||
const mockedPush = jest.fn();
|
||||
mockedUseRouter.mockImplementation(() => ({
|
||||
push: mockedPush,
|
||||
pathname: "/account/settings",
|
||||
}));
|
||||
|
||||
const session: SerializedSession = {
|
||||
user: {
|
||||
id: "auth0|1234567",
|
||||
email: "test@fss.dev",
|
||||
name: "test",
|
||||
role: "owner",
|
||||
teamId: "98765",
|
||||
},
|
||||
};
|
||||
const server = setupServer(
|
||||
rest.get("/api/user/session", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(session));
|
||||
}),
|
||||
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||
return res(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper: FunctionComponent = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider>
|
||||
<SessionProvider session={session}>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</SidebarProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedPush.mockClear();
|
||||
mockedUseRouter.mockClear();
|
||||
queryClient.clear();
|
||||
});
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
test("update email only", async () => {
|
||||
render(<SettingsPage session={session} />, { wrapper });
|
||||
|
||||
userEvent.type(
|
||||
screen.getByLabelText("Email address"),
|
||||
"test2@fss.dev{enter}",
|
||||
);
|
||||
await waitFor(() => screen.getByText("Your changes have been saved."));
|
||||
});
|
||||
|
||||
test("mismatching passwords", async () => {
|
||||
render(<SettingsPage session={session} />, { wrapper });
|
||||
|
||||
userEvent.type(screen.getByLabelText("New password"), "new password");
|
||||
userEvent.type(
|
||||
screen.getByLabelText("Confirm new password"),
|
||||
"does not match{enter}",
|
||||
);
|
||||
await waitFor(() => screen.getByText("New passwords don't match"));
|
||||
});
|
||||
|
||||
test("invalid email format", async () => {
|
||||
server.use(
|
||||
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
statusCode: 400,
|
||||
errorMessage: "Body is malformed",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsPage session={session} />, { wrapper });
|
||||
|
||||
userEvent.type(
|
||||
screen.getByLabelText("Email address"),
|
||||
"malformed@email{enter}",
|
||||
);
|
||||
await waitFor(() => screen.getByText("Body is malformed"));
|
||||
});
|
||||
|
||||
test("redirect to sign in page on 401 unauthorized", async () => {
|
||||
server.use(
|
||||
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
|
||||
return res(ctx.status(401));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsPage session={session} />, { wrapper });
|
||||
|
||||
userEvent.type(
|
||||
screen.getByLabelText("Email address"),
|
||||
"unauthorized@fss.dev{enter}",
|
||||
);
|
||||
await waitFor(() => expect(mockedPush).toBeCalledTimes(1));
|
||||
await waitFor(() => expect(mockedPush).toBeCalledWith("/auth/sign-in"));
|
||||
});
|
||||
|
||||
test("redirect to sign in page if user is unauthenticated", async () => {
|
||||
server.use(
|
||||
rest.get("/api/user/session", (req, res, ctx) => {
|
||||
return res(ctx.status(401));
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper: FunctionComponent = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider>
|
||||
<SessionProvider session={null}>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</SidebarProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
render(<SettingsPage session={session} />, { wrapper });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedPush).toBeCalledWith(
|
||||
"/auth/sign-in?redirectTo=/account/settings",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
323
src/__tests__/pages/account/settings/team.tsx
Normal file
323
src/__tests__/pages/account/settings/team.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
jest.mock("next/router", () => ({
|
||||
useRouter: jest.fn(),
|
||||
withRouter: (element: ComponentType) => element,
|
||||
}));
|
||||
jest.mock("../../../../database/users", () => ({ findUsersByTeam: jest.fn() }));
|
||||
jest.mock("../../../../database/teams", () => ({ findTeam: jest.fn() }));
|
||||
|
||||
import type { ComponentType, FunctionComponent } from "react";
|
||||
import { rest } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useRouter } from "next/router";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { render, waitFor, screen } from "../../../../../jest/testing-library";
|
||||
import { act, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
|
||||
import type { SerializedSession } from "../../../../../lib/session";
|
||||
import Session from "../../../../../lib/session";
|
||||
import type { TeamMembers } from "../../../../pages/api/team/members";
|
||||
import TeamPage, {
|
||||
getServerSideProps,
|
||||
} from "../../../../pages/account/settings/team";
|
||||
import type { User } from "../../../../database/users";
|
||||
import { findUsersByTeam } from "../../../../database/users";
|
||||
import { findTeam } from "../../../../database/teams";
|
||||
import { sessionCache } from "../../../../../lib/session-helpers";
|
||||
import { SessionProvider } from "../../../../session-context";
|
||||
import { SidebarProvider } from "../../../../components/layout/sidebar";
|
||||
|
||||
describe("/account/settings/team", () => {
|
||||
const mockedPush = jest.fn();
|
||||
const mockedUseRouter = useRouter as jest.Mock<
|
||||
Partial<ReturnType<typeof useRouter>>
|
||||
>;
|
||||
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
|
||||
const mockedFindUsersByTeam = findUsersByTeam as jest.Mock<
|
||||
ReturnType<typeof findUsersByTeam>
|
||||
>;
|
||||
window.IntersectionObserver = jest
|
||||
.fn()
|
||||
.mockImplementation(() => ({
|
||||
observe: () => null,
|
||||
disconnect: () => null,
|
||||
}));
|
||||
|
||||
const session: SerializedSession = {
|
||||
user: {
|
||||
id: "auth0|1234567",
|
||||
email: "test@fss.dev",
|
||||
name: "test",
|
||||
role: "owner",
|
||||
teamId: "98765",
|
||||
},
|
||||
};
|
||||
const createdAt = new Date();
|
||||
const teamMembers = [
|
||||
{
|
||||
...session.user,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
];
|
||||
const team = {
|
||||
id: "98765",
|
||||
subscriptionId: null,
|
||||
teamMembersLimit: 2,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
const teamMembersResponse: TeamMembers = {
|
||||
teamMembers,
|
||||
teamMembersLimit: team.teamMembersLimit,
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
rest.get("/api/user/session", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(session));
|
||||
}),
|
||||
rest.get("/api/team/members", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(teamMembersResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
mockedFindUsersByTeam.mockResolvedValue(teamMembers);
|
||||
mockedFindTeam.mockResolvedValue(team);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper: FunctionComponent = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider>
|
||||
<SessionProvider session={session}>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</SidebarProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedPush.mockClear();
|
||||
mockedUseRouter.mockClear();
|
||||
mockedFindTeam.mockClear();
|
||||
mockedFindUsersByTeam.mockClear();
|
||||
mockedUseRouter.mockImplementation(() => ({
|
||||
push: mockedPush,
|
||||
pathname: "/account/settings",
|
||||
}));
|
||||
queryClient.clear();
|
||||
});
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
test("list team members and display team limit", async () => {
|
||||
const teamMembersLimit = team.teamMembersLimit;
|
||||
|
||||
render(
|
||||
<TeamPage
|
||||
session={session}
|
||||
teamMembers={teamMembers}
|
||||
teamMembersLimit={teamMembersLimit}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
screen.getByText(
|
||||
(_, node) =>
|
||||
node?.textContent ===
|
||||
"Your team has 1 out of 2 team members.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("invite someone", async () => {
|
||||
const inviteMemberHandler = jest.fn();
|
||||
server.use(
|
||||
rest.post("/api/team/invite-member", (req, res, ctx) => {
|
||||
inviteMemberHandler();
|
||||
return res(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
const teamMembersLimit = team.teamMembersLimit;
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TeamPage
|
||||
session={session}
|
||||
teamMembers={teamMembers}
|
||||
teamMembersLimit={teamMembersLimit}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByText("Invite member"));
|
||||
await waitFor(() =>
|
||||
screen.getByText("Invite a member to your team"),
|
||||
);
|
||||
userEvent.type(
|
||||
screen.getByLabelText("Email address"),
|
||||
"recipient@fss.dev{enter}",
|
||||
);
|
||||
await waitForElementToBeRemoved(
|
||||
screen.getByLabelText("Email address"),
|
||||
);
|
||||
expect(inviteMemberHandler).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("team member management", () => {
|
||||
const createdAt = new Date();
|
||||
const invitedUser: User = {
|
||||
id: "auth0|112233",
|
||||
email: "recipient@fss.dev",
|
||||
name: "recipient",
|
||||
teamId: session.user.teamId,
|
||||
role: "member",
|
||||
pendingInvitation: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
const teamMembers = [
|
||||
{
|
||||
...session.user,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
invitedUser,
|
||||
];
|
||||
const team = {
|
||||
id: "98765",
|
||||
subscriptionId: null,
|
||||
teamMembersLimit: 2,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
const teamMembersResponse: TeamMembers = {
|
||||
teamMembers,
|
||||
teamMembersLimit: team.teamMembersLimit,
|
||||
};
|
||||
const teamMembersLimit = team.teamMembersLimit;
|
||||
|
||||
server.use(
|
||||
rest.get("/api/team/members", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(teamMembersResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
test("re-send invitation", async () => {
|
||||
const resendInvitationHandler = jest.fn();
|
||||
server.use(
|
||||
rest.post("/api/team/resend-invitation", (req, res, ctx) => {
|
||||
resendInvitationHandler();
|
||||
return res.once(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TeamPage
|
||||
session={session}
|
||||
teamMembers={teamMembers}
|
||||
teamMembersLimit={teamMembersLimit}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
userEvent.click(
|
||||
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
|
||||
);
|
||||
userEvent.click(screen.getByText("Re-send invitation"));
|
||||
await waitFor(() =>
|
||||
expect(resendInvitationHandler).toBeCalledTimes(1),
|
||||
);
|
||||
});
|
||||
|
||||
test("cancel invitation", async () => {
|
||||
const cancelInvitationHandler = jest.fn();
|
||||
server.use(
|
||||
rest.post("/api/team/remove-member", (req, res, ctx) => {
|
||||
cancelInvitationHandler();
|
||||
return res.once(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TeamPage
|
||||
session={session}
|
||||
teamMembers={teamMembers}
|
||||
teamMembersLimit={teamMembersLimit}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// await waitFor(() => screen.getByText((_, node) => node?.textContent === "Your team has 2 out of 2 team members."));
|
||||
userEvent.click(
|
||||
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
|
||||
);
|
||||
userEvent.click(screen.getByText("Cancel invitation"));
|
||||
userEvent.click(screen.getByText("Remove from my team"));
|
||||
await waitFor(() =>
|
||||
expect(cancelInvitationHandler).toBeCalledTimes(1),
|
||||
);
|
||||
await waitFor(() =>
|
||||
screen.getByText(
|
||||
(_, node) =>
|
||||
node?.textContent ===
|
||||
"Your team has 1 out of 2 team members.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerSideProps", () => {
|
||||
const context: any = {
|
||||
req: {},
|
||||
res: {},
|
||||
resolvedUrl: "/account/settings/team",
|
||||
};
|
||||
sessionCache.set(context.req, context.req, new Session(session.user));
|
||||
|
||||
test("return team members and team limit", async () => {
|
||||
const serverSideProps = await getServerSideProps(context);
|
||||
// @ts-ignore
|
||||
delete serverSideProps.props._superjson;
|
||||
expect(serverSideProps).toStrictEqual({
|
||||
props: {
|
||||
session: {
|
||||
accessToken: null,
|
||||
accessTokenExpiresAt: null,
|
||||
accessTokenScope: null,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
user: {
|
||||
email: "test@fss.dev",
|
||||
id: "auth0|1234567",
|
||||
name: "test",
|
||||
role: "owner",
|
||||
teamId: "98765",
|
||||
},
|
||||
},
|
||||
teamMembers: [
|
||||
{
|
||||
createdAt: createdAt.toISOString(),
|
||||
email: "test@fss.dev",
|
||||
id: "auth0|1234567",
|
||||
name: "test",
|
||||
role: "owner",
|
||||
teamId: "98765",
|
||||
updatedAt: createdAt.toISOString(),
|
||||
},
|
||||
],
|
||||
teamMembersLimit: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
221
src/__tests__/pages/api/auth/sign-in.ts
Normal file
221
src/__tests__/pages/api/auth/sign-in.ts
Normal file
@ -0,0 +1,221 @@
|
||||
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||
getAppMetadata: jest.fn(),
|
||||
setAppMetadata: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../database/users", () => ({
|
||||
findUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||
sendEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
|
||||
|
||||
import { parse } from "set-cookie-parser";
|
||||
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import signInHandler from "../../../../pages/api/auth/sign-in";
|
||||
import { sessionName } from "../../../../../lib/cookie-store";
|
||||
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||
import { findUser, createUser } from "../../../../database/users";
|
||||
import { createTeam } from "../../../../database/teams";
|
||||
import { getAppMetadata } from "../../../../pages/api/user/_auth0";
|
||||
|
||||
describe("/api/auth/sign-in", () => {
|
||||
const mockedSendEmail = sendEmail as jest.Mock<
|
||||
ReturnType<typeof sendEmail>
|
||||
>;
|
||||
const mockedGetAppMetadata = getAppMetadata as jest.Mock<
|
||||
ReturnType<typeof getAppMetadata>
|
||||
>;
|
||||
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
|
||||
const mockedCreateUser = createUser as jest.Mock<
|
||||
ReturnType<typeof createUser>
|
||||
>;
|
||||
const mockedCreateTeam = createTeam as jest.Mock<
|
||||
ReturnType<typeof createTeam>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFindUser.mockClear();
|
||||
mockedCreateUser.mockClear();
|
||||
mockedGetAppMetadata.mockClear();
|
||||
mockedSendEmail.mockClear();
|
||||
mockedCreateTeam.mockClear();
|
||||
});
|
||||
|
||||
test("responds 405 to GET", async () => {
|
||||
const response = await callApiHandler(signInHandler, { method: "GET" });
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 400 to POST with malformed body", async () => {
|
||||
const response = await callApiHandler(signInHandler, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with body from email login", async () => {
|
||||
mockedFindUser.mockResolvedValue({
|
||||
id: "auth0|1234567",
|
||||
teamId: "98765",
|
||||
role: "owner",
|
||||
email: "test@fss.dev",
|
||||
name: "Groot",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
|
||||
|
||||
const body = {
|
||||
accessToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
|
||||
idToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
|
||||
scope:
|
||||
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
|
||||
tokenType: "Bearer",
|
||||
refreshToken:
|
||||
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
|
||||
expiresIn: 86400,
|
||||
};
|
||||
const response = await callApiHandler(signInHandler, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||
const parsedCookies = parse(setCookieHeader);
|
||||
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||
);
|
||||
expect(cookieHasSession).toBe(true);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with body from 3rd party provider login", async () => {
|
||||
mockedFindUser.mockResolvedValue({
|
||||
id: "google-oauth2|103423079071922868186",
|
||||
teamId: "98765",
|
||||
role: "owner",
|
||||
email: "fss.user@gmail.com",
|
||||
name: "FSS User",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
|
||||
|
||||
const body = {
|
||||
accessToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
|
||||
idToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
|
||||
idTokenPayload: {
|
||||
given_name: "FSS",
|
||||
family_name: "User",
|
||||
nickname: "fss.user",
|
||||
name: "FSS User",
|
||||
picture:
|
||||
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
|
||||
locale: "en",
|
||||
updated_at: "2021-02-13T15:07:48.349Z",
|
||||
email: "fss.user@gmail.com",
|
||||
email_verified: true,
|
||||
iss: "https://mokhtar.eu.auth0.com/",
|
||||
sub: "google-oauth2|103423079071922868186",
|
||||
aud: "audience",
|
||||
iat: 1613228868,
|
||||
exp: 1613264868,
|
||||
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
|
||||
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
|
||||
},
|
||||
appState: null,
|
||||
refreshToken: null,
|
||||
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
|
||||
expiresIn: 7200,
|
||||
tokenType: "Bearer",
|
||||
scope: "openid profile email offline_access",
|
||||
};
|
||||
const response = await callApiHandler(signInHandler, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||
const parsedCookies = parse(setCookieHeader);
|
||||
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||
);
|
||||
expect(cookieHasSession).toBe(true);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with body from 3rd party provider login for the first time", async () => {
|
||||
mockedFindUser.mockResolvedValue(undefined);
|
||||
mockedCreateTeam.mockResolvedValue({
|
||||
id: "98765",
|
||||
subscriptionId: null,
|
||||
teamMembersLimit: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockedCreateUser.mockResolvedValue({
|
||||
id: "google-oauth2|103423079071922868186",
|
||||
teamId: "98765",
|
||||
role: "owner",
|
||||
email: "fss.user@gmail.com",
|
||||
name: "FSS User",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const body = {
|
||||
accessToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
|
||||
idToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
|
||||
idTokenPayload: {
|
||||
given_name: "FSS",
|
||||
family_name: "User",
|
||||
nickname: "fss.user",
|
||||
name: "FSS User",
|
||||
picture:
|
||||
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
|
||||
locale: "en",
|
||||
updated_at: "2021-02-13T15:07:48.349Z",
|
||||
email: "fss.user@gmail.com",
|
||||
email_verified: true,
|
||||
iss: "https://mokhtar.eu.auth0.com/",
|
||||
sub: "google-oauth2|103423079071922868186",
|
||||
aud: "audience",
|
||||
iat: 1613228868,
|
||||
exp: 1613264868,
|
||||
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
|
||||
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
|
||||
},
|
||||
appState: null,
|
||||
refreshToken: null,
|
||||
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
|
||||
expiresIn: 7200,
|
||||
tokenType: "Bearer",
|
||||
scope: "openid profile email offline_access",
|
||||
};
|
||||
const response = await callApiHandler(signInHandler, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSendEmail).toBeCalledTimes(1);
|
||||
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
|
||||
"fss.user@gmail.com",
|
||||
);
|
||||
|
||||
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||
const parsedCookies = parse(setCookieHeader);
|
||||
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||
);
|
||||
expect(cookieHasSession).toBe(true);
|
||||
});
|
||||
});
|
95
src/__tests__/pages/api/auth/sign-up.ts
Normal file
95
src/__tests__/pages/api/auth/sign-up.ts
Normal file
@ -0,0 +1,95 @@
|
||||
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||
setAppMetadata: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||
sendEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../database/users", () => ({ createUser: jest.fn() }));
|
||||
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
|
||||
|
||||
import { parse } from "set-cookie-parser";
|
||||
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import signUpHandler from "../../../../pages/api/auth/sign-up";
|
||||
import { sessionName } from "../../../../../lib/cookie-store";
|
||||
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||
import { createUser } from "../../../../database/users";
|
||||
import { createTeam } from "../../../../database/teams";
|
||||
|
||||
describe("/api/auth/sign-up", () => {
|
||||
const mockedSendEmail = sendEmail as jest.Mock<
|
||||
ReturnType<typeof sendEmail>
|
||||
>;
|
||||
const mockedCreateUser = createUser as jest.Mock<
|
||||
ReturnType<typeof createUser>
|
||||
>;
|
||||
const mockedCreateTeam = createTeam as jest.Mock<
|
||||
ReturnType<typeof createTeam>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedSendEmail.mockClear();
|
||||
mockedCreateUser.mockClear();
|
||||
mockedCreateTeam.mockClear();
|
||||
});
|
||||
|
||||
test("responds 405 to GET", async () => {
|
||||
const response = await callApiHandler(signUpHandler, { method: "GET" });
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 400 to POST with malformed body", async () => {
|
||||
const response = await callApiHandler(signUpHandler, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with body from email login", async () => {
|
||||
mockedCreateUser.mockResolvedValue({
|
||||
id: "auth0|1234567",
|
||||
teamId: "98765",
|
||||
role: "owner",
|
||||
email: "test@fss.dev",
|
||||
name: "Groot",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockedCreateTeam.mockResolvedValue({
|
||||
id: "98765",
|
||||
subscriptionId: null,
|
||||
teamMembersLimit: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const body = {
|
||||
accessToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
|
||||
idToken:
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
|
||||
scope:
|
||||
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
|
||||
tokenType: "Bearer",
|
||||
refreshToken:
|
||||
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
|
||||
expiresIn: 86400,
|
||||
};
|
||||
const response = await callApiHandler(signUpHandler, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSendEmail).toBeCalledTimes(1);
|
||||
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
|
||||
"test@fss.dev",
|
||||
);
|
||||
|
||||
const setCookieHeader = response.headers.get("set-cookie")!;
|
||||
const parsedCookies = parse(setCookieHeader);
|
||||
const cookieHasSession = parsedCookies.some((cookie) =>
|
||||
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
|
||||
);
|
||||
expect(cookieHasSession).toBe(true);
|
||||
});
|
||||
});
|
164
src/__tests__/pages/api/subscription/_subscription-created.ts
Normal file
164
src/__tests__/pages/api/subscription/_subscription-created.ts
Normal file
@ -0,0 +1,164 @@
|
||||
jest.mock("../../../../database/teams", () => ({
|
||||
findTeam: jest.fn(),
|
||||
updateTeam: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../database/subscriptions", () => ({
|
||||
...jest.requireActual("../../../../database/subscriptions"),
|
||||
createSubscription: jest.fn(),
|
||||
findTeamSubscription: jest.fn(),
|
||||
updateSubscription: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||
sendEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../subscription/plans", () => ({
|
||||
PAID_PLANS: {
|
||||
"229": { teamMembersLimit: 2 },
|
||||
},
|
||||
}));
|
||||
|
||||
import { subscriptionCreatedHandler } from "../../../../pages/api/subscription/_subscription-created";
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import { findTeam, updateTeam } from "../../../../database/teams";
|
||||
import {
|
||||
createSubscription,
|
||||
findUserSubscription,
|
||||
updateSubscription,
|
||||
} from "../../../../database/subscriptions";
|
||||
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||
|
||||
describe("subscription_created webhook event", () => {
|
||||
const mockedSendEmail = sendEmail as jest.Mock<
|
||||
ReturnType<typeof sendEmail>
|
||||
>;
|
||||
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
|
||||
const mockedUpdateTeam = updateTeam as jest.Mock<
|
||||
ReturnType<typeof updateTeam>
|
||||
>;
|
||||
const mockedCreateSubscription = createSubscription as jest.Mock<
|
||||
ReturnType<typeof createSubscription>
|
||||
>;
|
||||
const mockedFindTeamSubscription = findUserSubscription as jest.Mock<
|
||||
ReturnType<typeof findUserSubscription>
|
||||
>;
|
||||
const mockedUpdateSubscription = updateSubscription as jest.Mock<
|
||||
ReturnType<typeof updateSubscription>
|
||||
>;
|
||||
|
||||
mockedSendEmail.mockResolvedValue();
|
||||
|
||||
beforeEach(() => {
|
||||
mockedSendEmail.mockClear();
|
||||
mockedFindTeam.mockClear();
|
||||
mockedUpdateTeam.mockClear();
|
||||
mockedCreateSubscription.mockClear();
|
||||
mockedFindTeamSubscription.mockClear();
|
||||
mockedUpdateSubscription.mockClear();
|
||||
});
|
||||
|
||||
test("responds 400 to malformed event", async () => {
|
||||
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
test("responds 404 to valid event with unknown team", async () => {
|
||||
const teamId = "123";
|
||||
const subscriptionId = "222";
|
||||
const planId = "229";
|
||||
|
||||
const event = {
|
||||
alert_id: 1789225139,
|
||||
alert_name: "subscription_created",
|
||||
cancel_url:
|
||||
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
|
||||
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
|
||||
currency: "GBP",
|
||||
email: "reichert.arnaldo@example.net",
|
||||
event_time: "2021-05-07 13:50:58",
|
||||
linked_subscriptions: "6, 8, 7",
|
||||
marketing_consent: undefined,
|
||||
next_bill_date: "2021-06-02",
|
||||
passthrough: `{"teamId":"${teamId}"}`,
|
||||
quantity: 16,
|
||||
source: "Activation",
|
||||
status: "active",
|
||||
subscription_id: subscriptionId,
|
||||
subscription_plan_id: planId,
|
||||
unit_price: "unit_price",
|
||||
update_url:
|
||||
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
|
||||
user_id: "9",
|
||||
p_signature:
|
||||
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
|
||||
};
|
||||
|
||||
mockedFindTeam.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||
method: "POST",
|
||||
body: event,
|
||||
});
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(mockedCreateSubscription).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("responds 200 to valid event", async () => {
|
||||
const teamId = "123";
|
||||
const subscriptionId = "222";
|
||||
const planId = "229";
|
||||
|
||||
const event = {
|
||||
alert_id: 1789225139,
|
||||
alert_name: "subscription_created",
|
||||
cancel_url:
|
||||
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
|
||||
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
|
||||
currency: "GBP",
|
||||
email: "reichert.arnaldo@example.net",
|
||||
event_time: "2021-05-07 13:50:58",
|
||||
linked_subscriptions: "6, 8, 7",
|
||||
marketing_consent: undefined,
|
||||
next_bill_date: "2021-06-02",
|
||||
passthrough: `{"teamId":"${teamId}"}`,
|
||||
quantity: 16,
|
||||
source: "Activation",
|
||||
status: "active",
|
||||
subscription_id: subscriptionId,
|
||||
subscription_plan_id: planId,
|
||||
unit_price: "unit_price",
|
||||
update_url:
|
||||
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
|
||||
user_id: "9",
|
||||
p_signature:
|
||||
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
|
||||
};
|
||||
|
||||
mockedFindTeam.mockResolvedValueOnce({
|
||||
id: teamId,
|
||||
subscriptionId: null,
|
||||
teamMembersLimit: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const { status } = await callApiHandler(subscriptionCreatedHandler, {
|
||||
method: "POST",
|
||||
body: event,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(mockedCreateSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUpdateTeam).toHaveBeenCalledWith({
|
||||
id: teamId,
|
||||
subscriptionId,
|
||||
teamMembersLimit: 2,
|
||||
});
|
||||
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
|
||||
event.email,
|
||||
]);
|
||||
});
|
||||
});
|
111
src/__tests__/pages/api/subscription/webhook.ts
Normal file
111
src/__tests__/pages/api/subscription/webhook.ts
Normal file
@ -0,0 +1,111 @@
|
||||
jest.mock(
|
||||
"../../../../pages/api/subscription/_subscription-payment-succeeded",
|
||||
() => ({
|
||||
subscriptionPaymentSucceededHandler: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
import type { NextApiResponse } from "next";
|
||||
|
||||
import { subscriptionPaymentSucceededHandler } from "../../../../pages/api/subscription/_subscription-payment-succeeded";
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import webhookHandler from "../../../../pages/api/subscription/webhook";
|
||||
|
||||
describe("/api/subscription/webhook", () => {
|
||||
const mockedSubscriptionPaymentSucceededHandler = subscriptionPaymentSucceededHandler as jest.Mock<
|
||||
ReturnType<typeof subscriptionPaymentSucceededHandler>
|
||||
>;
|
||||
mockedSubscriptionPaymentSucceededHandler.mockImplementation(
|
||||
async (_, res: NextApiResponse) => res.status(200).end(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedSubscriptionPaymentSucceededHandler.mockClear();
|
||||
});
|
||||
|
||||
test("responds 405 to GET", async () => {
|
||||
const { status } = await callApiHandler(webhookHandler, {
|
||||
method: "GET",
|
||||
});
|
||||
expect(status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 500 to POST with invalid webhook event", async () => {
|
||||
const response = await callApiHandler(webhookHandler, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
test("responds 400 to POST with unsupported webhook event", async () => {
|
||||
const response = await callApiHandler(webhookHandler, {
|
||||
method: "POST",
|
||||
body: payoutPaid,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with supported and valid webhook event", async () => {
|
||||
const response = await callApiHandler(webhookHandler, {
|
||||
method: "POST",
|
||||
body: subscriptionPaymentSucceeded,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSubscriptionPaymentSucceededHandler).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const payoutPaid = {
|
||||
alert_id: 833499511,
|
||||
alert_name: "transfer_paid",
|
||||
amount: 648.8,
|
||||
currency: "USD",
|
||||
event_time: "2021-05-07 00:29:50",
|
||||
payout_id: 6,
|
||||
status: "paid",
|
||||
p_signature:
|
||||
"p5AwTrjZPgczHkU8CHiUc7VH1mn8FLH+s+JUaNqrlY7xhaD+KG2Aq6njnwH4Q+xGN51pwpFZDpjBI6EZIsYlP/Rs3GWObJU7I2xOpvLXIrvjMDeIgNVL2s+BWeqqzylFYGsH1uKHQIFa5fm/JiUEErHecoNyk3GcwP7j2qeiHra64i+mjhzKsprUd4NUlhxD7nEpfRpM7aMuMii7WE/EGBBW12bxiJCRcrm0yuSrDLTZCbiOnK6ddPqsYrSPjWJjSOFXblQK+erOTuvOZuRaf5eiZodbiOyeGsgZ/AhfqXiWt0bOpbuqgMkofUJSgz5AV3y3HgqxhhsrXCTRgdexr/6Cx7+k1mm2AWMhuTn3DU3+2eDkiNIeP52hPtjx6h/Kxbb7/OoxYB9rfDT42m553nPbWxdSGw6Zz5h2oWOH0goFAFMi9CSXS+HilXpmKWc2KjIFYyu8Yu+3lZ2KAMWPwDEc8liQsWZVSo/R4SXcd3t5p+k3uhFwRkwIoeF7If25MQADEBK1s84p5tZTgo4EPkqEwRYZdRiTBZ+xzrrEOvsAA192hEXcjWRnFlqYeMITY/j2rf/ZTlXXbLw1Bcje1vr27z3Qe64GP4m4Whrh37N0kOkSElMXnCMx8fj3WgyMyHZhKGE96t+sfuA1NJy/dGl968uJIz1XVWh9F+6fcGo=",
|
||||
};
|
||||
|
||||
const subscriptionPaymentSucceeded = {
|
||||
alert_id: 1667920177,
|
||||
alert_name: "subscription_payment_succeeded",
|
||||
balance_currency: "USD",
|
||||
balance_earnings: 791.71,
|
||||
balance_fee: 774.49,
|
||||
balance_gross: 102.03,
|
||||
balance_tax: 282.55,
|
||||
checkout_id: "4-599cbbe6fe49dc0-4c628740d6",
|
||||
country: "DE",
|
||||
coupon: "Coupon 5",
|
||||
currency: "USD",
|
||||
customer_name: "customer_name",
|
||||
earnings: 253.39,
|
||||
email: "baron.daugherty@example.org",
|
||||
event_time: "2021-05-07 00:18:15",
|
||||
fee: 0.11,
|
||||
initial_payment: true,
|
||||
instalments: 6,
|
||||
marketing_consent: 1,
|
||||
next_bill_date: "2021-05-23",
|
||||
next_payment_amount: "next_payment_amount",
|
||||
order_id: 7,
|
||||
passthrough: "Example String",
|
||||
payment_method: "card",
|
||||
payment_tax: 0.69,
|
||||
plan_name: "Example String",
|
||||
quantity: 62,
|
||||
receipt_url: "https://my.paddle.com/receipt/1/a18b96518813baa-a470ddf641",
|
||||
sale_gross: 556.08,
|
||||
status: "trialing",
|
||||
subscription_id: 3,
|
||||
subscription_payment_id: 4,
|
||||
subscription_plan_id: 9,
|
||||
unit_price: "unit_price",
|
||||
user_id: 5,
|
||||
p_signature:
|
||||
"eucBVrNR/4KySSm+sSGwcBcaCXXZFEyTi4OY0nCxAEeGAc3QaBpGI8r+Ma3J4i7XmKOSYxalDx2nuXB2igqomg9YPQirmcgFOECX8NFDLvZeu3/V7SYuEeGHLmjZFyOSK8htwGVheTzQiFGbGq8ALPD1vgb0CME2iulfLC7kiRGut8enpLWUGSXlzXP0AVvxWkS7MyT0EQEE+b62EDEavyds2YaS7/tWQVoKBuHeWm7JqjdbEg4b+ht7ev9ns2RgyGNxsRs3+w9rpL8uAIzib7m24aWqqfBoB2kMhJvM6csfgqDZ6gF3nOG2PE1VJzD4G2Y0RJsZPC3BboQmE//RIS1UdyxKEwGHi8cDPIJIIzn31xx42uJulyX69w0JihBnTfasuEXy9gZKB96XCsMmks9nBQZAi+ZNteBfT7unToXLMwHn0mPDTUj+NpEWjTdIUCL6JM4Ewk3cDTs9tleo0TAXxikk06YnjJbGxL7mEwofB31rFlUyzmkKtf935TMGGe4cbhBdGcLaImithNyo48mWQvTg8F2yvIa6vZ3rmbGL6oNe3GT8q7r+HBLdatv5uDoomboZqh7dsNEmpv6VwJtmeNEoQs8//VD/MCcLFPaKCZp8QmYBwvYXdVunxSwwCF6rwEm77U8Jo/2Ua7giCQj+ekkgJ7uE4ubo10lB5bE=",
|
||||
};
|
90
src/__tests__/pages/api/team/invite-member.ts
Normal file
90
src/__tests__/pages/api/team/invite-member.ts
Normal file
@ -0,0 +1,90 @@
|
||||
jest.mock("../../../../pages/api/_send-email", () => ({
|
||||
sendEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../database/users", () => ({
|
||||
createInvitedUser: jest.fn(),
|
||||
findUserByEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../../../pages/api/user/_auth0", () => ({
|
||||
createAuth0User: jest.fn(),
|
||||
}));
|
||||
|
||||
import inviteMemberHandler from "../../../../pages/api/team/invite-member";
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import { sendEmail } from "../../../../pages/api/_send-email";
|
||||
import { createInvitedUser, findUserByEmail } from "../../../../database/users";
|
||||
import { createAuth0User } from "../../../../pages/api/user/_auth0";
|
||||
|
||||
describe("/api/team/invite-member", () => {
|
||||
const mockedSendEmail = sendEmail as jest.Mock<
|
||||
ReturnType<typeof sendEmail>
|
||||
>;
|
||||
const mockedCreateInvitedUser = createInvitedUser as jest.Mock<
|
||||
ReturnType<typeof createInvitedUser>
|
||||
>;
|
||||
const mockedFindUserByEmail = findUserByEmail as jest.Mock<
|
||||
ReturnType<typeof findUserByEmail>
|
||||
>;
|
||||
const mockedCreateAuth0User = createAuth0User as jest.Mock<
|
||||
ReturnType<typeof createAuth0User>
|
||||
>;
|
||||
|
||||
mockedSendEmail.mockResolvedValue();
|
||||
|
||||
beforeEach(() => {
|
||||
mockedSendEmail.mockClear();
|
||||
mockedCreateInvitedUser.mockClear();
|
||||
mockedFindUserByEmail.mockClear();
|
||||
mockedCreateAuth0User.mockClear();
|
||||
});
|
||||
|
||||
test("responds 405 to GET", async () => {
|
||||
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||
method: "GET",
|
||||
authentication: "auth0",
|
||||
});
|
||||
expect(status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 400 to POST with malformed body", async () => {
|
||||
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body: {},
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
test("responds 500 to POST with valid body but already taken email address", async () => {
|
||||
const inviteeEmail = "test@fss.dev";
|
||||
mockedFindUserByEmail.mockResolvedValueOnce({
|
||||
email: inviteeEmail,
|
||||
} as any);
|
||||
|
||||
const body = { inviteeEmail };
|
||||
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body,
|
||||
});
|
||||
expect(status).toBe(500);
|
||||
});
|
||||
|
||||
test("responds 200 to POST with valid body", async () => {
|
||||
const inviteeUserId = "2";
|
||||
const inviteeEmail = "test@fss.dev";
|
||||
mockedCreateAuth0User.mockResolvedValueOnce({ user_id: inviteeUserId });
|
||||
|
||||
const body = { inviteeEmail };
|
||||
const { status } = await callApiHandler(inviteMemberHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
|
||||
inviteeEmail,
|
||||
]);
|
||||
expect(mockedCreateInvitedUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
21
src/__tests__/pages/api/user/session.ts
Normal file
21
src/__tests__/pages/api/user/session.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import sessionHandler from "../../../../pages/api/user/session";
|
||||
|
||||
describe("/api/user/session", () => {
|
||||
test("responds 405 to POST", async () => {
|
||||
const { status } = await callApiHandler(sessionHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
});
|
||||
expect(status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 200 with session to GET", async () => {
|
||||
const response = await callApiHandler(sessionHandler, {
|
||||
method: "GET",
|
||||
authentication: "auth0",
|
||||
});
|
||||
const session = await response.json();
|
||||
expect(session.user).toBeDefined();
|
||||
});
|
||||
});
|
112
src/__tests__/pages/api/user/update-user.ts
Normal file
112
src/__tests__/pages/api/user/update-user.ts
Normal file
@ -0,0 +1,112 @@
|
||||
jest.mock("auth0", () => ({
|
||||
ManagementClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("openid-client", () => ({
|
||||
Issuer: {
|
||||
discover: jest.fn().mockImplementation(() => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
refresh: jest.fn().mockImplementation(() => ({
|
||||
claims: jest.fn().mockResolvedValue({}),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../../database/users", () => ({
|
||||
findUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ManagementClient } from "auth0";
|
||||
|
||||
import { callApiHandler } from "../../../../../jest/helpers";
|
||||
import updateUserHandler from "../../../../pages/api/user/update-user";
|
||||
import { findUser, updateUser } from "../../../../database/users";
|
||||
|
||||
describe("/api/user/update-user", () => {
|
||||
const mockedManagementClient = ManagementClient as ReturnType<
|
||||
typeof jest.fn
|
||||
>;
|
||||
const mockedUpdateAuth0User = jest.fn();
|
||||
mockedManagementClient.mockImplementation(() => ({
|
||||
updateUser: mockedUpdateAuth0User,
|
||||
}));
|
||||
|
||||
const mockedFindUser = findUser as ReturnType<typeof jest.fn>;
|
||||
const mockedUpdateUser = updateUser as ReturnType<typeof jest.fn>;
|
||||
mockedFindUser.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: "auth0|1234567",
|
||||
email: "test@fss.dev",
|
||||
name: "Groot",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUpdateAuth0User.mockClear();
|
||||
mockedFindUser.mockClear();
|
||||
mockedUpdateUser.mockClear();
|
||||
});
|
||||
|
||||
test("responds 401 to unauthenticated request", async () => {
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("responds 405 to authenticated GET", async () => {
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "GET",
|
||||
authentication: "auth0",
|
||||
});
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
test("responds 400 to authenticated POST with malformed body", async () => {
|
||||
const body = { name: "", email: "", password: "" };
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("updates user password and responds 200 to authenticated POST", async () => {
|
||||
const body = { name: "", email: "", password: "dddddd" };
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedUpdateAuth0User).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test("updates both user password & email and responds 200 to authenticated POST", async () => {
|
||||
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "POST",
|
||||
authentication: "auth0",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedUpdateAuth0User).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test("responds 403 to authenticated POST when updating email for a 3rd party-authenticated user", async () => {
|
||||
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
|
||||
const response = await callApiHandler(updateUserHandler, {
|
||||
method: "POST",
|
||||
authentication: "google-oauth2",
|
||||
body,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockedUpdateAuth0User).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
51
src/__tests__/pages/auth/sign-in.tsx
Normal file
51
src/__tests__/pages/auth/sign-in.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
jest.mock("next/router", () => ({
|
||||
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
|
||||
}));
|
||||
jest.mock("../../../hooks/use-auth");
|
||||
|
||||
import { rest } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "../../../../jest/testing-library";
|
||||
|
||||
import useAuth from "../../../hooks/use-auth";
|
||||
|
||||
import SignInPage from "../../../pages/auth/sign-in";
|
||||
|
||||
describe("/auth/sign-in", () => {
|
||||
type RequestBody = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
|
||||
const mockedSignIn = jest.fn();
|
||||
mockedUseAuth.mockImplementation(() => ({
|
||||
signIn: mockedSignIn,
|
||||
socialProviders: [],
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
rest.post<RequestBody>("/api/auth/sign-in", (req, res, ctx) => {
|
||||
return res(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => mockedUseAuth.mockClear());
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
test("sign in with email", async () => {
|
||||
render(<SignInPage />);
|
||||
|
||||
userEvent.type(screen.getByLabelText("Email address"), "test@fss.dev");
|
||||
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
|
||||
|
||||
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
|
||||
});
|
||||
});
|
46
src/__tests__/pages/index.tsx
Normal file
46
src/__tests__/pages/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { rest } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { render, screen } from "../../../jest/testing-library";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import Index from "../../pages";
|
||||
|
||||
describe("/", () => {
|
||||
test("landing page snapshot", () => {
|
||||
const { asFragment } = render(<Index />);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("subscribe to newsletter", () => {
|
||||
const server = setupServer(
|
||||
rest.post("/api/newsletter/subscribe", (req, res, ctx) => {
|
||||
return res(ctx.status(200));
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
test("should display successful message after subscribing", async () => {
|
||||
render(<Index />);
|
||||
|
||||
userEvent.type(
|
||||
screen.getByPlaceholderText("Email address"),
|
||||
"test@fss.dev{enter}",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Thanks! We'll let you know when we launch",
|
||||
),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
131
src/__tests__/pages/team/invitation.tsx
Normal file
131
src/__tests__/pages/team/invitation.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
jest.mock("next/router", () => ({
|
||||
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
|
||||
}));
|
||||
jest.mock("../../../hooks/use-auth");
|
||||
jest.mock("../../../database/users", () => ({
|
||||
findTeamOwner: jest.fn(),
|
||||
findUser: jest.fn(),
|
||||
}));
|
||||
|
||||
import { rest } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "../../../../jest/testing-library";
|
||||
|
||||
import InvitationPage, {
|
||||
getServerSideProps,
|
||||
} from "../../../pages/team/invitation";
|
||||
import useAuth from "../../../hooks/use-auth";
|
||||
import { findTeamOwner, findUser } from "../../../database/users";
|
||||
import { generateSignInToken } from "../../../pages/api/team/_invite";
|
||||
|
||||
describe("/team/invitation", () => {
|
||||
type RequestBody = {
|
||||
token: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const mockedFindTeamOwner = findTeamOwner as jest.Mock<
|
||||
ReturnType<typeof findTeamOwner>
|
||||
>;
|
||||
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
|
||||
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
|
||||
const mockedSignIn = jest.fn();
|
||||
mockedUseAuth.mockImplementation(() => ({
|
||||
signIn: mockedSignIn,
|
||||
socialProviders: [],
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
rest.post<RequestBody>(
|
||||
"/api/team/accept-invitation",
|
||||
(req, res, ctx) => {
|
||||
return res(ctx.status(200));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseAuth.mockClear();
|
||||
mockedFindTeamOwner.mockClear();
|
||||
mockedFindUser.mockClear();
|
||||
});
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const inviteeEmail = "test@fss.dev";
|
||||
const teamId = "123";
|
||||
const teamOwner: any = {
|
||||
name: "Groot",
|
||||
};
|
||||
|
||||
test("accept invitation", async () => {
|
||||
render(
|
||||
<InvitationPage
|
||||
email={inviteeEmail}
|
||||
teamId={teamId}
|
||||
teamOwner={teamOwner}
|
||||
/>,
|
||||
);
|
||||
|
||||
userEvent.type(screen.getByLabelText("Name"), "John Doe");
|
||||
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
|
||||
|
||||
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
|
||||
});
|
||||
|
||||
describe("getServerSideProps", () => {
|
||||
const baseContext: any = {
|
||||
req: {},
|
||||
res: {},
|
||||
resolvedUrl: "/team/invitation",
|
||||
};
|
||||
|
||||
test("decode token and return props", async () => {
|
||||
const userId = "111";
|
||||
const invitedUser: any = {
|
||||
id: userId,
|
||||
email: inviteeEmail,
|
||||
teamId,
|
||||
pendingInvitation: true,
|
||||
};
|
||||
mockedFindTeamOwner.mockResolvedValueOnce(teamOwner);
|
||||
mockedFindUser.mockResolvedValueOnce(invitedUser);
|
||||
const token = await generateSignInToken({ teamId, userId });
|
||||
const context = {
|
||||
...baseContext,
|
||||
query: { token },
|
||||
};
|
||||
|
||||
const serverSideProps = await getServerSideProps(context);
|
||||
expect(serverSideProps).toStrictEqual({
|
||||
props: {
|
||||
email: inviteeEmail,
|
||||
teamId,
|
||||
teamOwner,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("redirect to sign in page if token is invalid", async () => {
|
||||
const context = {
|
||||
...baseContext,
|
||||
query: { token: "" },
|
||||
};
|
||||
const serverSideProps = await getServerSideProps(context);
|
||||
expect(serverSideProps).toStrictEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/sign-in?error=invalid-invitation",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
115
src/components/alert.tsx
Normal file
115
src/components/alert.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
type AlertVariant = "error" | "success" | "info" | "warning";
|
||||
|
||||
type AlertVariantProps = {
|
||||
backgroundColor: string;
|
||||
icon: ReactElement;
|
||||
titleTextColor: string;
|
||||
messageTextColor: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
message: string;
|
||||
variant: AlertVariant;
|
||||
};
|
||||
|
||||
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
|
||||
error: {
|
||||
backgroundColor: "bg-red-50",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
titleTextColor: "text-red-800",
|
||||
messageTextColor: "text-red-700",
|
||||
},
|
||||
success: {
|
||||
backgroundColor: "bg-green-50",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-5 w-5 text-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
titleTextColor: "text-green-800",
|
||||
messageTextColor: "text-green-700",
|
||||
},
|
||||
info: {
|
||||
backgroundColor: "bg-primary-50",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
titleTextColor: "text-primary-800",
|
||||
messageTextColor: "text-primary-700",
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: "bg-yellow-50",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
titleTextColor: "text-yellow-800",
|
||||
messageTextColor: "text-yellow-700",
|
||||
},
|
||||
};
|
||||
|
||||
export default function Alert({ title, message, variant }: Props) {
|
||||
const variantProperties = ALERT_VARIANTS[variant];
|
||||
|
||||
return (
|
||||
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">{variantProperties.icon}</div>
|
||||
<div className="ml-3">
|
||||
<h3
|
||||
className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
224
src/components/auth/auth-page.tsx
Normal file
224
src/components/auth/auth-page.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Alert from "../alert";
|
||||
|
||||
import useAuth from "../../hooks/use-auth";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
import Logo from "../logo";
|
||||
|
||||
type Props = {
|
||||
authType: "signIn" | "signUp";
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ module: "AuthPage" });
|
||||
|
||||
type Form = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function AuthPage({ authType }: Props) {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit } = useForm<Form>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const texts = TEXTS[authType];
|
||||
let redirectTo: string;
|
||||
if (Array.isArray(router.query.redirectTo)) {
|
||||
redirectTo = router.query.redirectTo[0];
|
||||
} else {
|
||||
redirectTo = router.query.redirectTo ?? "/messages";
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email, password, name }) => {
|
||||
setErrorMessage("");
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const params = { email, password, name, redirectTo };
|
||||
try {
|
||||
if (authType === "signIn") {
|
||||
await auth.signIn(params);
|
||||
}
|
||||
|
||||
if (authType === "signUp") {
|
||||
await auth.signUp(params);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
console.log("error", error);
|
||||
setErrorMessage(
|
||||
error.isAxiosError ?
|
||||
error.response.data.errorMessage :
|
||||
error.message
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Logo className="mx-auto h-8 w-8" />
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
{texts.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||
{texts.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Alert
|
||||
title="Oops, there was an issue"
|
||||
message={errorMessage}
|
||||
variant="error"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<div className="py-8 px-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
{authType === "signUp" ? (
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="name"
|
||||
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("name")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<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={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("email")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
<div>Password</div>
|
||||
|
||||
{authType === "signIn" ? (
|
||||
<div>
|
||||
<Link href="/auth/forgot-password">
|
||||
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
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("password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": isSubmitting,
|
||||
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
|
||||
},
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Loading..."
|
||||
: texts.actionButton}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
|
||||
type Texts = {
|
||||
title: string;
|
||||
subtitle: ReactNode;
|
||||
actionButton: string;
|
||||
};
|
||||
|
||||
const TEXTS: Record<Props["authType"], Texts> = {
|
||||
signUp: {
|
||||
title: "Create your account",
|
||||
subtitle: (
|
||||
<Link href="/auth/sign-in">
|
||||
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||
Already have an account?
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
actionButton: "Sign up",
|
||||
},
|
||||
signIn: {
|
||||
title: "Welcome back!",
|
||||
subtitle: (
|
||||
<>
|
||||
Need an account?
|
||||
<Link href="/auth/sign-up">
|
||||
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
|
||||
Create yours for free
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
actionButton: "Sign in",
|
||||
},
|
||||
};
|
15
src/components/avatar.tsx
Normal file
15
src/components/avatar.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const Avatar: FunctionComponent<Props> = ({ name }) => (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 flex-none rounded-full bg-gray-400 group-hover:opacity-75">
|
||||
<span className="text-sm leading-none text-white uppercase">
|
||||
{name.substr(0, 2)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default Avatar;
|
261
src/components/billing/billing-plans.tsx
Normal file
261
src/components/billing/billing-plans.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Toggle from "../toggle";
|
||||
import Modal, { ModalTitle } from "../modal";
|
||||
|
||||
import useSubscription from "../../hooks/use-subscription";
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
import type {
|
||||
Plan,
|
||||
PlanId,
|
||||
PlanName,
|
||||
} from "../../subscription/plans";
|
||||
import { FREE, PLANS } from "../../subscription/plans";
|
||||
|
||||
type Props = {
|
||||
activePlanId?: PlanId;
|
||||
};
|
||||
|
||||
type Form = {
|
||||
selectedPlanName: PlanName;
|
||||
};
|
||||
|
||||
const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
||||
const { userProfile } = useUser();
|
||||
const { subscribe, changePlan } = useSubscription();
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const activePlan = useMemo(() => {
|
||||
const activePlan = PLANS[activePlanId];
|
||||
if (!activePlan) {
|
||||
return FREE;
|
||||
}
|
||||
|
||||
return activePlan;
|
||||
}, [activePlanId]);
|
||||
const {
|
||||
register,
|
||||
unregister,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Form>({
|
||||
defaultValues: getDefaultValues(activePlan),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
register("selectedPlanName");
|
||||
|
||||
const { selectedPlanName } = getDefaultValues(activePlan);
|
||||
setValue("selectedPlanName", selectedPlanName);
|
||||
|
||||
return () => {
|
||||
unregister("selectedPlanName");
|
||||
};
|
||||
}, [register, unregister, activePlan, setValue]);
|
||||
|
||||
const plans = PLANS;
|
||||
const selectedPlanName = watch("selectedPlanName");
|
||||
const selectedPlan = useMemo(() => plans[selectedPlanName] ?? FREE, [
|
||||
plans,
|
||||
selectedPlanName,
|
||||
]);
|
||||
const isActivePlanSelected = activePlan.id === selectedPlan.id;
|
||||
const isSubmitDisabled = isSubmitting || isActivePlanSelected;
|
||||
|
||||
const onSubmit = handleSubmit(() => setIsConfirmationModalOpen(true));
|
||||
const closeModal = () => setIsConfirmationModalOpen(false);
|
||||
const onConfirm = async () => {
|
||||
if (isSubmitDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = userProfile!.email!;
|
||||
const userId = userProfile!.id;
|
||||
const selectedPlanId = selectedPlan.id;
|
||||
|
||||
const isMovingToPaidPlan =
|
||||
activePlan.id === "free" && selectedPlanId !== "free";
|
||||
if (isMovingToPaidPlan) {
|
||||
await subscribe({ email, userId, planId: selectedPlanId });
|
||||
} else {
|
||||
await changePlan({ planId: selectedPlanId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="shadow sm:rounded-md sm:overflow-hidden">
|
||||
<div className="bg-white py-6 px-4 space-y-6 sm:p-6">
|
||||
<fieldset>
|
||||
<RadioGroup
|
||||
value={selectedPlan.name}
|
||||
onChange={(planName) => setValue("selectedPlanName", planName)}
|
||||
className="relative bg-white rounded-md -space-y-px"
|
||||
>
|
||||
{Object.entries(plans).map(
|
||||
([planId, plan], index, plansEntries) => {
|
||||
const isChecked = selectedPlan.id === planId;
|
||||
console.log("selectedPlan.name", selectedPlan.name);
|
||||
|
||||
return (
|
||||
<RadioGroup.Option
|
||||
key={planId}
|
||||
value={planId}
|
||||
as="label"
|
||||
className={clsx(
|
||||
"relative border p-4 flex flex-col cursor-pointer md:pl-4 md:pr-6 md:grid md:grid-cols-3",
|
||||
{
|
||||
"rounded-tl-md rounded-tr-md": index === 0,
|
||||
"rounded-bl-md rounded-br-md": index === plansEntries.length - 1,
|
||||
"bg-primary-50 border-primary-200 z-10": isChecked,
|
||||
"border-gray-200": !isChecked,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center text-sm">
|
||||
<input
|
||||
className="h-4 w-4 text-primary-500 border-gray-300"
|
||||
type="radio"
|
||||
value={planId}
|
||||
checked={isChecked}
|
||||
readOnly
|
||||
/>
|
||||
<span className="ml-3 font-medium text-gray-900">
|
||||
{plan.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="ml-6 pl-1 text-sm md:ml-0 md:pl-0">
|
||||
<span
|
||||
className={clsx(
|
||||
"font-medium",
|
||||
{
|
||||
"text-primary-900": isChecked,
|
||||
"text-gray-900": !isChecked,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{plan.price === "free" ? (
|
||||
"Free "
|
||||
) : (
|
||||
<>
|
||||
${plan.price} /
|
||||
mo
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={clsx(
|
||||
"ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right",
|
||||
{
|
||||
"text-primary-700": isChecked,
|
||||
"text-gray-500": !isChecked,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{plan.description}
|
||||
</p>
|
||||
</RadioGroup.Option>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-900",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": isActivePlanSelected,
|
||||
"bg-primary-600 hover:bg-primary-700": !isActivePlanSelected,
|
||||
},
|
||||
)}
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
initialFocus={modalCancelButtonRef}
|
||||
isOpen={isConfirmationModalOpen}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>
|
||||
Move to {selectedPlan.name} plan
|
||||
</ModalTitle>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to move to{" "}
|
||||
{selectedPlan.name} plan?{" "}
|
||||
</p>
|
||||
{activePlan.name === "Team" &&
|
||||
selectedPlan.name !== "Team" ? (
|
||||
<p className="mt-2 text-sm font-medium text-gray-500">
|
||||
Attention: moving to a smaller plan will
|
||||
cause to remove extraneous team members to
|
||||
fit the new plan's allowance!
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:ml-3 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": isSubmitDisabled,
|
||||
"bg-primary-600 hover:bg-primary-700": !isSubmitDisabled,
|
||||
},
|
||||
)}
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Move to {selectedPlan.name} plan
|
||||
</button>
|
||||
<button
|
||||
ref={modalCancelButtonRef}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-gray-50 cursor-not-allowed": isSubmitDisabled,
|
||||
"hover:bg-gray-50": !isSubmitDisabled,
|
||||
},
|
||||
)}
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getDefaultValues = (activePlan: Plan) => ({
|
||||
selectedPlanName: activePlan.name.toLowerCase(),
|
||||
});
|
||||
|
||||
export default BillingPlans;
|
58
src/components/button.tsx
Normal file
58
src/components/button.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
FunctionComponent,
|
||||
MouseEventHandler,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
variant: Variant;
|
||||
onClick?: MouseEventHandler;
|
||||
isDisabled?: boolean;
|
||||
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
};
|
||||
|
||||
const Button: FunctionComponent<Props> = ({
|
||||
children,
|
||||
type,
|
||||
variant,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
type={type}
|
||||
className={clsx(
|
||||
"inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
{
|
||||
[VARIANTS_STYLES[variant].base]: !isDisabled,
|
||||
[VARIANTS_STYLES[variant].disabled]: isDisabled,
|
||||
},
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
||||
type Variant = "error" | "default";
|
||||
|
||||
type VariantStyle = {
|
||||
base: string;
|
||||
disabled: string;
|
||||
};
|
||||
|
||||
const VARIANTS_STYLES: Record<Variant, VariantStyle> = {
|
||||
error: {
|
||||
base: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
||||
disabled: "bg-red-400 cursor-not-allowed focus:ring-red-500",
|
||||
},
|
||||
default: {
|
||||
base: "bg-primary-600 hover:bg-primary-700 focus:ring-primary-500",
|
||||
disabled: "bg-primary-400 cursor-not-allowed focus:ring-primary-500",
|
||||
},
|
||||
};
|
9
src/components/divider.tsx
Normal file
9
src/components/divider.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
}
|
5
src/components/icons.tsx
Normal file
5
src/components/icons.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
84
src/components/layout/footer.tsx
Normal file
84
src/components/layout/footer.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPhoneAlt as fasPhone,
|
||||
faTh as fasTh,
|
||||
faComments as fasComments,
|
||||
faCog as fasCog,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faPhoneAlt as farPhone,
|
||||
faTh as farTh,
|
||||
faComments as farComments,
|
||||
faCog as farCog,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="grid grid-cols-4"
|
||||
style={{ flex: "0 0 50px" }}
|
||||
>
|
||||
<NavLink
|
||||
label="Calls"
|
||||
path="/calls"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasPhone} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farPhone} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Keypad"
|
||||
path="/keypad"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasTh} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farTh} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Messages"
|
||||
path="/messages"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasComments} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farComments} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Settings"
|
||||
path="/settings"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasCog} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farCog} />,
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
path: string;
|
||||
label: string;
|
||||
icons: {
|
||||
active: ReactNode;
|
||||
inactive: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
function NavLink({ path, label, icons }: NavLinkProps) {
|
||||
const router = useRouter();
|
||||
const isActiveRoute = router.pathname.startsWith(path);
|
||||
const icon = isActiveRoute ? icons.active : icons.inactive;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-around h-full">
|
||||
<Link href={path}>
|
||||
<a className="flex flex-col items-center">
|
||||
{icon}
|
||||
<span className="text-xs">{label}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
90
src/components/layout/header.tsx
Normal file
90
src/components/layout/header.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import Link from "next/link";
|
||||
import { MenuIcon } from "@heroicons/react/solid";
|
||||
|
||||
import Avatar from "../avatar";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
export default function Header() {
|
||||
const { userProfile } = useUser();
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{ boxShadow: "0 5px 10px -7px rgba(0, 0, 0, 0.0785)" }}
|
||||
className="z-30 py-4 bg-white"
|
||||
>
|
||||
<div className="container flex items-center justify-between h-full px-6 mx-auto text-primary-600">
|
||||
<button
|
||||
className="p-1 mr-5 -ml-1 rounded-md lg:hidden focus:outline-none focus:shadow-outline-primary"
|
||||
onClick={() => void 0}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<MenuIcon className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<ul className="flex ml-auto items-center flex-shrink-0 space-x-6">
|
||||
<li className="relative">
|
||||
<Menu>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Menu.Button
|
||||
className="rounded-full focus:shadow-outline-primary focus:outline-none"
|
||||
aria-label="Account"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
name={userProfile?.email ?? "FSS"}
|
||||
/>
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute outline-none right-0 px-1 py-1 divide-y divide-gray-100 z-30 mt-2 origin-top-right text-gray-600 bg-white border border-gray-100 rounded-md shadow-md min-w-max-content"
|
||||
static
|
||||
>
|
||||
<MenuItem href="/account/settings">
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
<MenuItem href="/api/auth/sign-out">
|
||||
<span>Log out</span>
|
||||
</MenuItem>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuItemProps = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
const MenuItem: FunctionComponent<MenuItemProps> = ({ children, href }) => (
|
||||
<Menu.Item>
|
||||
{() => (
|
||||
<Link href={href}>
|
||||
<a
|
||||
className="inline-flex space-x-2 items-center cursor-pointer w-full px-4 py-2 text-sm font-medium transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800"
|
||||
role="menuitem"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
99
src/components/layout/index.tsx
Normal file
99
src/components/layout/index.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import type { ErrorInfo, FunctionComponent } from "react";
|
||||
import { Component } from "react";
|
||||
import Head from "next/head";
|
||||
import type { WithRouterProps } from "next/dist/client/with-router";
|
||||
import { withRouter } from "next/router";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
|
||||
import Footer from "./footer";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ module: "Layout" });
|
||||
|
||||
const Layout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
title,
|
||||
pageTitle = title,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{pageTitle ? (
|
||||
<Head>
|
||||
<title>{pageTitle}</title>
|
||||
</Head>
|
||||
) : null}
|
||||
|
||||
<div className="h-full w-full overflow-hidden fixed bg-gray-50">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex flex-col flex-1 w-full overflow-y-auto">
|
||||
<main className="flex-1 my-0 h-full">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ErrorBoundaryState =
|
||||
| {
|
||||
isError: false;
|
||||
}
|
||||
| {
|
||||
isError: true;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const ErrorBoundary = withRouter(
|
||||
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
||||
public readonly state = {
|
||||
isError: false,
|
||||
} as const;
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error(error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.isError) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Oops, something went wrong.
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
|
||||
Would you like to{" "}
|
||||
<button
|
||||
className="inline-flex space-x-2 items-center text-left"
|
||||
onClick={this.props.router.reload}
|
||||
>
|
||||
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
|
||||
reload the page
|
||||
</span>
|
||||
</button>{" "}
|
||||
?
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default Layout;
|
23
src/components/loading.tsx
Normal file
23
src/components/loading.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
export default function Loading({ className = "" }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin h-5 w-5 text-primary-600 ${className}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
15
src/components/logo.tsx
Normal file
15
src/components/logo.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Logo: FunctionComponent<Props> = ({ className }) => (
|
||||
<div className={clsx("relative", className)}>
|
||||
<Image src="/static/logo.svg" layout="fill" alt="app logo" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logo;
|
15
src/components/long-press-handler.tsx
Normal file
15
src/components/long-press-handler.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { FunctionComponent } from "react";
|
||||
import usePress from "react-gui/use-press";
|
||||
|
||||
const LongPressHandler: FunctionComponent = ({ children }) => {
|
||||
const onLongPress = (event: any) => console.log("event", event);
|
||||
const ref = usePress({ onLongPress });
|
||||
|
||||
return (
|
||||
<div ref={ref} onContextMenu={e => e.preventDefault()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LongPressHandler;
|
71
src/components/modal.tsx
Normal file
71
src/components/modal.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import type { FunctionComponent, MutableRefObject, ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
type Props = {
|
||||
initialFocus?: MutableRefObject<HTMLElement | null> | undefined;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const Modal: FunctionComponent<Props> = ({
|
||||
children,
|
||||
initialFocus,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
className="fixed z-30 inset-0 overflow-y-auto"
|
||||
initialFocus={initialFocus}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
static
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center md:block md:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden md:inline-block md:align-middle md:h-screen">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 md:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 md:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalTitle: FunctionComponent = ({ children }) => (
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg leading-6 font-medium text-gray-900"
|
||||
>
|
||||
{children}
|
||||
</Dialog.Title>
|
||||
);
|
||||
|
||||
export default Modal;
|
33
src/components/outside-alerter.tsx
Normal file
33
src/components/outside-alerter.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Handler = (event: MouseEvent) => void;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
function OutsideAlerter({ children, handler }: Props) {
|
||||
const wrapperRef = useRef(null);
|
||||
useOutsideAlerter(wrapperRef, handler);
|
||||
|
||||
return <div ref={wrapperRef}>{children}</div>;
|
||||
}
|
||||
|
||||
function useOutsideAlerter(ref: RefObject<HTMLElement>, handler: Handler) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
|
||||
export default OutsideAlerter;
|
110
src/components/settings/danger-zone.tsx
Normal file
110
src/components/settings/danger-zone.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Button from "../button";
|
||||
import SettingsSection from "./settings-section";
|
||||
import Modal, { ModalTitle } from "../modal";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
export default function DangerZone() {
|
||||
const user = useUser();
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isDeletingUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationModalOpen(false);
|
||||
};
|
||||
const onConfirm = () => {
|
||||
setIsDeletingUser(true);
|
||||
user.deleteUser();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Danger Zone"
|
||||
description="Highway to the Danger Zone 𝅘𝅥𝅮"
|
||||
>
|
||||
<div className="shadow border border-red-300 sm:rounded-md sm:overflow-hidden">
|
||||
<div className="flex justify-between items-center flex-row px-4 py-5 bg-white sm:p-6">
|
||||
<p>
|
||||
Once you delete your account, all of its data will be
|
||||
permanently deleted.
|
||||
</p>
|
||||
|
||||
<span className="text-base font-medium">
|
||||
<Button
|
||||
variant="error"
|
||||
type="button"
|
||||
onClick={() => setIsConfirmationModalOpen(true)}
|
||||
>
|
||||
Delete my account
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
initialFocus={modalCancelButtonRef}
|
||||
isOpen={isConfirmationModalOpen}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Delete my account</ModalTitle>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Are you sure you want to delete your account?
|
||||
Your subscription will be cancelled and your
|
||||
data permanently deleted.
|
||||
</p>
|
||||
<p>
|
||||
You are free to create a new account with the
|
||||
same email address if you ever wish to come
|
||||
back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 md:ml-3 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-red-400 cursor-not-allowed": isDeletingUser,
|
||||
"bg-red-600 hover:bg-red-700": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
onClick={onConfirm}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
<button
|
||||
ref={modalCancelButtonRef}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-gray-50 cursor-not-allowed": isDeletingUser,
|
||||
"hover:bg-gray-50": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
onClick={closeModal}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
215
src/components/settings/pricing-plans.old.tsx
Normal file
215
src/components/settings/pricing-plans.old.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
import useSubscription from "../../hooks/use-subscription";
|
||||
|
||||
import type { Plan, PlanId } from "../../subscription/plans";
|
||||
import {
|
||||
FREE,
|
||||
MONTHLY,
|
||||
ANNUALLY,
|
||||
TEAM_MONTHLY,
|
||||
TEAM_ANNUALLY,
|
||||
} from "../../subscription/plans";
|
||||
|
||||
type Props = {
|
||||
activePlanId?: PlanId;
|
||||
};
|
||||
const PLANS: Record<BillingSchedule, Plan[]> = {
|
||||
monthly: [FREE, MONTHLY, TEAM_MONTHLY],
|
||||
yearly: [FREE, ANNUALLY, TEAM_ANNUALLY],
|
||||
};
|
||||
|
||||
const PricingPlans: FunctionComponent<Props> = ({ activePlanId }) => {
|
||||
const [billingSchedule, setBillingSchedule] = useState<BillingSchedule>(
|
||||
"monthly",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="sm:flex sm:flex-col sm:align-center">
|
||||
<div className="relative self-center mt-6 bg-gray-100 rounded-lg p-0.5 flex sm:mt-8">
|
||||
<button
|
||||
onClick={() => setBillingSchedule("monthly")}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"relative w-1/2 border-gray-200 rounded-md shadow-sm py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
|
||||
{
|
||||
"bg-white": billingSchedule === "monthly",
|
||||
},
|
||||
)}
|
||||
>
|
||||
Monthly billing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingSchedule("yearly")}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"ml-0.5 relative w-1/2 border border-transparent rounded-md py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
|
||||
{
|
||||
"bg-white": billingSchedule === "yearly",
|
||||
},
|
||||
)}
|
||||
>
|
||||
Yearly billing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 flex flex-row flex-wrap sm:mt-10 sm:space-y-0 sm:gap-6 lg:max-w-4xl lg:mx-auto">
|
||||
{PLANS[billingSchedule].map((plan) => (
|
||||
<PricingPlan
|
||||
key={`pricing-plan-${plan.name}`}
|
||||
plan={plan}
|
||||
billingSchedule={billingSchedule}
|
||||
activePlanId={activePlanId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPlans;
|
||||
|
||||
type BillingSchedule = "yearly" | "monthly";
|
||||
|
||||
type PricingPlanProps = {
|
||||
plan: Plan;
|
||||
billingSchedule: BillingSchedule;
|
||||
activePlanId?: PlanId;
|
||||
};
|
||||
|
||||
const PricingPlan: FunctionComponent<PricingPlanProps> = ({
|
||||
plan,
|
||||
billingSchedule,
|
||||
activePlanId,
|
||||
}) => {
|
||||
const { subscribe, changePlan } = useSubscription();
|
||||
const { userProfile } = useUser();
|
||||
const { name, description, features, price, id } = plan;
|
||||
const isActivePlan =
|
||||
(typeof activePlanId !== "undefined" ? activePlanId : "free") === id;
|
||||
|
||||
function movePlan() {
|
||||
const teamId = userProfile!.teamId;
|
||||
const email = userProfile!.email;
|
||||
const planId = plan.id;
|
||||
|
||||
if (typeof activePlanId === "undefined" && typeof planId === "number") {
|
||||
return subscribe({ email, teamId, planId });
|
||||
}
|
||||
|
||||
changePlan({ planId });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white w-full flex-grow border rounded-lg shadow-sm divide-y divide-gray-200 sm:w-auto",
|
||||
{
|
||||
"border-gray-200": !isActivePlan,
|
||||
"border-primary-600": isActivePlan,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">
|
||||
{name}
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-500">{description}</p>
|
||||
<p className="mt-8">
|
||||
<PlanPrice
|
||||
price={price}
|
||||
billingSchedule={billingSchedule}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<PlanButton
|
||||
name={name}
|
||||
isActivePlan={isActivePlan}
|
||||
changePlan={movePlan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 pb-8 px-6">
|
||||
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">
|
||||
What's included
|
||||
</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={`pricing-plan-${name}-feature-${feature}`}
|
||||
className="flex space-x-3"
|
||||
>
|
||||
<CheckIcon className="flex-shrink-0 h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-500">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PlanButtonProps = {
|
||||
name: Plan["name"];
|
||||
isActivePlan: boolean;
|
||||
changePlan: () => void;
|
||||
};
|
||||
|
||||
const PlanButton: FunctionComponent<PlanButtonProps> = ({
|
||||
name,
|
||||
isActivePlan,
|
||||
changePlan,
|
||||
}) => {
|
||||
return isActivePlan ? (
|
||||
<div className="block w-full py-2 text-sm font-semibold text-gray-500 text-center">
|
||||
You're currently on this plan
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={changePlan}
|
||||
className="block w-full cursor-pointer bg-primary-600 border border-primary-600 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-primary-700"
|
||||
>
|
||||
Move to <span className="font-bold">{name}</span> plan
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type PlanPriceProps = {
|
||||
price: Plan["price"];
|
||||
billingSchedule: BillingSchedule;
|
||||
};
|
||||
|
||||
const PlanPrice: FunctionComponent<PlanPriceProps> = ({
|
||||
price,
|
||||
billingSchedule,
|
||||
}) => {
|
||||
if (price === "free") {
|
||||
return (
|
||||
<span className="text-4xl font-extrabold text-gray-900">Free</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-4xl font-extrabold text-gray-900">
|
||||
${price}
|
||||
</span>
|
||||
<span className="text-base font-medium text-gray-500">/mo</span>
|
||||
{billingSchedule === "yearly" ? (
|
||||
<span className="ml-1 text-sm text-gray-500">
|
||||
billed yearly
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
138
src/components/settings/profile-informations.tsx
Normal file
138
src/components/settings/profile-informations.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Alert from "../alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "./settings-section";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
|
||||
type Form = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ module: "profile-settings" });
|
||||
|
||||
const ProfileInformations: FunctionComponent = () => {
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<Form>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setValue("name", user.userProfile?.name ?? "");
|
||||
setValue("email", user.userProfile?.email ?? "");
|
||||
}, [setValue, user.userProfile]);
|
||||
|
||||
const onSubmit = handleSubmit(async ({ name, email }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await user.updateUser({ name, email });
|
||||
} catch (error) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
logger.error("session expired, redirecting to sign in page");
|
||||
return router.push("/auth/sign-in");
|
||||
}
|
||||
|
||||
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="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="name"
|
||||
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("name")}
|
||||
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;
|
52
src/components/settings/settings-layout.tsx
Normal file
52
src/components/settings/settings-layout.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
import Layout from "../layout";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
const pageTitle = "User Settings";
|
||||
|
||||
const SettingsLayout: FunctionComponent = ({ children }) => {
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
if (user.isLoading) {
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<section className="px-4 py-4 sm:px-6 md:px-0">
|
||||
Loading...
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (user.error) {
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<section className="px-4 py-4 sm:px-6 md:px-0">
|
||||
<p className="py-2">Oops, something unexpected happened!</p>
|
||||
<pre>{user.error.message}</pre>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<header className="px-4 sm:px-6 md:px-0">
|
||||
<header className="flex">
|
||||
<span className="flex items-center cursor-pointer" onClick={router.back}>
|
||||
<FontAwesomeIcon className="h-8 w-8 mr-2" icon={faChevronLeft} /> Back
|
||||
</span>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<main>{children}</main>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
26
src/components/settings/settings-section.tsx
Normal file
26
src/components/settings/settings-section.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
const SettingsSection: FunctionComponent<Props> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
}) => (
|
||||
<div className="px-4 sm:px-6 md:px-0 lg:grid lg:grid-cols-4 lg:gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-gray-600">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-5 lg:mt-0 lg:col-span-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SettingsSection;
|
141
src/components/settings/update-password.tsx
Normal file
141
src/components/settings/update-password.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Alert from "../alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "./settings-section";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
import { useState } from "react";
|
||||
|
||||
const logger = appLogger.child({ module: "update-password" });
|
||||
|
||||
type Form = {
|
||||
newPassword: string;
|
||||
newPasswordConfirmation: string;
|
||||
};
|
||||
|
||||
const UpdatePassword: FunctionComponent = () => {
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<Form>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
async ({ newPassword, newPasswordConfirmation }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordConfirmation) {
|
||||
setErrorMessage("New passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await user.updateUser({ password: newPassword });
|
||||
} catch (error) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
logger.error(
|
||||
"session expired, redirecting to sign in page",
|
||||
);
|
||||
return router.push("/auth/sign-in");
|
||||
}
|
||||
|
||||
setErrorMessage(error.response.data.errorMessage);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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}
|
||||
|
||||
{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>
|
||||
<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={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("newPassword")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPasswordConfirmation"
|
||||
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
<div>Confirm new password</div>
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="newPasswordConfirmation"
|
||||
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("newPasswordConfirmation")}
|
||||
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;
|
41
src/components/toggle.tsx
Normal file
41
src/components/toggle.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
isChecked: boolean;
|
||||
label?: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Toggle: FunctionComponent<Props> = ({ isChecked, label, onChange }) => {
|
||||
return (
|
||||
<Switch.Group as="div" className="flex items-center space-x-4">
|
||||
<Switch
|
||||
as="button"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer focus:outline-none ring-0 transition-colors ease-in-out duration-200",
|
||||
{
|
||||
"bg-primary-500": isChecked,
|
||||
"bg-gray-200": !isChecked,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{({ checked }) => (
|
||||
<span
|
||||
className={`${
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
} inline-block w-5 h-5 transition duration-200 ease-in-out transform bg-white rounded-full shadow ring-0`}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
{label ? (
|
||||
<Switch.Label className="ml-3">{label}</Switch.Label>
|
||||
) : null}
|
||||
</Switch.Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toggle;
|
111
src/components/welcome/onboarding-layout.tsx
Normal file
111
src/components/welcome/onboarding-layout.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
type StepLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
currentStep: 1 | 2 | 3;
|
||||
previous?: StepLink;
|
||||
next?: StepLink;
|
||||
};
|
||||
|
||||
const steps = [
|
||||
"Welcome",
|
||||
"Twilio Credentials",
|
||||
"Pick a plan",
|
||||
] as const;
|
||||
|
||||
const OnboardingLayout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
currentStep,
|
||||
previous,
|
||||
next,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="min-h-screen text-center block p-0">
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="inline-block align-middle h-screen">
|
||||
​
|
||||
</span>
|
||||
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">{steps[currentStep - 1]}</h3>
|
||||
|
||||
<section className="px-6 pt-6 pb-12">{children}</section>
|
||||
|
||||
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
|
||||
{
|
||||
next ? (
|
||||
<Link href={next.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{next.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
previous ? (
|
||||
<Link href={previous.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{previous.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
|
||||
<ol className="flex items-center">
|
||||
{steps.map((step, stepIdx) => {
|
||||
const isComplete = currentStep > stepIdx + 1;
|
||||
const isCurrent = stepIdx + 1 === currentStep;
|
||||
|
||||
return (
|
||||
<li key={step} className={clsx(stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "", "relative")}>
|
||||
{isComplete ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="h-0.5 w-full bg-primary-600" />
|
||||
</div>
|
||||
<a className="relative w-8 h-8 flex items-center justify-center bg-primary-600 rounded-full">
|
||||
<CheckIcon className="w-5 h-5 text-white" />
|
||||
<span className="sr-only">{step}</span>
|
||||
</a>
|
||||
</>
|
||||
) : isCurrent ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="h-0.5 w-full bg-gray-200" />
|
||||
</div>
|
||||
<a className="relative w-8 h-8 flex items-center justify-center bg-white border-2 border-primary-600 rounded-full">
|
||||
<span className="h-2.5 w-2.5 bg-primary-600 rounded-full" />
|
||||
<span className="sr-only">{step}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="h-0.5 w-full bg-gray-200" />
|
||||
</div>
|
||||
<a className="group relative w-8 h-8 flex items-center justify-center bg-white border-2 border-gray-300 rounded-full">
|
||||
<span className="h-2.5 w-2.5 bg-transparent rounded-full" />
|
||||
<span className="sr-only">{step}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingLayout;
|
33
src/database/_encryption.ts
Normal file
33
src/database/_encryption.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import crypto from "crypto";
|
||||
import getConfig from "next/config";
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
|
||||
export function encrypt(text: string, encryptionKey: Buffer | string) {
|
||||
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey) ? encryptionKey : Buffer.from(encryptionKey, "hex");
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKeyAsBuffer, iv);
|
||||
const encrypted = cipher.update(text);
|
||||
const encryptedBuffer = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return `${iv.toString("hex")}:${encryptedBuffer.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedHexText: string, encryptionKey: Buffer | string) {
|
||||
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey) ? encryptionKey : Buffer.from(encryptionKey, "hex");
|
||||
const [hexIv, hexText] = encryptedHexText.split(":");
|
||||
const iv = Buffer.from(hexIv, "hex");
|
||||
const encryptedText = Buffer.from(hexText, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKeyAsBuffer, iv);
|
||||
const decrypted = decipher.update(encryptedText);
|
||||
const decryptedBuffer = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decryptedBuffer.toString();
|
||||
}
|
||||
|
||||
export function computeEncryptionKey(userIdentifier: string) {
|
||||
return crypto.scryptSync(userIdentifier, serverRuntimeConfig.masterEncryptionKey, 32);
|
||||
}
|
14
src/database/_types.ts
Normal file
14
src/database/_types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export enum SmsType {
|
||||
SENT = "sent",
|
||||
RECEIVED = "received",
|
||||
}
|
||||
|
||||
export type Sms = {
|
||||
id: number;
|
||||
customerId: string;
|
||||
content: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: SmsType;
|
||||
sentAt: Date;
|
||||
};
|
55
src/database/customer.ts
Normal file
55
src/database/customer.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/server";
|
||||
import { computeEncryptionKey } from "./_encryption";
|
||||
|
||||
const logger = appLogger.child({ module: "customer" });
|
||||
|
||||
export type Customer = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
paddleCustomerId: string;
|
||||
paddleSubscriptionId: string;
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
encryptionKey: string;
|
||||
};
|
||||
|
||||
type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">;
|
||||
|
||||
export async function createCustomer({ id, email, name }: CreateCustomerParams): Promise<Customer> {
|
||||
const encryptionKey = computeEncryptionKey(id).toString("hex");
|
||||
const { error, data } = await supabase
|
||||
.from<Customer>("customer")
|
||||
.insert({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
encryptionKey,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log("data", data);
|
||||
|
||||
return data![0];
|
||||
}
|
||||
|
||||
export async function findCustomer(id: Customer["id"]): Promise<Customer> {
|
||||
const { error, data } = await supabase
|
||||
.from<Customer>("customer")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, update: Partial<Customer>) {
|
||||
await supabase.from<Customer>("customer")
|
||||
.update(update)
|
||||
.eq("id", id)
|
||||
.throwOnError();
|
||||
}
|
55
src/database/phone-number.ts
Normal file
55
src/database/phone-number.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/server";
|
||||
|
||||
const logger = appLogger.child({ module: "phone-number" });
|
||||
|
||||
export type PhoneNumber = {
|
||||
id: string;
|
||||
customerId: string;
|
||||
phoneNumberSid: string;
|
||||
phoneNumber: string;
|
||||
};
|
||||
|
||||
type CreatePhoneNumberParams = Pick<PhoneNumber, "customerId" | "phoneNumber" | "phoneNumberSid">;
|
||||
|
||||
export async function createPhoneNumber({
|
||||
customerId,
|
||||
phoneNumber,
|
||||
phoneNumberSid,
|
||||
}: CreatePhoneNumberParams): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.insert({
|
||||
customerId: customerId,
|
||||
phoneNumber,
|
||||
phoneNumberSid,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data![0];
|
||||
}
|
||||
|
||||
export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
.eq("customerId", customerId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
46
src/database/sms.ts
Normal file
46
src/database/sms.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/server";
|
||||
import type { Sms } from "./_types";
|
||||
|
||||
const logger = appLogger.child({ module: "sms" });
|
||||
|
||||
export async function insertSms(messages: Omit<Sms, "id">): Promise<Sms> {
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.insert(messages);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data![0];
|
||||
}
|
||||
|
||||
export async function insertManySms(messages: Omit<Sms, "id">[]) {
|
||||
await supabase
|
||||
.from<Sms>("sms")
|
||||
.insert(messages)
|
||||
.throwOnError();
|
||||
}
|
||||
|
||||
export async function findCustomerMessages(customerId: Sms["customerId"]): Promise<Sms[]> {
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.select("*")
|
||||
.eq("customerId", customerId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
|
||||
export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.select("*")
|
||||
.eq("customerId", customerId)
|
||||
.or(`to.eq.${recipient},from.eq.${recipient}`);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
151
src/database/subscriptions.ts
Normal file
151
src/database/subscriptions.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import type { PlanId } from "../subscription/plans";
|
||||
import appLogger from "../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ module: "subscriptions" });
|
||||
|
||||
export type SubscriptionStatus =
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "past_due"
|
||||
| "paused"
|
||||
| "deleted";
|
||||
|
||||
export const SUBSCRIPTION_STATUSES: SubscriptionStatus[] = [
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"paused",
|
||||
"deleted",
|
||||
];
|
||||
|
||||
export type Subscription = {
|
||||
userId: string;
|
||||
planId: PlanId;
|
||||
paddleCheckoutId: string;
|
||||
paddleSubscriptionId: string;
|
||||
nextBillDate: Date;
|
||||
status: SubscriptionStatus;
|
||||
lastEventTime: Date;
|
||||
updateUrl: string;
|
||||
cancelUrl: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type FirestoreSubscription = FirestoreEntry<Subscription>;
|
||||
|
||||
const subscriptions = firestoreCollection<FirestoreSubscription>(
|
||||
"subscriptions",
|
||||
);
|
||||
|
||||
type CreateSubscriptionParams = Pick<
|
||||
Subscription,
|
||||
| "userId"
|
||||
| "planId"
|
||||
| "paddleCheckoutId"
|
||||
| "paddleSubscriptionId"
|
||||
| "nextBillDate"
|
||||
| "status"
|
||||
| "updateUrl"
|
||||
| "cancelUrl"
|
||||
| "lastEventTime"
|
||||
>;
|
||||
|
||||
export async function createSubscription({
|
||||
userId,
|
||||
planId,
|
||||
paddleCheckoutId,
|
||||
paddleSubscriptionId,
|
||||
nextBillDate,
|
||||
status,
|
||||
updateUrl,
|
||||
cancelUrl,
|
||||
lastEventTime,
|
||||
}: CreateSubscriptionParams): Promise<Subscription> {
|
||||
const createdAt = FieldValue.serverTimestamp() as Timestamp;
|
||||
await subscriptions.doc(paddleSubscriptionId).set({
|
||||
userId,
|
||||
planId,
|
||||
paddleCheckoutId,
|
||||
paddleSubscriptionId,
|
||||
nextBillDate,
|
||||
status,
|
||||
updateUrl,
|
||||
cancelUrl,
|
||||
lastEventTime,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
const subscription = await findSubscription({ paddleSubscriptionId });
|
||||
|
||||
return subscription!;
|
||||
}
|
||||
|
||||
type GetSubscriptionParams = Pick<Subscription, "paddleSubscriptionId">;
|
||||
|
||||
export async function findSubscription({
|
||||
paddleSubscriptionId,
|
||||
}: GetSubscriptionParams): Promise<Subscription | undefined> {
|
||||
const subscriptionDocument = await subscriptions
|
||||
.doc(paddleSubscriptionId)
|
||||
.get();
|
||||
if (!subscriptionDocument.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
return convertFromFirestore(subscriptionDocument.data()!);
|
||||
}
|
||||
|
||||
type FindUserSubscriptionParams = Pick<Subscription, "userId">;
|
||||
|
||||
export async function findUserSubscription({
|
||||
userId,
|
||||
}: FindUserSubscriptionParams): Promise<Subscription | null> {
|
||||
const subscriptionDocumentsSnapshot = await subscriptions
|
||||
.where("userId", "==", userId)
|
||||
.where("status", "!=", "deleted")
|
||||
.get();
|
||||
if (subscriptionDocumentsSnapshot.docs.length === 0) {
|
||||
logger.warn(`No subscription found for user ${userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionDocument = subscriptionDocumentsSnapshot.docs[0].data();
|
||||
|
||||
return convertFromFirestore(subscriptionDocument);
|
||||
}
|
||||
|
||||
type UpdateSubscriptionParams = Pick<Subscription, "paddleSubscriptionId"> &
|
||||
Partial<
|
||||
Pick<
|
||||
Subscription,
|
||||
| "planId"
|
||||
| "paddleCheckoutId"
|
||||
| "paddleSubscriptionId"
|
||||
| "nextBillDate"
|
||||
| "status"
|
||||
| "updateUrl"
|
||||
| "cancelUrl"
|
||||
| "lastEventTime"
|
||||
>
|
||||
>;
|
||||
|
||||
export async function updateSubscription(
|
||||
update: UpdateSubscriptionParams,
|
||||
): Promise<void> {
|
||||
const paddleSubscriptionId = update.paddleSubscriptionId;
|
||||
await subscriptions.doc(paddleSubscriptionId).set(
|
||||
{
|
||||
...update,
|
||||
updatedAt: FieldValue.serverTimestamp() as Timestamp,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSubscription({
|
||||
paddleSubscriptionId,
|
||||
}: Pick<Subscription, "paddleSubscriptionId">): Promise<void> {
|
||||
await subscriptions.doc(paddleSubscriptionId).delete();
|
||||
}
|
17
src/fonts.css
Normal file
17
src/fonts.css
Normal file
@ -0,0 +1,17 @@
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: optional;
|
||||
font-style: normal;
|
||||
font-named-instance: "Regular";
|
||||
src: url("/static/fonts/inter/Inter-roman.var.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: optional;
|
||||
font-style: italic;
|
||||
font-named-instance: "Italic";
|
||||
src: url("/static/fonts/inter/Inter-italic.var.woff2") format("woff2");
|
||||
}
|
66
src/hooks/use-auth.ts
Normal file
66
src/hooks/use-auth.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
|
||||
import supabase from "../supabase/client";
|
||||
|
||||
type Credentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export default function useAuth() {
|
||||
const router = useRouter();
|
||||
const redirectToRef = useRef("/messages");
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
|
||||
await axios.post("/api/auth/session", { event, session });
|
||||
|
||||
if (event === "SIGNED_IN") {
|
||||
await router.push(redirectToRef.current);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => data?.unsubscribe();
|
||||
}, []);
|
||||
|
||||
async function signUp({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}: Credentials & { name: string; redirectTo?: string }) {
|
||||
await axios.post("/api/auth/sign-up", { email, password, name });
|
||||
await signIn({ email, password, redirectTo: "/welcome/step-one" });
|
||||
}
|
||||
|
||||
async function signIn({
|
||||
email,
|
||||
password,
|
||||
redirectTo = "/messages",
|
||||
}: Credentials & { redirectTo?: string }) {
|
||||
redirectToRef.current = redirectTo;
|
||||
const { error } = await supabase.auth.signIn({ email, password });
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async function resetPassword(email: string) {
|
||||
return supabase.auth.api.resetPasswordForEmail(email);
|
||||
}
|
||||
|
||||
return {
|
||||
signUp,
|
||||
signIn,
|
||||
signOut,
|
||||
resetPassword,
|
||||
};
|
||||
}
|
49
src/hooks/use-paddle.ts
Normal file
49
src/hooks/use-paddle.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import getConfig from "next/config";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Paddle: any;
|
||||
}
|
||||
}
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const vendor = publicRuntimeConfig.paddle.vendorId;
|
||||
|
||||
export default function usePaddle({
|
||||
eventCallback,
|
||||
}: {
|
||||
eventCallback: (data: any) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.Paddle) {
|
||||
const script = document.createElement("script");
|
||||
script.onload = () => {
|
||||
window.Paddle.Setup({
|
||||
vendor,
|
||||
eventCallback(data: any) {
|
||||
eventCallback(data);
|
||||
|
||||
if (data.event === "Checkout.Complete") {
|
||||
setTimeout(() => router.reload(), 1000);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
script.src = "https://cdn.paddle.com/paddle/paddle.js";
|
||||
|
||||
document.head.appendChild(script);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return { Paddle: null };
|
||||
}
|
||||
|
||||
return { Paddle: window.Paddle };
|
||||
}
|
22
src/hooks/use-request.ts
Normal file
22
src/hooks/use-request.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { AxiosError } from "axios";
|
||||
import axios from "axios";
|
||||
import type { UseQueryOptions } from "react-query";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import type { ApiError } from "../pages/api/_types";
|
||||
|
||||
export default function useRequest<
|
||||
TData = unknown,
|
||||
TError = AxiosError<ApiError>
|
||||
>(url: string, options?: UseQueryOptions<TData, TError>) {
|
||||
const query = createQuery<TData>(url);
|
||||
|
||||
return useQuery<TData, TError>(url, query, options);
|
||||
}
|
||||
|
||||
function createQuery<T = any>(url: string) {
|
||||
return async function query() {
|
||||
const { data } = await axios.get<T>(url);
|
||||
return data;
|
||||
};
|
||||
}
|
92
src/hooks/use-subscription.ts
Normal file
92
src/hooks/use-subscription.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
|
||||
import type { PlanId } from "../subscription/plans";
|
||||
import { Plan } from "../subscription/plans";
|
||||
import usePaddle from "./use-paddle";
|
||||
|
||||
export default function useSubscription() {
|
||||
const router = useRouter();
|
||||
const resolve = useRef<() => void>();
|
||||
const promise = useRef<Promise<void>>();
|
||||
|
||||
const { Paddle } = usePaddle({
|
||||
eventCallback(data) {
|
||||
if (["Checkout.Close", "Checkout.Complete"].includes(data.event)) {
|
||||
resolve.current!();
|
||||
promise.current = new Promise((r) => (resolve.current = r));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
promise.current = new Promise((r) => (resolve.current = r));
|
||||
}, []);
|
||||
|
||||
type BuyParams = {
|
||||
email: string;
|
||||
userId: string;
|
||||
planId: PlanId;
|
||||
coupon?: string;
|
||||
};
|
||||
|
||||
async function subscribe(params: BuyParams) {
|
||||
const { email, userId, planId, coupon } = params;
|
||||
const checkoutOpenParams = {
|
||||
email,
|
||||
product: planId,
|
||||
allowQuantity: false,
|
||||
passthrough: JSON.stringify({ userId }),
|
||||
coupon: "",
|
||||
};
|
||||
|
||||
if (coupon) {
|
||||
checkoutOpenParams.coupon = coupon;
|
||||
}
|
||||
|
||||
Paddle.Checkout.open(checkoutOpenParams);
|
||||
|
||||
return promise.current;
|
||||
}
|
||||
|
||||
async function updatePaymentMethod({ updateUrl }: { updateUrl: string }) {
|
||||
const checkoutOpenParams = { override: updateUrl };
|
||||
|
||||
Paddle.Checkout.open(checkoutOpenParams);
|
||||
|
||||
return promise.current;
|
||||
}
|
||||
|
||||
async function cancelSubscription({ cancelUrl }: { cancelUrl: string }) {
|
||||
const checkoutOpenParams = { override: cancelUrl };
|
||||
|
||||
Paddle.Checkout.open(checkoutOpenParams);
|
||||
|
||||
return promise.current;
|
||||
}
|
||||
|
||||
type ChangePlanParams = {
|
||||
planId: Plan["id"];
|
||||
};
|
||||
|
||||
async function changePlan({ planId }: ChangePlanParams) {
|
||||
try {
|
||||
await axios.post(
|
||||
"/api/subscription/update",
|
||||
{ planId },
|
||||
{ withCredentials: true },
|
||||
);
|
||||
router.reload();
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
updatePaymentMethod,
|
||||
cancelSubscription,
|
||||
changePlan,
|
||||
};
|
||||
}
|
52
src/hooks/use-user.ts
Normal file
52
src/hooks/use-user.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useContext } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import type { User, UserAttributes } from "@supabase/supabase-js";
|
||||
|
||||
import { SessionContext } from "../session-context";
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/client";
|
||||
|
||||
const logger = appLogger.child({ module: "useUser" });
|
||||
|
||||
type UseUser = {
|
||||
updateUser: (attributes: UserAttributes) => Promise<void>;
|
||||
deleteUser: () => Promise<void>;
|
||||
} & (
|
||||
| {
|
||||
isLoading: true;
|
||||
error: null;
|
||||
userProfile: null;
|
||||
}
|
||||
| {
|
||||
isLoading: false;
|
||||
error: Error;
|
||||
userProfile: User | null;
|
||||
}
|
||||
| {
|
||||
isLoading: false;
|
||||
error: null;
|
||||
userProfile: User;
|
||||
}
|
||||
);
|
||||
|
||||
export default function useUser(): UseUser {
|
||||
const session = useContext(SessionContext);
|
||||
const router = useRouter();
|
||||
|
||||
return {
|
||||
isLoading: session.state.user === null,
|
||||
userProfile: session.state.user,
|
||||
error: session.state.error,
|
||||
async deleteUser() {
|
||||
await axios.post("/api/user/delete-user", null, {
|
||||
withCredentials: true,
|
||||
});
|
||||
router.push("/api/auth/sign-out");
|
||||
},
|
||||
async updateUser(attributes: UserAttributes) {
|
||||
const { error } = await supabase.auth.update(attributes);
|
||||
if (error) throw error;
|
||||
}
|
||||
} as UseUser;
|
||||
}
|
39
src/pages/_app.tsx
Normal file
39
src/pages/_app.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useRef } from "react";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { Hydrate } from "react-query/hydration";
|
||||
|
||||
import { pageTitle } from "./_document";
|
||||
import { SessionProvider } from "../session-context";
|
||||
|
||||
import "../fonts.css";
|
||||
import "../tailwind.css";
|
||||
|
||||
const NextApp = (props: AppProps) => {
|
||||
const queryClientRef = useRef<QueryClient>();
|
||||
if (!queryClientRef.current) {
|
||||
queryClientRef.current = new QueryClient();
|
||||
}
|
||||
|
||||
const { Component, pageProps } = props;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<Hydrate state={pageProps.dehydratedState}>
|
||||
<SessionProvider user={pageProps.user}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<title>{pageTitle}</title>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
</Hydrate>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextApp;
|
60
src/pages/_document.tsx
Normal file
60
src/pages/_document.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import Document, { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export const pageTitle = "My Serverless App";
|
||||
const defaultDescription = "My app, freshly generated by FSS";
|
||||
const defaultOGURL = "";
|
||||
const defaultOGImage = "";
|
||||
|
||||
class NextDocument extends Document {
|
||||
public render() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="description" content={defaultDescription} />
|
||||
<link
|
||||
rel="icon"
|
||||
sizes="192x192"
|
||||
href="/static/touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/static/touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="/static/favicon-mask.svg"
|
||||
color="#49B882"
|
||||
/>
|
||||
<link rel="icon" href="/static/favicon.ico" />
|
||||
<meta property="og:url" content={defaultOGURL} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta
|
||||
property="og:description"
|
||||
content={defaultDescription}
|
||||
/>
|
||||
<meta name="twitter:site" content={defaultOGURL} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content={defaultOGImage} />
|
||||
<meta property="og:image" content={defaultOGImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
href="/static/fonts/inter/Inter-roman.var.woff2"
|
||||
/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NextDocument;
|
6
src/pages/api/_redirect.ts
Normal file
6
src/pages/api/_redirect.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { ServerResponse } from "http";
|
||||
|
||||
export function redirect(res: ServerResponse, to: string) {
|
||||
res.writeHead(302, { Location: encodeURI(to) });
|
||||
res.end();
|
||||
}
|
45
src/pages/api/_send-email.ts
Normal file
45
src/pages/api/_send-email.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { SendEmailRequest } from "aws-sdk/clients/ses";
|
||||
import { Credentials, SES } from "aws-sdk";
|
||||
import getConfig from "next/config";
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
|
||||
const credentials = new Credentials({
|
||||
accessKeyId: serverRuntimeConfig.awsSes.accessKeyId,
|
||||
secretAccessKey: serverRuntimeConfig.awsSes.secretAccessKey,
|
||||
});
|
||||
const ses = new SES({
|
||||
region: serverRuntimeConfig.awsSes.awsRegion,
|
||||
credentials,
|
||||
});
|
||||
|
||||
type SendEmailParams = {
|
||||
body: string;
|
||||
subject: string;
|
||||
recipients: string[];
|
||||
};
|
||||
|
||||
export async function sendEmail({
|
||||
body,
|
||||
subject,
|
||||
recipients,
|
||||
}: SendEmailParams) {
|
||||
const request: SendEmailRequest = {
|
||||
Destination: { ToAddresses: recipients },
|
||||
Message: {
|
||||
Body: {
|
||||
Text: {
|
||||
Charset: "UTF-8",
|
||||
Data: body,
|
||||
},
|
||||
},
|
||||
Subject: {
|
||||
Charset: "UTF-8",
|
||||
Data: subject,
|
||||
},
|
||||
},
|
||||
Source: serverRuntimeConfig.awsSes.fromEmail,
|
||||
};
|
||||
|
||||
await ses.sendEmail(request).promise();
|
||||
}
|
4
src/pages/api/_types.ts
Normal file
4
src/pages/api/_types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
10
src/pages/api/auth/session.ts
Normal file
10
src/pages/api/auth/session.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import supabase from "../../../supabase/server";
|
||||
|
||||
export default async function session(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
return supabase.auth.api.setAuthCookie(req, res);
|
||||
}
|
71
src/pages/api/auth/sign-in.ts
Normal file
71
src/pages/api/auth/sign-in.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { UserCredentials } from "@supabase/supabase-js";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import supabase from "../../../supabase/server";
|
||||
|
||||
type Response = void | ApiError;
|
||||
|
||||
type Body = Pick<UserCredentials, "email" | "password">;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/auth/sign-in" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
email: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
});
|
||||
|
||||
export default async function signIn(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Response>,
|
||||
): Promise<void> {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
const { error } = await supabase.auth.signIn({
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
});
|
||||
if (error) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
logger.error(error);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
supabase.auth.api.setAuthCookie(req, res);
|
||||
|
||||
res.status(200).end();
|
||||
}
|
94
src/pages/api/auth/sign-up.ts
Normal file
94
src/pages/api/auth/sign-up.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { UserCredentials } from "@supabase/supabase-js";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { sendEmail } from "../_send-email";
|
||||
import supabase from "../../../supabase/server";
|
||||
import { createCustomer } from "../../../database/customer";
|
||||
|
||||
type Response = void | ApiError;
|
||||
|
||||
type Body = Pick<UserCredentials, "email" | "password"> & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ route: "/api/auth/sign-up" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
name: Joi.string().required(),
|
||||
email: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
});
|
||||
|
||||
export default async function signUp(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Response>,
|
||||
): Promise<void> {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
const { error, user } = await supabase.auth.signUp({ email: body.email, password: body.password });
|
||||
if (error) {
|
||||
// @ts-ignore
|
||||
const statusCode = error.status ?? 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
logger.error(error);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
supabase.auth.update({ data: { name: body.name } }),
|
||||
createCustomer({
|
||||
id: user!.id,
|
||||
email: body.email!,
|
||||
name: body.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log("user", user);
|
||||
const email = user!.email;
|
||||
|
||||
if (email && email !== "") {
|
||||
await sendEmail({
|
||||
subject: "Welcome to my app",
|
||||
body: `Hi there,
|
||||
|
||||
Thanks for signing up to my app.`,
|
||||
recipients: [email],
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
21
src/pages/api/ddd.ts
Normal file
21
src/pages/api/ddd.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import twilio from "twilio";
|
||||
|
||||
export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
|
||||
const accountSid = "ACa886d066be0832990d1cf43fb1d53362";
|
||||
const authToken = "8696a59a64b94bb4eba3548ed815953b";
|
||||
// const ddd = await twilio(accountSid, authToken).incomingPhoneNumbers.list();
|
||||
const phoneNumber = "+33757592025";
|
||||
const ddd = await twilio(accountSid, authToken)
|
||||
.messages
|
||||
.list({
|
||||
to: phoneNumber,
|
||||
});
|
||||
|
||||
console.log("ddd", ddd);
|
||||
|
||||
return res.status(200).send(ddd);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
function uuid(a,b){for(b=a='';a++<36;b+=a*51&52?(a^15?8^Math.random()*(a^20?16:4):4).toString(16):'-');return b}
|
21
src/pages/api/newsletter/_mailchimp.ts
Normal file
21
src/pages/api/newsletter/_mailchimp.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import getConfig from "next/config";
|
||||
import axios from "axios";
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
|
||||
export async function addSubscriber(email: string) {
|
||||
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp;
|
||||
const region = apiKey.split("-")[1];
|
||||
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
|
||||
const data = {
|
||||
email_address: email,
|
||||
status: "subscribed",
|
||||
};
|
||||
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${base64ApiKey}`,
|
||||
};
|
||||
|
||||
return axios.post(url, data, { headers });
|
||||
}
|
65
src/pages/api/newsletter/subscribe.ts
Normal file
65
src/pages/api/newsletter/subscribe.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { addSubscriber } from "./_mailchimp";
|
||||
|
||||
type Body = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Response = {} | ApiError;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/newsletter/subscribe" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
email: Joi.string().email().required(),
|
||||
});
|
||||
|
||||
export default async function subscribeToNewsletter(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Response>,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = bodySchema.validate(req.body, {
|
||||
stripUnknown: true,
|
||||
});
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
const { email }: Body = validationResult.value;
|
||||
|
||||
try {
|
||||
await addSubscriber(email);
|
||||
} catch (error) {
|
||||
console.log("error", error.response?.data);
|
||||
|
||||
if (error.response?.data.title !== "Member Exists") {
|
||||
return res.status(error.response?.status ?? 400).end();
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
36
src/pages/api/queue/fetch-messages.ts
Normal file
36
src/pages/api/queue/fetch-messages.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Queue } from "quirrel/next";
|
||||
import twilio from "twilio";
|
||||
|
||||
import { findCustomerPhoneNumber } from "../../../database/phone-number";
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
import insertMessagesQueue from "./insert-messages";
|
||||
|
||||
type Payload = {
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
const fetchMessagesQueue = Queue<Payload>(
|
||||
"api/queue/fetch-messages",
|
||||
async ({ customerId }) => {
|
||||
const customer = await findCustomer(customerId);
|
||||
const phoneNumber = await findCustomerPhoneNumber(customerId);
|
||||
|
||||
const messagesSent = await twilio(customer.accountSid, customer.authToken)
|
||||
.messages
|
||||
.list({ from: phoneNumber.phoneNumber });
|
||||
const messagesReceived = await twilio(customer.accountSid, customer.authToken)
|
||||
.messages
|
||||
.list({ to: phoneNumber.phoneNumber });
|
||||
const messages = [
|
||||
...messagesSent,
|
||||
...messagesReceived,
|
||||
].sort((a, b) => a.dateSent.getTime() - b.dateSent.getTime());
|
||||
|
||||
await insertMessagesQueue.enqueue({
|
||||
customerId,
|
||||
messages,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default fetchMessagesQueue;
|
33
src/pages/api/queue/insert-messages.ts
Normal file
33
src/pages/api/queue/insert-messages.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Queue } from "quirrel/next";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
|
||||
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
import type { Sms } from "../../../database/_types";
|
||||
import { SmsType } from "../../../database/_types";
|
||||
import { insertManySms } from "../../../database/sms";
|
||||
import { encrypt } from "../../../database/_encryption";
|
||||
|
||||
type Payload = {
|
||||
customerId: string;
|
||||
messages: MessageInstance[];
|
||||
}
|
||||
|
||||
const insertMessagesQueue = Queue<Payload>(
|
||||
"api/queue/insert-messages",
|
||||
async ({ messages, customerId }) => {
|
||||
const customer = await findCustomer(customerId);
|
||||
const encryptionKey = customer.encryptionKey;
|
||||
|
||||
const sms = messages.map<Omit<Sms, "id">>(message => ({
|
||||
customerId,
|
||||
content: encrypt(message.body, encryptionKey),
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
|
||||
sentAt: message.dateSent,
|
||||
}));
|
||||
await insertManySms(sms);
|
||||
},
|
||||
);
|
||||
|
||||
export default insertMessagesQueue;
|
93
src/pages/api/subscription/_subscription-cancelled.ts
Normal file
93
src/pages/api/subscription/_subscription-cancelled.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import type { SubscriptionStatus } from "../../../database/subscriptions";
|
||||
import {
|
||||
findSubscription,
|
||||
SUBSCRIPTION_STATUSES,
|
||||
updateSubscription,
|
||||
} from "../../../database/subscriptions";
|
||||
import { FREE } from "../../../subscription/plans";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ module: "subscription-cancelled" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
event_time: Joi.string().required(),
|
||||
status: Joi.string()
|
||||
.allow(...SUBSCRIPTION_STATUSES)
|
||||
.required(),
|
||||
subscription_id: Joi.string().required(),
|
||||
});
|
||||
|
||||
export async function subscriptionCancelled(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const validationResult = bodySchema.validate(req.body, {
|
||||
allowUnknown: true,
|
||||
});
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
const paddleSubscriptionId = body.subscription_id;
|
||||
const subscription = await findSubscription({ paddleSubscriptionId });
|
||||
if (!subscription) {
|
||||
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
|
||||
const statusCode = 404;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage,
|
||||
};
|
||||
logger.error(errorMessage, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEventTime = new Date(body.event_time);
|
||||
if (subscription.lastEventTime > lastEventTime) {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSubscription({
|
||||
paddleSubscriptionId,
|
||||
status: body.status,
|
||||
lastEventTime,
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
type Body = {
|
||||
alert_id: string;
|
||||
alert_name: string;
|
||||
cancellation_effective_date: string;
|
||||
checkout_id: string;
|
||||
currency: string;
|
||||
email: string;
|
||||
event_time: string;
|
||||
linked_subscriptions: string;
|
||||
marketing_consent: string;
|
||||
passthrough: string;
|
||||
quantity: string;
|
||||
status: SubscriptionStatus;
|
||||
subscription_id: string;
|
||||
subscription_plan_id: string;
|
||||
unit_price: string;
|
||||
user_id: string;
|
||||
p_signature: string;
|
||||
};
|
131
src/pages/api/subscription/_subscription-created.ts
Normal file
131
src/pages/api/subscription/_subscription-created.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { SubscriptionStatus } from "../../../database/subscriptions";
|
||||
import {
|
||||
createSubscription,
|
||||
findUserSubscription,
|
||||
updateSubscription,
|
||||
SUBSCRIPTION_STATUSES,
|
||||
} from "../../../database/subscriptions";
|
||||
import { sendEmail } from "../_send-email";
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { PAID_PLANS } from "../../../subscription/plans";
|
||||
|
||||
const logger = appLogger.child({ module: "subscription-created" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
checkout_id: Joi.string().required(),
|
||||
email: Joi.string().required(),
|
||||
event_time: Joi.string().required(),
|
||||
next_bill_date: Joi.string().required(),
|
||||
passthrough: Joi.string().required(),
|
||||
status: Joi.string()
|
||||
.allow(...SUBSCRIPTION_STATUSES)
|
||||
.required(),
|
||||
subscription_id: Joi.string().required(),
|
||||
subscription_plan_id: Joi.string().required(),
|
||||
});
|
||||
|
||||
export async function subscriptionCreatedHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const validationResult = bodySchema.validate(req.body, {
|
||||
allowUnknown: true,
|
||||
});
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
const paddleCheckoutId = body.checkout_id;
|
||||
const paddleSubscriptionId = body.subscription_id;
|
||||
const planId = body.subscription_plan_id;
|
||||
const { userId } = JSON.parse(body.passthrough);
|
||||
const email = body.email;
|
||||
const nextBillDate = new Date(body.next_bill_date);
|
||||
const status = body.status;
|
||||
const lastEventTime = new Date(body.event_time);
|
||||
const updateUrl = body.update_url;
|
||||
const cancelUrl = body.cancel_url;
|
||||
|
||||
const subscription = await findUserSubscription({ userId });
|
||||
const teamHasSubscription = Boolean(subscription);
|
||||
if (teamHasSubscription) {
|
||||
await updateSubscription({
|
||||
paddleCheckoutId,
|
||||
paddleSubscriptionId,
|
||||
planId,
|
||||
nextBillDate,
|
||||
status,
|
||||
lastEventTime,
|
||||
updateUrl,
|
||||
cancelUrl,
|
||||
});
|
||||
|
||||
sendEmail({
|
||||
subject: "Thanks for coming back",
|
||||
body: "Thanks for coming back",
|
||||
recipients: [email],
|
||||
}).catch((error) => {
|
||||
logger.error(error, "/api/subscription/webhook");
|
||||
});
|
||||
} else {
|
||||
await createSubscription({
|
||||
paddleCheckoutId,
|
||||
paddleSubscriptionId,
|
||||
userId,
|
||||
planId,
|
||||
nextBillDate,
|
||||
status,
|
||||
lastEventTime,
|
||||
updateUrl,
|
||||
cancelUrl,
|
||||
});
|
||||
|
||||
const nextPlan = PAID_PLANS[planId];
|
||||
sendEmail({
|
||||
subject: "Thanks for your purchase",
|
||||
body: `Welcome to ${nextPlan.name} plan`,
|
||||
recipients: [email],
|
||||
}).catch((error) => {
|
||||
logger.error(error, "/api/subscription/webhook");
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
type Body = {
|
||||
alert_id: string;
|
||||
alert_name: string;
|
||||
cancel_url: string;
|
||||
checkout_id: string;
|
||||
currency: string;
|
||||
email: string;
|
||||
event_time: string;
|
||||
linked_subscriptions: string;
|
||||
marketing_consent: string;
|
||||
next_bill_date: string;
|
||||
passthrough: string;
|
||||
quantity: string;
|
||||
source: string;
|
||||
status: SubscriptionStatus;
|
||||
subscription_id: string;
|
||||
subscription_plan_id: string;
|
||||
unit_price: string;
|
||||
update_url: string;
|
||||
user_id: string;
|
||||
p_signature: string;
|
||||
};
|
@ -0,0 +1,90 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import type { SubscriptionStatus } from "../../../database/subscriptions";
|
||||
import {
|
||||
findSubscription,
|
||||
updateSubscription,
|
||||
} from "../../../database/subscriptions";
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ module: "subscription-payment-succeeded" });
|
||||
|
||||
export async function subscriptionPaymentSucceededHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const body: Body = req.body;
|
||||
|
||||
const paddleSubscriptionId = body.subscription_id;
|
||||
const subscription = await findSubscription({ paddleSubscriptionId });
|
||||
if (!subscription) {
|
||||
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
|
||||
const statusCode = 404;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage,
|
||||
};
|
||||
logger.error(errorMessage, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEventTime = new Date(body.event_time);
|
||||
if (subscription.lastEventTime > lastEventTime) {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const status = body.status;
|
||||
const nextBillDate = new Date(body.next_bill_date);
|
||||
|
||||
await updateSubscription({
|
||||
paddleSubscriptionId,
|
||||
status,
|
||||
lastEventTime,
|
||||
nextBillDate,
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
type Body = {
|
||||
alert_id: string;
|
||||
alert_name: string;
|
||||
balance_currency: string;
|
||||
balance_earnings: string;
|
||||
balance_fee: string;
|
||||
balance_gross: string;
|
||||
balance_tax: string;
|
||||
checkout_id: string;
|
||||
country: string;
|
||||
coupon: string;
|
||||
currency: string;
|
||||
customer_name: string;
|
||||
earnings: string;
|
||||
email: string;
|
||||
event_time: string;
|
||||
fee: string;
|
||||
initial_payment: string;
|
||||
instalments: string;
|
||||
marketing_consent: string;
|
||||
next_bill_date: string;
|
||||
next_payment_amount: string;
|
||||
order_id: string;
|
||||
passthrough: string;
|
||||
payment_method: string;
|
||||
payment_tax: string;
|
||||
plan_name: string;
|
||||
quantity: string;
|
||||
receipt_url: string;
|
||||
sale_gross: string;
|
||||
status: SubscriptionStatus;
|
||||
subscription_id: string;
|
||||
subscription_payment_id: string;
|
||||
subscription_plan_id: string;
|
||||
unit_price: string;
|
||||
user_id: string;
|
||||
p_signature: string;
|
||||
};
|
122
src/pages/api/subscription/_subscription-updated.ts
Normal file
122
src/pages/api/subscription/_subscription-updated.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import type { SubscriptionStatus } from "../../../database/subscriptions";
|
||||
import {
|
||||
findSubscription,
|
||||
SUBSCRIPTION_STATUSES,
|
||||
updateSubscription,
|
||||
} from "../../../database/subscriptions";
|
||||
import { PAID_PLANS } from "../../../subscription/plans";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { sendEmail } from "../_send-email";
|
||||
|
||||
const logger = appLogger.child({ module: "subscription-updated" });
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
update_url: Joi.string().required(),
|
||||
status: Joi.string()
|
||||
.allow(...SUBSCRIPTION_STATUSES)
|
||||
.required(),
|
||||
subscription_id: Joi.string().required(),
|
||||
event_time: Joi.string().required(),
|
||||
});
|
||||
|
||||
export async function subscriptionUpdated(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const validationResult = bodySchema.validate(req.body, {
|
||||
allowUnknown: true,
|
||||
});
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
const paddleSubscriptionId = body.subscription_id;
|
||||
const subscription = await findSubscription({ paddleSubscriptionId });
|
||||
if (!subscription) {
|
||||
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
|
||||
const statusCode = 404;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage,
|
||||
};
|
||||
logger.error(errorMessage, "/api/subscription/webhook");
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEventTime = new Date(body.event_time);
|
||||
if (subscription.lastEventTime > lastEventTime) {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const status = body.status;
|
||||
const updateUrl = body.update_url;
|
||||
const cancelUrl = body.cancel_url;
|
||||
const planId = body.subscription_plan_id;
|
||||
const nextPlan = PAID_PLANS[planId];
|
||||
await updateSubscription({
|
||||
paddleSubscriptionId,
|
||||
planId,
|
||||
status,
|
||||
lastEventTime,
|
||||
updateUrl,
|
||||
cancelUrl,
|
||||
});
|
||||
|
||||
const user = await findUser({ id: subscription.userId });
|
||||
|
||||
sendEmail({
|
||||
subject: "Thanks for your purchase",
|
||||
body: `Welcome to ${nextPlan.name} plan`,
|
||||
recipients: [user.email],
|
||||
}).catch((error) => {
|
||||
logger.error(error, "/api/subscription/webhook");
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
type Body = {
|
||||
alert_id: string;
|
||||
alert_name: string;
|
||||
cancel_url: string;
|
||||
checkout_id: string;
|
||||
currency: string;
|
||||
email: string;
|
||||
event_time: string;
|
||||
linked_subscriptions: string;
|
||||
marketing_consent: string;
|
||||
new_price: string;
|
||||
new_quantity: string;
|
||||
new_unit_price: string;
|
||||
next_bill_date: string;
|
||||
old_next_bill_date: string;
|
||||
old_price: string;
|
||||
old_quantity: string;
|
||||
old_status: string;
|
||||
old_subscription_plan_id: string;
|
||||
old_unit_price: string;
|
||||
passthrough: string;
|
||||
status: SubscriptionStatus;
|
||||
subscription_id: string;
|
||||
subscription_plan_id: string;
|
||||
update_url: string;
|
||||
user_id: string;
|
||||
p_signature: string;
|
||||
};
|
88
src/pages/api/subscription/update.ts
Normal file
88
src/pages/api/subscription/update.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import type { ApiError } from "../_types";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { findUserSubscription } from "../../../database/subscriptions";
|
||||
import Joi from "joi";
|
||||
import {
|
||||
cancelPaddleSubscription,
|
||||
updateSubscriptionPlan,
|
||||
} from "../../../subscription/_paddle-api";
|
||||
|
||||
type Body = {
|
||||
planId: string;
|
||||
};
|
||||
type Response = {} | ApiError;
|
||||
|
||||
const logger = appLogger.child({
|
||||
route: "/api/subscription/update-subscription",
|
||||
});
|
||||
|
||||
const bodySchema = Joi.object<Body>({
|
||||
planId: Joi.string().required(),
|
||||
});
|
||||
|
||||
export default withApiAuthRequired<Response>(async function updateSubscription(
|
||||
req,
|
||||
res,
|
||||
session,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = bodySchema.validate(req.body, {
|
||||
stripUnknown: true,
|
||||
});
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
const { planId }: Body = validationResult.value;
|
||||
|
||||
const subscription = await findUserSubscription({
|
||||
teamId: session.user.teamId,
|
||||
});
|
||||
if (!subscription) {
|
||||
const statusCode = 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "You are not subscribed yet, this should not happen.",
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = subscription.paddleSubscriptionId;
|
||||
const isMovingToFreePlan = planId === "free";
|
||||
if (isMovingToFreePlan) {
|
||||
await cancelPaddleSubscription({ subscriptionId });
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSubscriptionPlan({
|
||||
planId,
|
||||
subscriptionId,
|
||||
});
|
||||
|
||||
res.status(200).end();
|
||||
});
|
78
src/pages/api/subscription/webhook.ts
Normal file
78
src/pages/api/subscription/webhook.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||
import getConfig from "next/config";
|
||||
import { PaddleSdk, stringifyMetadata } from "@devoxa/paddle-sdk";
|
||||
|
||||
import { subscriptionCreatedHandler } from "./_subscription-created";
|
||||
import { subscriptionPaymentSucceededHandler } from "./_subscription-payment-succeeded";
|
||||
import { subscriptionCancelled } from "./_subscription-cancelled";
|
||||
import { subscriptionUpdated } from "./_subscription-updated";
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
type SupportedWebhook =
|
||||
| "subscription_created"
|
||||
| "subscription_cancelled"
|
||||
| "subscription_payment_succeeded"
|
||||
| "subscription_updated";
|
||||
const supportedWebhooks: SupportedWebhook[] = [
|
||||
"subscription_created",
|
||||
"subscription_cancelled",
|
||||
"subscription_payment_succeeded",
|
||||
"subscription_updated",
|
||||
];
|
||||
|
||||
const handlers: Record<SupportedWebhook, NextApiHandler> = {
|
||||
subscription_created: subscriptionCreatedHandler,
|
||||
subscription_payment_succeeded: subscriptionPaymentSucceededHandler,
|
||||
subscription_cancelled: subscriptionCancelled,
|
||||
subscription_updated: subscriptionUpdated,
|
||||
};
|
||||
|
||||
function isSupportedWebhook(webhook: any): webhook is SupportedWebhook {
|
||||
return supportedWebhooks.includes(webhook);
|
||||
}
|
||||
|
||||
const logger = appLogger.child({ route: "/api/subscription/webhook" });
|
||||
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
|
||||
const paddleSdk = new PaddleSdk({
|
||||
publicKey: serverRuntimeConfig.paddle.publicKey,
|
||||
vendorId: publicRuntimeConfig.paddle.vendorId,
|
||||
vendorAuthCode: serverRuntimeConfig.paddle.apiKey,
|
||||
metadataCodec: stringifyMetadata(),
|
||||
});
|
||||
|
||||
export default async function webhook(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paddleSdk.verifyWebhookEvent(req.body)) {
|
||||
const statusCode = 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Webhook event is invalid",
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
return res.status(statusCode).send(apiError);
|
||||
}
|
||||
|
||||
const alertName = req.body.alert_name;
|
||||
if (isSupportedWebhook(alertName)) {
|
||||
return handlers[alertName](req, res);
|
||||
}
|
||||
|
||||
return res.status(400).end();
|
||||
}
|
52
src/pages/api/user/add-phone-number.ts
Normal file
52
src/pages/api/user/add-phone-number.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import Joi from "joi";
|
||||
import twilio from "twilio";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { createPhoneNumber } from "../../../database/phone-number";
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
import fetchMessagesQueue from "../queue/fetch-messages";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/add-phone-number" });
|
||||
|
||||
type Body = {
|
||||
phoneNumberSid: string;
|
||||
}
|
||||
|
||||
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
|
||||
const bodySchema = Joi.object<Body>({
|
||||
phoneNumberSid: Joi.string().required(),
|
||||
});
|
||||
|
||||
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = user.id;
|
||||
const customer = await findCustomer(customerId);
|
||||
const phoneNumbers = await twilio(customer.accountSid, customer.authToken)
|
||||
.incomingPhoneNumbers
|
||||
.list();
|
||||
const { phoneNumberSid }: Body = validationResult.value;
|
||||
const phoneNumber = phoneNumbers.find(phoneNumber => phoneNumber.sid === phoneNumberSid)!;
|
||||
await createPhoneNumber({
|
||||
customerId,
|
||||
phoneNumberSid,
|
||||
phoneNumber: phoneNumber.phoneNumber,
|
||||
});
|
||||
|
||||
await fetchMessagesQueue.enqueue({ customerId });
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
71
src/pages/api/user/delete-user.ts
Normal file
71
src/pages/api/user/delete-user.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { ApiError } from "../_types";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import { deleteSubscription } from "../../../database/subscriptions";
|
||||
import { cancelPaddleSubscription } from "../../../subscription/_paddle-api";
|
||||
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
type Response = void | ApiError;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/delete-user" });
|
||||
|
||||
export default withApiAuthRequired<Response>(async function deleteUserHandler(
|
||||
req,
|
||||
res,
|
||||
session,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id: userId, role, teamId } = session.user;
|
||||
const team = await findTeam({ id: teamId });
|
||||
const subscriptionId = team!.subscriptionId;
|
||||
|
||||
try {
|
||||
let actions: Promise<any>[] = [
|
||||
deleteAuth0User({ id: userId }),
|
||||
deleteUser({ id: userId, teamId }),
|
||||
];
|
||||
|
||||
if (role === "owner") {
|
||||
const teamMembers = await findUsersByTeam({ teamId });
|
||||
|
||||
teamMembers.forEach((member) =>
|
||||
actions.push(deleteUser({ id: member.id, teamId })),
|
||||
);
|
||||
actions.push(deleteTeam({ id: teamId }));
|
||||
|
||||
if (subscriptionId) {
|
||||
actions.push(
|
||||
cancelPaddleSubscription({ subscriptionId }),
|
||||
deleteSubscription({
|
||||
paddleSubscriptionId: subscriptionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(actions);
|
||||
|
||||
res.status(200).end();
|
||||
} catch (error) {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
}
|
||||
});
|
21
src/pages/api/user/list-twilio-numbers.ts
Normal file
21
src/pages/api/user/list-twilio-numbers.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import Joi from "joi";
|
||||
import twilio from "twilio";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { createPhoneNumber } from "../../../database/phone-number";
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/list-twilio-numbers" });
|
||||
|
||||
export default withApiAuthRequired(async function listTwilioNumbersHandler(req, res, user) {
|
||||
const customer = await findCustomer(user.id);
|
||||
const phoneNumbers = await twilio(customer.accountSid, customer.authToken)
|
||||
.incomingPhoneNumbers
|
||||
.list();
|
||||
|
||||
return res.status(200).send({
|
||||
phoneNumbers: phoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid })),
|
||||
});
|
||||
});
|
30
src/pages/api/user/session.ts
Normal file
30
src/pages/api/user/session.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { ApiError } from "../_types";
|
||||
import type Session from "../../../../lib/session";
|
||||
import {
|
||||
sessionCache,
|
||||
withApiAuthRequired,
|
||||
} from "../../../../lib/session-helpers";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
type Response = Session | ApiError;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/session" });
|
||||
|
||||
export default withApiAuthRequired<Response>(async function session(req, res) {
|
||||
if (req.method !== "GET") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionCache.get(req, res)!;
|
||||
|
||||
res.status(200).send(session);
|
||||
});
|
115
src/pages/api/user/update-user.ts
Normal file
115
src/pages/api/user/update-user.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import supabase from "../../../supabase/server";
|
||||
import { UserAttributes } from "@supabase/gotrue-js/dist/main/lib/types";
|
||||
import { Customer, updateCustomer } from "../../../database/customer";
|
||||
|
||||
type Response = void | ApiError;
|
||||
|
||||
type Body = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
twilioAccountSid?: string;
|
||||
twilioAuthToken?: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/update-user" });
|
||||
const bodySchema = Joi.object<Body>({
|
||||
name: Joi.string().allow(""),
|
||||
email: Joi.string().email().allow(""),
|
||||
password: Joi.string().allow(""),
|
||||
twilioAccountSid: Joi.string().allow(""),
|
||||
twilioAuthToken: Joi.string().allow(""),
|
||||
});
|
||||
|
||||
export default withApiAuthRequired<Response>(async function updateUserHandler(
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = bodySchema.validate(req.body);
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Body = validationResult.value;
|
||||
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
twilioAuthToken,
|
||||
twilioAccountSid,
|
||||
} = body;
|
||||
const shouldUpdateName = name?.length && name !== user.user_metadata.name;
|
||||
const shouldUpdateEmail = email?.length && email !== user.email;
|
||||
const shouldUpdatePassword = password?.length;
|
||||
const shouldUpdateTwilioCredentials = twilioAuthToken?.length && twilioAccountSid?.length;
|
||||
|
||||
try {
|
||||
let updatedSupabaseUser: UserAttributes = {};
|
||||
let updatedCustomer: Partial<Customer> = {};
|
||||
|
||||
if (shouldUpdateName) {
|
||||
updatedSupabaseUser.data = { name };
|
||||
updatedCustomer.name = name;
|
||||
}
|
||||
|
||||
if (shouldUpdateEmail) {
|
||||
updatedSupabaseUser.email = email;
|
||||
updatedCustomer.email = email;
|
||||
// TODO: once Paddle allows it, update customer email through their API
|
||||
}
|
||||
|
||||
if (shouldUpdatePassword) {
|
||||
updatedSupabaseUser.password = password;
|
||||
}
|
||||
|
||||
if (shouldUpdateTwilioCredentials) {
|
||||
updatedCustomer.accountSid = twilioAccountSid;
|
||||
updatedCustomer.authToken = twilioAuthToken;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
supabase.auth.update(updatedSupabaseUser),
|
||||
updateCustomer(user.id, updatedCustomer),
|
||||
]);
|
||||
|
||||
res.status(200).end();
|
||||
} catch (error) {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
}
|
||||
});
|
127
src/pages/auth/forgot-password.tsx
Normal file
127
src/pages/auth/forgot-password.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import type { NextPage } from "next";
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Alert from "../../components/alert";
|
||||
|
||||
import useAuth from "../../hooks/use-auth";
|
||||
|
||||
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
|
||||
import appLogger from "../../../lib/logger";
|
||||
import Logo from "../../components/logo";
|
||||
|
||||
type Form = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ page: "/auth/forgot-password" });
|
||||
|
||||
const ForgotPassword: NextPage = () => {
|
||||
const auth = useAuth();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { isSubmitting, isSubmitSuccessful, errors },
|
||||
} = useForm<Form>();
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.resetPassword(email);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
setError("email", { message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
const errorMessage = errors.email?.message;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Logo className="mx-auto h-8 w-8" />
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Get a new password
|
||||
</h2>
|
||||
<p className="mt-2 px-4 text-center text-sm leading-5 text-gray-600">
|
||||
Enter your user account's email address and we will
|
||||
send you a password reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Alert
|
||||
title="Oops, there was an issue"
|
||||
message={errorMessage}
|
||||
variant="error"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSubmitSuccessful ? (
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Alert
|
||||
title="Password reset email sent"
|
||||
message="Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder."
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<div className="px-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<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"
|
||||
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 className="mt-6">
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-primary-400 hover:bg-primary-400 active:bg-primary-400": isSubmitting,
|
||||
"bg-primary-600 hover:bg-primary-500 active:bg-primary-700": !isSubmitting,
|
||||
},
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Loading..."
|
||||
: "Send password reset email"}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
|
||||
export const getServerSideProps = withPageAuthNotRequired();
|
12
src/pages/auth/sign-in.tsx
Normal file
12
src/pages/auth/sign-in.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { NextPage } from "next";
|
||||
|
||||
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
|
||||
import AuthPage from "../../components/auth/auth-page";
|
||||
|
||||
const SignIn: NextPage = () => {
|
||||
return <AuthPage authType="signIn" />;
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
|
||||
export const getServerSideProps = withPageAuthNotRequired();
|
34
src/pages/auth/sign-out.tsx
Normal file
34
src/pages/auth/sign-out.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { NextPage } from "next";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import useAuth from "../../hooks/use-auth";
|
||||
|
||||
const SignOut: NextPage = () => {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => void auth.signOut());
|
||||
|
||||
return (
|
||||
<div className="py-12 px-10 my-16 mx-auto w-1/2 leading-5 text-gray-900 bg-white rounded border border-gray-400 border-solid shadow-xs max-w-[400px]">
|
||||
<div className="block mx-auto w-11/12 text-center max-w-screen-lg">
|
||||
<h1 className="p-0 text-4xl font-black text-gray-700 normal-case min-h-[1rem]">
|
||||
See you again soon!
|
||||
</h1>
|
||||
<Link href="/auth/sign-in">
|
||||
<a className="font-bold text-teal-600 no-underline cursor-pointer hover:text-gray-800 hover:no-underline">
|
||||
Log back in
|
||||
</a>
|
||||
</Link>
|
||||
<br />
|
||||
<Link href="/">
|
||||
<a className="font-bold text-teal-600 no-underline cursor-pointer hover:text-gray-800 hover:no-underline">
|
||||
Back to home
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignOut;
|
12
src/pages/auth/sign-up.tsx
Normal file
12
src/pages/auth/sign-up.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { NextPage } from "next";
|
||||
|
||||
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
|
||||
import AuthPage from "../../components/auth/auth-page";
|
||||
|
||||
const SignUp: NextPage = () => {
|
||||
return <AuthPage authType="signUp" />;
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
|
||||
export const getServerSideProps = withPageAuthNotRequired();
|
37
src/pages/calls.tsx
Normal file
37
src/pages/calls.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import Layout from "../components/layout";
|
||||
import useUser from "../hooks/use-user";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const pageTitle = "Calls";
|
||||
|
||||
const Calls: NextPage<Props> = (props) => {
|
||||
const { userProfile } = useUser();
|
||||
|
||||
console.log("userProfile", userProfile);
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Calls page</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async (context, user) => {
|
||||
return {
|
||||
props: { userId: user.id, ddd: 23 as const },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Calls;
|
268
src/pages/index.tsx
Normal file
268
src/pages/index.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import type { NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Logo from "../components/logo";
|
||||
|
||||
const Index: NextPage = () => {
|
||||
return (
|
||||
<section className="bg-white">
|
||||
<Hero />
|
||||
<Features />
|
||||
<Newsletter />
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function Hero() {
|
||||
return (
|
||||
<section className="bg-primary-700 bg-opacity-5">
|
||||
<header className="max-w-screen-lg mx-auto px-3 py-6">
|
||||
<div className="flex flex-wrap justify-between items-center">
|
||||
<Link href="/">
|
||||
<a>
|
||||
<Logo className="h-8 w-8" />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center justify-end flex-1 w-0">
|
||||
<Link href="/auth/sign-in">
|
||||
<a className="whitespace-nowrap text-base font-medium text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out">
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/auth/sign-up">
|
||||
<a className="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out">
|
||||
Sign up
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-screen-lg mx-auto px-3 pt-16 pb-24 text-center">
|
||||
<h2 className="text-5xl tracking-tight font-extrabold text-gray-900">
|
||||
Welcome to your
|
||||
<br />
|
||||
<span className="text-primary-600">serverless</span> web app
|
||||
</h2>
|
||||
<p className="mt-3 text-lg text-gray-800 sm:mt-5 sm:max-w-xl sm:mx-auto">
|
||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure
|
||||
qui lorem cupidatat commodo. Elit sunt amet fugiat veniam
|
||||
occaecat fugiat aliqua.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:flex-row-reverse sm:justify-center">
|
||||
<div className="rounded-md shadow">
|
||||
<Link href="/auth/sign-up">
|
||||
<a className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out md:py-4 md:text-lg">
|
||||
Create an account
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/auth/sign-in">
|
||||
<a className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out md:py-4 md:text-lg">
|
||||
I'm already a user
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Features() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<div className="max-w-screen-lg mx-auto space-y-32 px-3">
|
||||
<div className="text-center">
|
||||
<h3 className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10">
|
||||
A better way to bootstrap your app
|
||||
</h3>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-7 text-gray-600 lg:mx-auto">
|
||||
Lorem ipsum dolor sit amet consect adipisicing elit.
|
||||
Possimus magnam voluptatum cupiditate veritatis in
|
||||
accusamus quisquam.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Feature
|
||||
title="Feature #1"
|
||||
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
|
||||
illustration="/static/illustrations/support-team.svg"
|
||||
/>
|
||||
|
||||
<Feature
|
||||
title="Feature #2"
|
||||
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
|
||||
illustration="/static/illustrations/data-analytics.svg"
|
||||
isReversed
|
||||
/>
|
||||
|
||||
<Feature
|
||||
title="Feature #3"
|
||||
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
|
||||
illustration="/static/illustrations/learn-coding.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FeatureProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
illustration: string;
|
||||
isReversed?: true;
|
||||
};
|
||||
|
||||
function Feature({
|
||||
title,
|
||||
description,
|
||||
illustration,
|
||||
isReversed,
|
||||
}: FeatureProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col-reverse items-center justify-between",
|
||||
isReversed && "md:flex-row-reverse",
|
||||
!isReversed && "md:flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-center">
|
||||
<h3 className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-6 text-lg leading-9 text-gray-800">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-96 h-60">
|
||||
<Image
|
||||
src={illustration}
|
||||
layout="fill"
|
||||
alt={`Feature ${title} illustration`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Newsletter() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitted, isSubmitting },
|
||||
} = useForm<{ email: string }>();
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email }) => {
|
||||
try {
|
||||
const { default: axios } = await import("axios");
|
||||
await axios.post("/api/newsletter/subscribe", { email });
|
||||
setValue("email", "");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-primary-700 bg-opacity-5">
|
||||
<div className="max-w-screen-lg mx-auto px-3 py-16 xl:flex xl:items-center">
|
||||
<div className="xl:w-0 xl:flex-1">
|
||||
<h2 className="text-3xl font-extrabold tracking-tight">
|
||||
Want to know when we launch?
|
||||
</h2>
|
||||
<p className="mt-3 max-w-3xl text-lg leading-6 text-gray-600">
|
||||
Lorem ipsum, dolor sit amet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:w-full sm:max-w-md xl:mt-0 xl:ml-8">
|
||||
{isSubmitting || isSubmitted ? (
|
||||
<span className="text-green-600">
|
||||
Thanks! We'll let you know when we launch
|
||||
</span>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="sm:flex">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete=""
|
||||
className="w-full border-gray-300 px-5 py-3 placeholder-gray-500 rounded-md"
|
||||
placeholder="Email address"
|
||||
{...register("email")}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 w-full flex items-center justify-center px-5 py-3 border border-transparent shadow text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out sm:mt-0 sm:ml-3 sm:w-auto sm:flex-shrink-0"
|
||||
>
|
||||
💌 Notify me
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8">
|
||||
<div className="flex justify-center md:order-2">
|
||||
<a
|
||||
href="https://twitter.com/m5r_m"
|
||||
className="ml-6 text-gray-500 hover:text-gray-600"
|
||||
>
|
||||
<span className="sr-only">Twitter</span>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/m5r"
|
||||
className="ml-6 text-gray-500 hover:text-gray-600"
|
||||
>
|
||||
<span className="sr-only">GitHub</span>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-8 md:mt-0 md:order-1">
|
||||
<p className="text-center text-base leading-6 text-gray-600">
|
||||
© 2021{" "}
|
||||
<a
|
||||
href="https://www.capsulecorp.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Capsule Corp.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
129
src/pages/keypad.tsx
Normal file
129
src/pages/keypad.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import type { FunctionComponent } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBackspace, faPhoneAlt as faPhone } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import Layout from "../components/layout";
|
||||
import useUser from "../hooks/use-user";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const pageTitle = "Keypad";
|
||||
|
||||
const Keypad: NextPage<Props> = () => {
|
||||
const { userProfile } = useUser();
|
||||
const phoneNumber = useAtom(phoneNumberAtom)[0];
|
||||
const pressBackspace = useAtom(pressBackspaceAtom)[1];
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
|
||||
<div className="h-16 text-3xl text-gray-700">
|
||||
<span>{phoneNumber}</span>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<Row>
|
||||
<Digit digit="1" />
|
||||
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
|
||||
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
|
||||
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
|
||||
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
|
||||
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
|
||||
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="*" />
|
||||
<ZeroDigit />
|
||||
<Digit digit="#" />
|
||||
</Row>
|
||||
<Row>
|
||||
<div
|
||||
className="col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
||||
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
|
||||
</div>
|
||||
<div className="my-auto" onClick={pressBackspace}>
|
||||
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
||||
</div>
|
||||
</Row>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const ZeroDigit: FunctionComponent = () => {
|
||||
return (
|
||||
<div className="text-3xl cursor-pointer">
|
||||
0 <DigitLetters>+</DigitLetters>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Row: FunctionComponent = ({ children }) => (
|
||||
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => {
|
||||
const pressDigit = useAtom(pressDigitAtom)[1];
|
||||
const onClick = () => pressDigit(digit);
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="text-3xl cursor-pointer">
|
||||
{digit}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DigitLetters: FunctionComponent = ({ children }) => (
|
||||
<div className="text-xs text-gray-600">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const phoneNumberAtom = atom("");
|
||||
const pressDigitAtom = atom(
|
||||
null,
|
||||
(get, set, digit) => {
|
||||
if (get(phoneNumberAtom).length > 17) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(phoneNumberAtom, prevState => prevState + digit);
|
||||
},
|
||||
);
|
||||
const pressBackspaceAtom = atom(
|
||||
null,
|
||||
(get, set) => {
|
||||
if (get(phoneNumberAtom).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(phoneNumberAtom, prevState => prevState.slice(0, -1));
|
||||
},
|
||||
);
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async (context, user) => {
|
||||
return {
|
||||
props: { userId: user.id, ddd: 23 as const },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Keypad;
|
83
src/pages/messages.tsx
Normal file
83
src/pages/messages.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import Layout from "../components/layout";
|
||||
import useUser from "../hooks/use-user";
|
||||
import type { Sms } from "../database/_types";
|
||||
import { SmsType } from "../database/_types";
|
||||
import { findCustomerMessages } from "../database/sms";
|
||||
import { findCustomer } from "../database/customer";
|
||||
import { decrypt } from "../database/_encryption";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const pageTitle = "Messages";
|
||||
|
||||
const Messages: NextPage<Props> = ({ conversations }) => {
|
||||
const { userProfile } = useUser();
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
console.log("conversations", conversations);
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Messages page</p>
|
||||
<ul>
|
||||
{Object.entries(conversations).map(([recipient, conversation]) => {
|
||||
const lastMessage = conversation[conversation.length - 1];
|
||||
return (
|
||||
<li key={recipient}>
|
||||
<Link href={`/messages/${recipient}`}>
|
||||
<a>
|
||||
<div>{recipient}</div>
|
||||
<div>{lastMessage.content}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
type Recipient = string;
|
||||
export type Conversation = Record<Recipient, Sms[]>;
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
const messages = await findCustomerMessages(user.id);
|
||||
const conversations = messages.reduce<Conversation>((acc, message) => {
|
||||
let recipient: string;
|
||||
if (message.type === SmsType.SENT) {
|
||||
recipient = message.to;
|
||||
} else {
|
||||
recipient = message.from;
|
||||
}
|
||||
|
||||
if (!acc[recipient]) {
|
||||
acc[recipient] = [];
|
||||
}
|
||||
|
||||
acc[recipient].push({
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
props: { conversations },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Messages;
|
83
src/pages/messages/[recipient].tsx
Normal file
83
src/pages/messages/[recipient].tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import Layout from "../../components/layout";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import { findConversation } from "../../database/sms";
|
||||
import { decrypt } from "../../database/_encryption";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
import type { Sms } from "../../database/_types";
|
||||
import { SmsType } from "../../database/_types";
|
||||
|
||||
type Props = {
|
||||
recipient: string;
|
||||
conversation: Sms[];
|
||||
};
|
||||
|
||||
const Messages: NextPage<Props> = ({ conversation }) => {
|
||||
const { userProfile } = useUser();
|
||||
const router = useRouter();
|
||||
const pageTitle = `Messages with ${router.query.recipient}`;
|
||||
|
||||
console.log("userProfile", userProfile);
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<header className="flex">
|
||||
<span className="flex items-center cursor-pointer" onClick={router.back}>
|
||||
<FontAwesomeIcon className="h-8 w-8" icon={faChevronLeft} /> Back
|
||||
</span>
|
||||
</header>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ul>
|
||||
{conversation.map(message => {
|
||||
return (
|
||||
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
|
||||
{message.content}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired<Props>(
|
||||
async (context, user) => {
|
||||
const recipient = context.params?.recipient;
|
||||
if (!recipient || Array.isArray(recipient)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/messages",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const customer = await findCustomer(user.id);
|
||||
const conversation = await findConversation(user.id, recipient);
|
||||
console.log("conversation", conversation);
|
||||
|
||||
console.log("recipient", recipient);
|
||||
return {
|
||||
props: {
|
||||
recipient,
|
||||
conversation: conversation.map(message => ({
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey),
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Messages;
|
57
src/pages/settings/account.tsx
Normal file
57
src/pages/settings/account.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import type { NextPage } from "next";
|
||||
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
import SettingsLayout from "../../components/settings/settings-layout";
|
||||
import Alert from "../../components/alert";
|
||||
import ProfileInformations from "../../components/settings/profile-informations";
|
||||
import Divider from "../../components/divider";
|
||||
import UpdatePassword from "../../components/settings/update-password";
|
||||
import DangerZone from "../../components/settings/danger-zone";
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
|
||||
const Account: NextPage = () => {
|
||||
const user = useUser();
|
||||
|
||||
if (user.isLoading) {
|
||||
return <SettingsLayout>Loading...</SettingsLayout>;
|
||||
}
|
||||
|
||||
if (user.error !== null) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Alert
|
||||
title="Oops, there was an issue"
|
||||
message={user.error.message}
|
||||
variant="error"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ProfileInformations />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<UpdatePassword />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired();
|
||||
|
||||
export default Account;
|
106
src/pages/settings/billing.tsx
Normal file
106
src/pages/settings/billing.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import type { FunctionComponent, MouseEventHandler } from "react";
|
||||
import type { NextPage } from "next";
|
||||
import { ExternalLinkIcon } from "@heroicons/react/outline";
|
||||
|
||||
import SettingsLayout from "../../components/settings/settings-layout";
|
||||
import SettingsSection from "../../components/settings/settings-section";
|
||||
import BillingPlans from "../../components/billing/billing-plans";
|
||||
import Divider from "../../components/divider";
|
||||
|
||||
import useSubscription from "../../hooks/use-subscription";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import type { Subscription } from "../../database/subscriptions";
|
||||
import { findUserSubscription } from "../../database/subscriptions";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ page: "/account/settings/billing" });
|
||||
|
||||
type Props = {
|
||||
subscription: Subscription | null;
|
||||
};
|
||||
|
||||
const Billing: NextPage<Props> = ({ subscription }) => {
|
||||
/*
|
||||
TODO: I want to be able to
|
||||
- renew subscription (after pause/cancel for example) (message like "your subscription expired, would you like to renew ?")
|
||||
- know when is the last time I paid and for how much
|
||||
- know when is the next time I will pay and for how much
|
||||
*/
|
||||
const { cancelSubscription, updatePaymentMethod } = useSubscription();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
{subscription ? (
|
||||
<>
|
||||
<SettingsSection title="Payment method">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
updatePaymentMethod({
|
||||
updateUrl: subscription.updateUrl,
|
||||
})
|
||||
}
|
||||
text="Update payment method on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Plan">
|
||||
<BillingPlans activePlanId={subscription?.planId} />
|
||||
</SettingsSection>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Cancel subscription">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
cancelSubscription({
|
||||
cancelUrl: subscription.cancelUrl,
|
||||
})
|
||||
}
|
||||
text="Cancel subscription on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
) : (
|
||||
<SettingsSection title="Plan">
|
||||
<BillingPlans />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
|
||||
type PaddleLinkProps = {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PaddleLink: FunctionComponent<PaddleLinkProps> = ({ onClick, text }) => (
|
||||
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
|
||||
<ExternalLinkIcon className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="transition-colors duration-150 border-b border-transparent hover:border-primary-500">
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired<Props>(
|
||||
async (context, user) => {
|
||||
// const subscription = await findUserSubscription({ userId: user.id });
|
||||
|
||||
return {
|
||||
props: { subscription: null },
|
||||
};
|
||||
},
|
||||
);
|
52
src/pages/settings/index.tsx
Normal file
52
src/pages/settings/index.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCreditCard, faUserCircle } from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
import Layout from "../../components/layout";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import appLogger from "../../../lib/logger";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const logger = appLogger.child({ page: "/account/settings" });
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Account",
|
||||
href: "/settings/account",
|
||||
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />
|
||||
},
|
||||
];
|
||||
|
||||
const Settings: NextPage<Props> = (props) => {
|
||||
return (
|
||||
<Layout title="Settings">
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<aside className="py-6 lg:col-span-3">
|
||||
<nav className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className='border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium'
|
||||
>
|
||||
<item.icon className='text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6' />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired();
|
||||
|
||||
export default Settings;
|
22
src/pages/welcome/step-one.tsx
Normal file
22
src/pages/welcome/step-one.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { NextPage } from "next";
|
||||
|
||||
import { withPageAuthRequired } from "../../../lib/session-helpers";
|
||||
|
||||
import OnboardingLayout from "../../components/welcome/onboarding-layout";
|
||||
|
||||
const StepOne: NextPage = () => {
|
||||
return (
|
||||
<OnboardingLayout
|
||||
currentStep={1}
|
||||
next={{ href: "/welcome/step-two", label: "Set up your phone number" }}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<span>Welcome, let’s set up your virtual phone!</span>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageAuthRequired();
|
||||
|
||||
export default StepOne;
|
107
src/pages/welcome/step-three.tsx
Normal file
107
src/pages/welcome/step-three.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import twilio from "twilio";
|
||||
import { useForm } from "react-hook-form";
|
||||
import axios from "axios";
|
||||
|
||||
import { withPageAuthRequired } from "../../../lib/session-helpers";
|
||||
|
||||
import OnboardingLayout from "../../components/welcome/onboarding-layout";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
type Form = {
|
||||
phoneNumberSid: string;
|
||||
}
|
||||
|
||||
const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumbers }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Form>();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setValue("phoneNumberSid", availablePhoneNumbers[0].sid);
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async ({ phoneNumberSid }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post("/api/user/add-phone-number", { phoneNumberSid }, { withCredentials: true });
|
||||
await router.push("/messages");
|
||||
});
|
||||
|
||||
if (!hasTwilioCredentials) {
|
||||
return (
|
||||
<OnboardingLayout
|
||||
currentStep={3}
|
||||
previous={{ href: "/welcome/step-two", label: "Back" }}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<span>You don't have any phone number, fill your Twilio credentials first</span>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
currentStep={3}
|
||||
previous={{ href: "/welcome/step-two", label: "Back" }}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
|
||||
Phone number
|
||||
</label>
|
||||
<select
|
||||
id="phoneNumberSid"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
{...register("phoneNumberSid")}
|
||||
>
|
||||
{availablePhoneNumbers.map(({ sid, phoneNumber }) => (
|
||||
<option value={sid} key={sid}>{phoneNumber}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"max-w-[240px] mt-6 mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
|
||||
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
|
||||
isSubmitting && "bg-primary-400 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
const hasTwilioCredentials = customer.accountSid.length > 0 && customer.authToken.length > 0;
|
||||
const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken)
|
||||
.incomingPhoneNumbers
|
||||
.list();
|
||||
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
|
||||
|
||||
return {
|
||||
props: {
|
||||
hasTwilioCredentials,
|
||||
availablePhoneNumbers: phoneNumbers,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default StepThree;
|
104
src/pages/welcome/step-two.tsx
Normal file
104
src/pages/welcome/step-two.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import axios from "axios";
|
||||
|
||||
import { withPageAuthRequired } from "../../../lib/session-helpers";
|
||||
import OnboardingLayout from "../../components/welcome/onboarding-layout";
|
||||
import clsx from "clsx";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
type Form = {
|
||||
twilioAccountSid: string;
|
||||
twilioAuthToken: string;
|
||||
}
|
||||
|
||||
const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Form>();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setValue("twilioAuthToken", authToken);
|
||||
setValue("twilioAccountSid", accountSid);
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post("/api/user/update-user", {
|
||||
twilioAccountSid,
|
||||
twilioAuthToken,
|
||||
}, { withCredentials: true });
|
||||
await router.push("/welcome/step-three");
|
||||
});
|
||||
const hasTwilioCredentials = accountSid.length > 0 && authToken.length > 0;
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
currentStep={2}
|
||||
next={hasTwilioCredentials ? { href: "/welcome/step-three", label: "Next" } : undefined}
|
||||
previous={{ href: "/welcome/step-one", label: "Back" }}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||
<div className="w-full">
|
||||
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
|
||||
Twilio Account SID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="twilioAccountSid"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
{...register("twilioAccountSid", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
|
||||
Twilio Auth Token
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="twilioAuthToken"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
{...register("twilioAuthToken", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
|
||||
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
|
||||
isSubmitting && "bg-primary-400 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
|
||||
return {
|
||||
props: {
|
||||
accountSid: customer.accountSid ?? "",
|
||||
authToken: customer.authToken ?? "",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default StepTwo;
|
105
src/session-context.tsx
Normal file
105
src/session-context.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { Dispatch, ReactNode, Reducer, ReducerAction } from "react";
|
||||
import { createContext, useEffect, useReducer } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import supabase from "./supabase/client";
|
||||
|
||||
type Context = {
|
||||
state: SessionState;
|
||||
dispatch: Dispatch<ReducerAction<typeof sessionReducer>>;
|
||||
};
|
||||
|
||||
export const SessionContext = createContext<Context>(null as any);
|
||||
|
||||
type ProviderProps = {
|
||||
children: ReactNode;
|
||||
user?: User | null;
|
||||
};
|
||||
|
||||
function getInitialState(initialUser: User | null | undefined): SessionState {
|
||||
if (!initialUser) {
|
||||
return {
|
||||
state: "LOADING",
|
||||
user: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "SUCCESS",
|
||||
user: initialUser,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionProvider({ children, user }: ProviderProps) {
|
||||
const [state, dispatch] = useReducer(
|
||||
sessionReducer,
|
||||
getInitialState(user),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log("event", event);
|
||||
if (["SIGNED_IN", "USER_UPDATED"].includes(event)) {
|
||||
dispatch({
|
||||
type: "SET_SESSION",
|
||||
user: session!.user!,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (state.user === null) {
|
||||
dispatch({
|
||||
type: "SET_SESSION",
|
||||
user: supabase.auth.user()!,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ state, dispatch }}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionState =
|
||||
| {
|
||||
state: "LOADING";
|
||||
user: null;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
state: "SUCCESS";
|
||||
user: User;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
state: "ERROR";
|
||||
user: User | null;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SESSION"; user: User }
|
||||
| { type: "THROW_ERROR"; error: Error };
|
||||
|
||||
const sessionReducer: Reducer<SessionState, Action> = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "SET_SESSION":
|
||||
return {
|
||||
...state,
|
||||
state: "SUCCESS",
|
||||
user: action.user,
|
||||
error: null,
|
||||
};
|
||||
case "THROW_ERROR":
|
||||
return {
|
||||
...state,
|
||||
state: "ERROR",
|
||||
error: action.error,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
};
|
51
src/subscription/_paddle-api.ts
Normal file
51
src/subscription/_paddle-api.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import axios from "axios";
|
||||
import getConfig from "next/config";
|
||||
|
||||
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
|
||||
|
||||
const vendor_id = publicRuntimeConfig.paddle.vendorId;
|
||||
const vendor_auth_code = serverRuntimeConfig.paddle.apiKey;
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "https://vendors.paddle.com/api/2.0",
|
||||
});
|
||||
|
||||
async function request<T>(path: string, data: any) {
|
||||
return client.post<T>(path, {
|
||||
...data,
|
||||
vendor_id,
|
||||
vendor_auth_code,
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateSubscriptionPlanParams = {
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
prorate?: boolean;
|
||||
};
|
||||
|
||||
export async function updateSubscriptionPlan({
|
||||
subscriptionId,
|
||||
planId,
|
||||
prorate = true,
|
||||
}: UpdateSubscriptionPlanParams) {
|
||||
const { data } = await request("/subscription/users/update", {
|
||||
subscription_id: subscriptionId,
|
||||
plan_id: planId,
|
||||
prorate,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelPaddleSubscription({
|
||||
subscriptionId,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
}) {
|
||||
const { data } = await request("/subscription/users_cancel", {
|
||||
subscription_id: subscriptionId,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
61
src/subscription/plans.ts
Normal file
61
src/subscription/plans.ts
Normal file
@ -0,0 +1,61 @@
|
||||
export type PlanId = string;
|
||||
|
||||
export type PaidPlan = {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: readonly string[];
|
||||
};
|
||||
|
||||
export type FreePlan = Omit<PaidPlan, "id" | "billingCycle" | "price"> & {
|
||||
id: "free";
|
||||
billingCycle: null;
|
||||
price: "free";
|
||||
};
|
||||
|
||||
export type Plan = FreePlan | PaidPlan;
|
||||
|
||||
export const FREE: FreePlan = {
|
||||
id: "free",
|
||||
billingCycle: null,
|
||||
name: "Free",
|
||||
description: "Try out our software",
|
||||
price: "free",
|
||||
features: [
|
||||
"Potenti felis, in cras at at ligula nunc.",
|
||||
"Orci neque eget pellentesque.",
|
||||
],
|
||||
};
|
||||
|
||||
export const MONTHLY: PaidPlan = {
|
||||
id: "647654",
|
||||
name: "Monthly",
|
||||
description: "All the basics for starting a new business",
|
||||
price: 21,
|
||||
features: [
|
||||
"Potenti felis, in cras at at ligula nunc.",
|
||||
"Orci neque eget pellentesque.",
|
||||
"Donec mauris sit in eu tincidunt etiam.",
|
||||
],
|
||||
};
|
||||
|
||||
export const ANNUALLY: PaidPlan = {
|
||||
id: "647656",
|
||||
name: "Annually",
|
||||
description: "All the basics for starting a new business",
|
||||
price: 19,
|
||||
features: [
|
||||
"Potenti felis, in cras at at ligula nunc.",
|
||||
"Orci neque eget pellentesque.",
|
||||
"Donec mauris sit in eu tincidunt etiam.",
|
||||
],
|
||||
};
|
||||
|
||||
export const PLANS = {
|
||||
[FREE.id]: FREE,
|
||||
[MONTHLY.id]: MONTHLY,
|
||||
[ANNUALLY.id]: ANNUALLY,
|
||||
};
|
||||
|
||||
export type PlanName = Lowercase<Plan["name"]>;
|
10
src/supabase/client.ts
Normal file
10
src/supabase/client.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import getConfig from "next/config";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const { supabase: { url, anonKey } } = publicRuntimeConfig;
|
||||
|
||||
const supabase = createClient(url, anonKey);
|
||||
|
||||
export default supabase;
|
11
src/supabase/server.ts
Normal file
11
src/supabase/server.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import getConfig from "next/config";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
|
||||
|
||||
const { supabase: { url } } = publicRuntimeConfig;
|
||||
const { supabase: { roleKey } } = serverRuntimeConfig;
|
||||
|
||||
const supabase = createClient(url, roleKey);
|
||||
|
||||
export default supabase;
|
3
src/tailwind.css
Normal file
3
src/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
Reference in New Issue
Block a user