get rid of onboarding requirements

This commit is contained in:
m5r 2021-10-16 00:24:28 +02:00
parent c8f707af9c
commit 3cc6f35071
33 changed files with 291 additions and 142 deletions

View File

@ -40,16 +40,9 @@ export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ct
const user = await authenticateUser(email, password);
const organization = user.memberships[0]!.organization;
const hasCompletedOnboarding =
Boolean(organization.twilioAccountSid) &&
Boolean(organization.twilioAuthToken) &&
Boolean(organization.twilioApiKey) &&
Boolean(organization.twilioApiSecret) &&
Boolean(organization.phoneNumbers.length > 1);
await ctx.session.$create({
userId: user.id,
roles: [user.role, user.memberships[0]!.role],
hasCompletedOnboarding: hasCompletedOnboarding || undefined,
orgId: organization.id,
});

View File

@ -31,6 +31,7 @@ export default resolver.pipe(resolver.zod(Signup), async ({ email, password, ful
userId: user.id,
roles: [user.role, user.memberships[0]!.role],
orgId: user.memberships[0]!.organizationId,
shouldShowWelcomeMessage: true,
});
return user;
});

View File

@ -29,7 +29,7 @@ const SignUp: BlitzPage = () => {
onSubmit={async (values) => {
try {
await signupMutation(values);
router.push(Routes.StepOne());
await router.push(Routes.Welcome());
} catch (error: any) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma
@ -47,7 +47,13 @@ const SignUp: BlitzPage = () => {
);
};
SignUp.redirectAuthenticatedTo = Routes.StepOne();
SignUp.redirectAuthenticatedTo = ({ session }) => {
if (session.shouldShowWelcomeMessage) {
return Routes.Welcome();
}
return Routes.Messages();
};
SignUp.getLayout = (page) => <BaseLayout title="Sign Up">{page}</BaseLayout>;

View File

@ -0,0 +1,28 @@
import type { BlitzPage, GetServerSideProps } from "blitz";
import { getSession, Routes, useRouter } from "blitz";
const Welcome: BlitzPage = () => {
const router = useRouter();
return (
<div>
<p>Thanks for joining Shellphone</p>
<p>Let us know if you need our help</p>
<p>Make sure to set up your phone number</p>
<button onClick={() => router.push(Routes.Messages())}>Open my phone</button>
</div>
);
};
Welcome.authenticate = { redirectTo: Routes.SignIn() };
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const session = await getSession(req, res);
await session.$setPublicData({ shouldShowWelcomeMessage: undefined });
return {
props: {},
};
};
export default Welcome;

View File

@ -0,0 +1,28 @@
import { Routes, useRouter } from "blitz";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function MissingTwilioCredentials() {
const router = useRouter();
return (
<div className="text-center my-auto">
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
<h3 className="mt-2 text-sm font-medium text-gray-900">You haven&#39;t set up any phone number yet</h3>
<p className="mt-1 text-sm text-gray-500">
Head over to your settings
<br />
to set up your phone number.
</p>
<div className="mt-6">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => router.push(Routes.PhoneSettings())}
>
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Set up my phone number
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import type { FunctionComponent } from "react";
import clsx from "clsx";
type Props = {
className?: string;
title: string;
};
const PageTitle: FunctionComponent<Props> = ({ className, title }) => {
return (
<div className={clsx(className, "flex flex-col space-y-6 p-3")}>
<h2 className="text-3xl font-bold">{title}</h2>
</div>
);
};
export default PageTitle;

View File

@ -9,7 +9,9 @@ export default function useCurrentUser() {
return {
user,
organization,
hasFilledTwilioCredentials: Boolean(user && organization?.twilioAccountSid && organization?.twilioAuthToken),
hasCompletedOnboarding: session.hasCompletedOnboarding,
hasFilledTwilioCredentials: Boolean(
organization && organization.twilioAccountSid && organization.twilioAuthToken,
),
hasActiveSubscription: organization && organization.subscriptions.length > 0,
};
}

View File

@ -1,22 +0,0 @@
import { Routes, useRouter } from "blitz";
import useCurrentUser from "./use-current-user";
import useCurrentPhoneNumber from "./use-current-phone-number";
export default function useRequireOnboarding() {
const router = useRouter();
const { hasFilledTwilioCredentials, hasCompletedOnboarding } = useCurrentUser();
const phoneNumber = useCurrentPhoneNumber();
if (hasCompletedOnboarding) {
return;
}
if (!hasFilledTwilioCredentials) {
throw router.push(Routes.StepTwo());
}
if (!phoneNumber) {
throw router.push(Routes.StepThree());
}
}

View File

@ -32,7 +32,7 @@ function NavLink({ path, label, icon }: NavLinkProps) {
<Link href={path}>
<a
className={clsx("flex flex-col items-center", {
"text-[#007AFF]": isActiveRoute,
"text-primary-500": isActiveRoute,
"text-[#959595]": !isActiveRoute,
})}
>

View File

@ -19,7 +19,7 @@ export default function EmptyMessages() {
<div className="mt-6">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-[#007AFF] focus:outline-none focus:ring-2 focus:ring-offset-2"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={openNewMessageArea}
>
<IoCreateOutline className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />

View File

@ -4,14 +4,16 @@ import type { BlitzPage } from "blitz";
import { Routes } from "blitz";
import { atom, useAtom } from "jotai";
import Layout from "../../core/layouts/layout";
import Layout from "app/core/layouts/layout";
import ConversationsList from "../components/conversations-list";
import NewMessageButton from "../components/new-message-button";
import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
import useNotifications from "../../core/hooks/use-notifications";
import MissingTwilioCredentials from "app/core/components/missing-twilio-credentials";
import useNotifications from "app/core/hooks/use-notifications";
import useCurrentUser from "app/core/hooks/use-current-user";
import PageTitle from "../../core/components/page-title";
const Messages: BlitzPage = () => {
useRequireOnboarding();
const { hasFilledTwilioCredentials } = useCurrentUser();
const { subscription, subscribe } = useNotifications();
const setIsOpen = useAtom(bottomSheetOpenAtom)[1];
@ -21,11 +23,18 @@ const Messages: BlitzPage = () => {
}
}, [subscribe, subscription]);
if (!hasFilledTwilioCredentials) {
return (
<>
<div className="flex flex-col space-y-6 p-3">
<h2 className="text-3xl font-bold">Messages</h2>
</div>
<MissingTwilioCredentials />
<PageTitle className="filter blur-sm absolute top-0" title="Messages" />
</>
);
}
return (
<>
<PageTitle title="Messages" />
<section className="flex flex-grow flex-col">
<Suspense fallback="Loading...">
<ConversationsList />

View File

@ -5,12 +5,9 @@ import { IoChevronBack, IoInformationCircle, IoCall } from "react-icons/io5";
import Layout from "../../../core/layouts/layout";
import Conversation from "../../components/conversation";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
import useConversation from "../../hooks/use-conversation";
const ConversationPage: BlitzPage = () => {
useRequireOnboarding();
const router = useRouter();
const recipient = decodeURIComponent(router.params.recipient);
const pageTitle = `Messages with ${recipient}`;

View File

@ -22,12 +22,11 @@ export default resolver.pipe(
where: { id: organizationId },
include: { phoneNumbers: true },
});
if (!organization) {
if (!organization || !organization.phoneNumbers[0]) {
throw new NotFoundError();
}
const phoneNumberId = organization.phoneNumbers[0]!.id;
const phoneNumberId = organization.phoneNumbers[0].id;
const processingState = await db.processingPhoneNumber.findFirst({ where: { organizationId, phoneNumberId } });
if (processingState && !processingState.hasFetchedMessages) {
return;

View File

@ -1,7 +1,7 @@
import type { FunctionComponent } from "react";
import { useRef } from "react";
import Modal, { ModalTitle } from "../../settings/components/modal";
import Modal, { ModalTitle } from "app/core/components/modal";
type Props = {
isHelpModalOpen: boolean;

View File

@ -30,7 +30,6 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
number: phoneNumber.phoneNumber,
},
});
context.session.$setPrivateData({ hasCompletedOnboarding: true });
const mainTwilioClient = twilio(organization.twilioAccountSid, organization.twilioAuthToken);
const apiKey = await mainTwilioClient.newKeys.create({ friendlyName: "Shellphone API key" });

View File

@ -40,7 +40,6 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
await session.$setPublicData({ hasCompletedOnboarding: true });
return {
redirect: {
destination: Routes.Messages().pathname,

View File

@ -102,7 +102,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
await session.$setPublicData({ hasCompletedOnboarding: true });
return {
redirect: {
destination: Routes.Messages().pathname,

View File

@ -140,7 +140,6 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
await session.$setPublicData({ hasCompletedOnboarding: true });
return {
redirect: {
destination: Routes.Messages().pathname,

View File

@ -22,7 +22,7 @@ export default function EmptyMessages() {
<p className="mt-1 text-sm text-gray-500">Get started by calling someone you know.</p>
<div className="mt-6">
<Link href={Routes.KeypadPage()}>
<a className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-[#007AFF] focus:outline-none focus:ring-2 focus:ring-offset-2">
<a className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2">
<IoKeypad className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Open keypad
</a>

View File

@ -0,0 +1,53 @@
import type { FunctionComponent } from "react";
import { useRef } from "react";
import { Link, Routes, useRouter } from "blitz";
import Modal, { ModalTitle } from "app/core/components/modal";
type Props = {
isOpen: boolean;
closeModal: () => void;
};
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
const router = useRouter();
return (
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} 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>Woah, hold on! Set up your &#128026;phone number first</ModalTitle>
<div className="mt-2 text-gray-500">
<p>
First things first. Head over to your{" "}
<Link href={Routes.PhoneSettings()}>
<a className="underline">phone settings</a>
</Link>{" "}
to set up your phone number.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
ref={openSettingsButtonRef}
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm"
onClick={() => router.push(Routes.PhoneSettings())}
>
Take me there
</button>
<button
type="button"
className="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"
onClick={closeModal}
>
I got it, thanks!
</button>
</div>
</Modal>
);
};
export default KeypadErrorModal;

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { useMutation } from "blitz";
import { NotFoundError, Routes, useMutation, useRouter } from "blitz";
import type { TwilioError } from "@twilio/voice-sdk";
import { Call, Device } from "@twilio/voice-sdk";
import getToken from "../mutations/get-token";
import appLogger from "../../../integrations/logger";
import appLogger from "integrations/logger";
const logger = appLogger.child({ module: "use-device" });
export default function useDevice() {
const router = useRouter();
const [device, setDevice] = useState<Device | null>(null);
const [isDeviceReady, setIsDeviceReady] = useState(() => device?.state === Device.State.Registered);
const [getTokenMutation] = useMutation(getToken);
@ -18,9 +19,17 @@ export default function useDevice() {
return;
}
try {
const token = await getTokenMutation();
device.updateToken(token);
}, [device, getTokenMutation]);
} catch (error) {
if (error instanceof NotFoundError) {
throw router.push(Routes.KeypadPage());
}
throw error;
}
}, [device, getTokenMutation, router]);
useEffect(() => {
const intervalId = setInterval(() => {
@ -31,6 +40,7 @@ export default function useDevice() {
useEffect(() => {
(async () => {
try {
const token = await getTokenMutation();
const device = new Device(token, {
codecPreferences: [Call.Codec.Opus, Call.Codec.PCMU],
@ -40,8 +50,15 @@ export default function useDevice() {
});
device.register();
setDevice(device);
} catch (error) {
if (error instanceof NotFoundError) {
throw router.push(Routes.KeypadPage());
}
throw error;
}
})();
}, [getTokenMutation, setDevice]);
}, [getTokenMutation, setDevice, router]);
useEffect(() => {
if (!device) {

View File

@ -5,9 +5,6 @@ import getPhoneCalls from "../queries/get-phone-calls";
export default function usePhoneCalls() {
const phoneNumber = useCurrentPhoneNumber();
if (!phoneNumber) {
throw new NotFoundError();
}
return useQuery(getPhoneCalls, { phoneNumberId: phoneNumber.id });
return useQuery(getPhoneCalls, { phoneNumberId: phoneNumber?.id as string }, { enabled: Boolean(phoneNumber) });
}

View File

@ -2,18 +2,27 @@ import { Suspense } from "react";
import type { BlitzPage } from "blitz";
import { Routes } from "blitz";
import Layout from "../../core/layouts/layout";
import Layout from "app/core/layouts/layout";
import PhoneCallsList from "../components/phone-calls-list";
import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
import MissingTwilioCredentials from "app/core/components/missing-twilio-credentials";
import useCurrentUser from "app/core/hooks/use-current-user";
import PageTitle from "../../core/components/page-title";
const PhoneCalls: BlitzPage = () => {
useRequireOnboarding();
const { hasFilledTwilioCredentials } = useCurrentUser();
if (!hasFilledTwilioCredentials) {
return (
<>
<MissingTwilioCredentials />
<PageTitle className="filter blur-sm absolute top-0" title="Calls" />
</>
);
}
return (
<>
<div className="flex flex-col space-y-6 py-3 pl-12">
<h2 className="text-3xl font-bold">Calls</h2>
</div>
<PageTitle className="pl-12" title="Calls" />
<section className="flex flex-grow flex-col">
<Suspense fallback="Loading...">
<PhoneCallsList />

View File

@ -1,4 +1,4 @@
import { Fragment, useRef } from "react";
import { Fragment, useRef, useState } from "react";
import type { BlitzPage } from "blitz";
import { Routes, useRouter } from "blitz";
import { atom, useAtom } from "jotai";
@ -7,15 +7,17 @@ import { Transition } from "@headlessui/react";
import { IoBackspace, IoCall } from "react-icons/io5";
import { Direction } from "db";
import Layout from "../../core/layouts/layout";
import Layout from "app/core/layouts/layout";
import Keypad from "../components/keypad";
import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
import usePhoneCalls from "../hooks/use-phone-calls";
import useKeyPress from "../hooks/use-key-press";
import useCurrentUser from "app/core/hooks/use-current-user";
import KeypadErrorModal from "../components/keypad-error-modal";
const KeypadPage: BlitzPage = () => {
useRequireOnboarding();
const { hasFilledTwilioCredentials, hasActiveSubscription } = useCurrentUser();
const router = useRouter();
const [isKeypadErrorModalOpen, setIsKeypadErrorModalOpen] = useState(false);
const [phoneCalls] = usePhoneCalls();
const [phoneNumber, setPhoneNumber] = useAtom(phoneNumberAtom);
const removeDigit = useAtom(pressBackspaceAtom)[1];
@ -74,6 +76,7 @@ const KeypadPage: BlitzPage = () => {
});
return (
<>
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black">
<div className="h-16 text-3xl text-gray-700">
<span>{phoneNumber}</span>
@ -82,6 +85,15 @@ const KeypadPage: BlitzPage = () => {
<Keypad onDigitPressProps={onDigitPressProps} onZeroPressProps={onZeroPressProps}>
<button
onClick={async () => {
if (!hasFilledTwilioCredentials) {
setIsKeypadErrorModalOpen(true);
return;
}
if (!hasActiveSubscription) {
// TODO
}
if (phoneNumber === "") {
const lastCall = phoneCalls?.[0];
if (lastCall) {
@ -117,6 +129,8 @@ const KeypadPage: BlitzPage = () => {
</Transition>
</Keypad>
</div>
<KeypadErrorModal closeModal={() => setIsKeypadErrorModalOpen(false)} isOpen={isKeypadErrorModalOpen} />
</>
);
};

View File

@ -5,14 +5,12 @@ import type { TwilioError } from "@twilio/voice-sdk";
import { atom, useAtom } from "jotai";
import { IoCall } from "react-icons/io5";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
import useMakeCall from "../../hooks/use-make-call";
import useDevice from "../../hooks/use-device";
import Keypad from "../../components/keypad";
const OutgoingCall: BlitzPage = () => {
useRequireOnboarding();
const [phoneNumber, setPhoneNumber] = useAtom(phoneNumberAtom);
const router = useRouter();
const recipient = decodeURIComponent(router.params.recipient);

View File

@ -4,7 +4,7 @@ import clsx from "clsx";
import Button from "../button";
import SettingsSection from "../settings-section";
import Modal, { ModalTitle } from "../modal";
import Modal, { ModalTitle } from "app/core/components/modal";
import deleteUser from "../../mutations/delete-user";
export default function DangerZone() {

View File

@ -6,6 +6,7 @@ import {
IoLogOutOutline,
IoNotificationsOutline,
IoCardOutline,
IoCallOutline,
IoPersonCircleOutline,
} from "react-icons/io5";
@ -15,6 +16,7 @@ import Divider from "./divider";
const subNavigation = [
{ name: "Account", href: Routes.Account(), icon: IoPersonCircleOutline },
{ name: "Phone", href: Routes.PhoneSettings(), icon: IoCallOutline },
{ name: "Billing", href: Routes.Billing(), icon: IoCardOutline },
{ name: "Notifications", href: Routes.Notifications(), icon: IoNotificationsOutline },
];
@ -36,7 +38,7 @@ const SettingsLayout: FunctionComponent = ({ children }) => {
<main className="flex flex-col flex-grow mx-auto w-full max-w-7xl pb-10 lg:py-12 lg:px-8">
<div className="flex flex-col flex-grow lg:grid lg:grid-cols-12 lg:gap-x-5">
<aside className="py-6 px-2 sm:px-6 lg:py-0 lg:px-0 lg:col-span-3">
<nav className="h-full flex flex-col">
<nav className="space-y-1 h-full flex flex-col">
{subNavigation.map((item) => {
const isCurrentPage = item.href.pathname === router.pathname;
@ -47,7 +49,7 @@ const SettingsLayout: FunctionComponent = ({ children }) => {
isCurrentPage
? "bg-gray-50 text-primary-600 hover:bg-white"
: "text-gray-900 hover:text-gray-900 hover:bg-gray-50",
"mb-1 group rounded-md px-3 py-2 flex items-center text-sm font-medium",
"group rounded-md px-3 py-2 flex items-center text-sm font-medium",
)}
aria-current={isCurrentPage ? "page" : undefined}
>
@ -66,7 +68,7 @@ const SettingsLayout: FunctionComponent = ({ children }) => {
);
})}
<Divider className="mt-auto mb-1" />
<Divider />
<button
onClick={() => logoutMutation()}
className="group text-gray-900 hover:text-gray-900 hover:bg-gray-50 rounded-md px-3 py-2 flex items-center text-sm font-medium"

View File

@ -3,7 +3,6 @@ import { GetServerSideProps, getSession, Routes } from "blitz";
import db, { Subscription, SubscriptionStatus } from "db";
import useSubscription from "../../hooks/use-subscription";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
import usePaymentsHistory from "../../hooks/use-payments-history";
import SettingsLayout from "../../components/settings-layout";
import SettingsSection from "../../components/settings-section";
@ -27,7 +26,6 @@ const Billing: BlitzPage<Props> = (props) => {
- resubscribe (message like "your subscription expired, would you like to renew ?")
*/
useRequireOnboarding();
const { count: paymentsCount } = usePaymentsHistory();
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription({
initialData: props.subscription,

View File

@ -5,11 +5,8 @@ import SettingsLayout from "../../components/settings-layout";
import ProfileInformations from "../../components/account/profile-informations";
import UpdatePassword from "../../components/account/update-password";
import DangerZone from "../../components/account/danger-zone";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
const Account: BlitzPage = () => {
useRequireOnboarding();
return (
<div className="flex flex-col space-y-6">
<ProfileInformations />

View File

@ -0,0 +1,14 @@
import type { BlitzPage } from "blitz";
import { Routes } from "blitz";
import SettingsLayout from "../../components/settings-layout";
const PhoneSettings: BlitzPage = () => {
return <div>Coming soon</div>;
};
PhoneSettings.getLayout = (page) => <SettingsLayout>{page}</SettingsLayout>;
PhoneSettings.authenticate = { redirectTo: Routes.SignIn() };
export default PhoneSettings;

View File

@ -1,6 +1,6 @@
import type { Ctx } from "blitz";
import db from "db";
import db, { SubscriptionStatus } from "db";
export default async function getCurrentUser(_ = null, { session }: Ctx) {
if (!session.userId) return null;
@ -15,14 +15,10 @@ export default async function getCurrentUser(_ = null, { session }: Ctx) {
memberships: {
include: {
organization: {
select: {
id: true,
encryptionKey: true,
twilioAccountSid: true,
twilioAuthToken: true,
twilioApiKey: true,
twilioApiSecret: true,
twimlAppSid: true,
include: {
subscriptions: {
where: { status: SubscriptionStatus.active },
},
},
},
},

View File

@ -15,7 +15,7 @@ declare module "blitz" {
userId: User["id"];
roles: Role[];
orgId: Organization["id"];
hasCompletedOnboarding?: true;
shouldShowWelcomeMessage?: true;
};
}
}