make app usable without account, remove extra stuff
This commit is contained in:
@ -1,63 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { type User, TokenType } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { sendForgotPasswordEmail } from "~/mailers/forgot-password-mailer.server";
|
||||
import { generateToken, hashToken } from "~/utils/token.server";
|
||||
import { ForgotPassword } from "../validations";
|
||||
|
||||
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
|
||||
|
||||
type ForgotPasswordFailureActionData = { errors: FormError<typeof ForgotPassword>; submitted?: never };
|
||||
type ForgotPasswordSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type ForgotPasswordActionData = ForgotPasswordFailureActionData | ForgotPasswordSuccessfulActionData;
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(ForgotPassword, formData);
|
||||
if (validation.errors) {
|
||||
return json<ForgotPasswordFailureActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
|
||||
// always wait the same amount of time so attackers can't tell the difference whether a user is found
|
||||
await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]);
|
||||
|
||||
// return the same result whether a password reset email was sent or not
|
||||
return json<ForgotPasswordSuccessfulActionData>({ submitted: true });
|
||||
};
|
||||
|
||||
export default action;
|
||||
|
||||
async function updatePassword(user: User | null) {
|
||||
const membership = await db.membership.findFirst({ where: { userId: user?.id } });
|
||||
if (!user || !membership) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const hashedToken = hashToken(token);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS);
|
||||
|
||||
await db.token.deleteMany({ where: { type: TokenType.RESET_PASSWORD, userId: user.id } });
|
||||
await db.token.create({
|
||||
data: {
|
||||
user: { connect: { id: user.id } },
|
||||
membership: { connect: { id: membership.id } },
|
||||
type: TokenType.RESET_PASSWORD,
|
||||
expiresAt,
|
||||
hashedToken,
|
||||
sentTo: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
await sendForgotPasswordEmail({
|
||||
to: user.email,
|
||||
token,
|
||||
userName: user.fullName,
|
||||
});
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { GlobalRole, MembershipRole } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { authenticate, hashPassword } from "~/utils/auth.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { Register } from "../validations";
|
||||
|
||||
export type RegisterActionData = {
|
||||
errors: FormError<typeof Register>;
|
||||
};
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(Register, formData);
|
||||
if (validation.errors) {
|
||||
return json<RegisterActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const { fullName, email, password } = validation.data;
|
||||
const hashedPassword = await hashPassword(password.trim());
|
||||
try {
|
||||
await db.user.create({
|
||||
data: {
|
||||
fullName: fullName.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
hashedPassword,
|
||||
role: GlobalRole.CUSTOMER,
|
||||
memberships: {
|
||||
create: {
|
||||
role: MembershipRole.OWNER,
|
||||
organization: {
|
||||
create: {}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
|
||||
if (error.code === "P2002") {
|
||||
if (error.meta.target[0] === "email") {
|
||||
return json<RegisterActionData>({
|
||||
errors: { general: "An account with this email address already exists" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json<RegisterActionData>({
|
||||
errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` },
|
||||
});
|
||||
}
|
||||
|
||||
return authenticate({ email, password, request, failureRedirect: "/register" });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,56 +0,0 @@
|
||||
import { type ActionFunction, json, redirect } from "@remix-run/node";
|
||||
import { TokenType } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { authenticate, hashPassword } from "~/utils/auth.server";
|
||||
import { ResetPasswordError } from "~/utils/errors";
|
||||
import { hashToken } from "~/utils/token.server";
|
||||
import { ResetPassword } from "../validations";
|
||||
|
||||
export type ResetPasswordActionData = { errors: FormError<typeof ResetPassword> };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
return redirect("/forgot-password");
|
||||
}
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(ResetPassword, { ...formData, token });
|
||||
if (validation.errors) {
|
||||
return json<ResetPasswordActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const hashedToken = hashToken(token);
|
||||
const savedToken = await db.token.findFirst({
|
||||
where: { hashedToken, type: TokenType.RESET_PASSWORD },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!savedToken) {
|
||||
logger.warn(`No token found with hashedToken=${hashedToken}`);
|
||||
throw new ResetPasswordError();
|
||||
}
|
||||
|
||||
await db.token.delete({ where: { id: savedToken.id } });
|
||||
|
||||
if (savedToken.expiresAt < new Date()) {
|
||||
logger.warn(`Token with hashedToken=${hashedToken} is expired since ${savedToken.expiresAt.toUTCString()}`);
|
||||
throw new ResetPasswordError();
|
||||
}
|
||||
|
||||
const password = validation.data.password.trim();
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { email } = await db.user.update({
|
||||
where: { id: savedToken.userId },
|
||||
data: { hashedPassword },
|
||||
});
|
||||
|
||||
await db.session.deleteMany({ where: { userId: savedToken.userId } });
|
||||
|
||||
return authenticate({ email, password, request });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,23 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
|
||||
import { SignIn } from "../validations";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { authenticate } from "~/utils/auth.server";
|
||||
|
||||
export type SignInActionData = { errors: FormError<typeof SignIn> };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.clone().formData());
|
||||
const validation = validate(SignIn, formData);
|
||||
if (validation.errors) {
|
||||
return json<SignInActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
const successRedirect = redirectTo ? decodeURIComponent(redirectTo) : null;
|
||||
const { email, password } = validation.data;
|
||||
return authenticate({ email, password, request, successRedirect });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,11 +0,0 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
|
||||
import { requireLoggedOut } from "~/utils/auth.server";
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,25 +0,0 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
|
||||
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
export type RegisterLoaderData = { errors: { general: string } } | null;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const errorMessage = getErrorMessage(session);
|
||||
if (errorMessage) {
|
||||
return json<RegisterLoaderData>(
|
||||
{ errors: { general: errorMessage } },
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,23 +0,0 @@
|
||||
import { type LoaderFunction, redirect } from "@remix-run/node";
|
||||
|
||||
import { requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
return redirect("/forgot-password");
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,25 +0,0 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
|
||||
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
export type SignInLoaderData = { errors: { general: string } } | null;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const errorMessage = getErrorMessage(session);
|
||||
if (errorMessage) {
|
||||
return json<SignInLoaderData>(
|
||||
{ errors: { general: errorMessage } },
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,49 +0,0 @@
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { ForgotPasswordActionData } from "../actions/forgot-password";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const actionData = useActionData<ForgotPasswordActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Forgot your password?
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
|
||||
{actionData?.submitted ? (
|
||||
<p className="text-center">
|
||||
If your email is in our system, you will receive instructions to reset your password shortly.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
tabIndex={2}
|
||||
className="w-full flex justify-center py-2 px-4 text-base font-medium"
|
||||
>
|
||||
Send reset password link
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { RegisterActionData } from "../actions/register";
|
||||
import type { RegisterLoaderData } from "../loaders/register";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const loaderData = useLoaderData<RegisterLoaderData>();
|
||||
const actionData = useActionData<RegisterActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
const topErrorMessage = loaderData?.errors?.general || actionData?.errors?.general;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||
<Link
|
||||
to="/sign-in"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
|
||||
>
|
||||
Already have an account?
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
|
||||
{topErrorMessage ? (
|
||||
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage!} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="fullName"
|
||||
type="text"
|
||||
label="Full name"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.fullName}
|
||||
tabIndex={1}
|
||||
/>
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={2}
|
||||
/>
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={3}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
tabIndex={4}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import { Form, useActionData, useSearchParams, useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import type { ResetPasswordActionData } from "../actions/reset-password";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const actionData = useActionData<ResetPasswordActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Set a new password</h2>
|
||||
</header>
|
||||
|
||||
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
label="New Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="passwordConfirmation"
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.passwordConfirmation}
|
||||
tabIndex={2}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-base 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,
|
||||
},
|
||||
)}
|
||||
tabIndex={3}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useSearchParams, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { SignInActionData } from "../actions/sign-in";
|
||||
import type { SignInLoaderData } from "../loaders/sign-in";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function SignInPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const loaderData = useLoaderData<SignInLoaderData>();
|
||||
const actionData = useActionData<SignInActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Welcome back!</h2>
|
||||
{/*<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||
Need an account?
|
||||
<Link
|
||||
to="/register"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
|
||||
>
|
||||
Create yours for free
|
||||
</Link>
|
||||
</p>*/}
|
||||
</header>
|
||||
|
||||
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
|
||||
{loaderData?.errors ? (
|
||||
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
|
||||
<Alert title="Oops, there was an issue" message={loaderData.errors.general} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={2}
|
||||
sideLabel={
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition ease-in-out duration-150"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={transition.state === "submitting"} tabIndex={3}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const password = z.string().min(10).max(100);
|
||||
|
||||
export const Register = z.object({
|
||||
fullName: z.string().nonempty(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const SignIn = z.object({
|
||||
email: z.string().email(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const ForgotPassword = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const ResetPassword = z
|
||||
.object({
|
||||
password: password,
|
||||
passwordConfirmation: password,
|
||||
token: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirmation, {
|
||||
message: "Passwords don't match",
|
||||
path: ["passwordConfirmation"], // set the path of the error
|
||||
});
|
||||
|
||||
export const AcceptInvitation = z.object({
|
||||
fullName: z.string(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const AcceptAuthedInvitation = z.object({
|
||||
token: z.string(),
|
||||
});
|
@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { validate } from "~/utils/validation.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.clone().formData();
|
||||
@ -31,7 +31,6 @@ const action: ActionFunction = async ({ request }) => {
|
||||
export default action;
|
||||
|
||||
async function subscribe(request: Request) {
|
||||
const { organization } = await requireLoggedIn(request);
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
|
||||
@ -42,17 +41,16 @@ async function subscribe(request: Request) {
|
||||
}
|
||||
|
||||
const { subscription } = validation.data;
|
||||
const membership = await db.membership.findFirst({
|
||||
where: { id: organization.membershipId },
|
||||
});
|
||||
if (!membership) {
|
||||
return notFound("Phone number not found");
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.notificationSubscription.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
twilioAccountSid: twilio.accountSid,
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
|
@ -1,26 +0,0 @@
|
||||
import type { ButtonHTMLAttributes, FunctionComponent } from "react";
|
||||
import { useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const Button: FunctionComponent<Props> = ({ children, ...props }) => {
|
||||
const transition = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-base 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": transition.state === "submitting",
|
||||
"bg-primary-600 hover:bg-primary-700": transition.state !== "submitting",
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
@ -1,35 +0,0 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function InactiveSubscription() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
||||
<span className="hidden md:inline-block md:align-middle md:h-screen">​</span>
|
||||
<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">
|
||||
<div className="text-center my-auto p-4">
|
||||
<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 don't have any active subscription
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 max-w-sm mx-auto break-normal whitespace-normal">
|
||||
You need an active subscription to use this feature.
|
||||
<br />
|
||||
Head over to your settings to pick a plan.
|
||||
</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={() => navigate("/settings/billing")}
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Choose a plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { HiCheck as CheckIcon, HiSelector as SelectorIcon } from "react-icons/hi";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Option = { name: string; value: string };
|
||||
|
||||
type Props = {
|
||||
options: Option[];
|
||||
onChange: (selectedValue: Option) => void;
|
||||
value: Option;
|
||||
};
|
||||
|
||||
export default function Select({ options, onChange, value }: Props) {
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">{value.name}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{options.map((option, index) => (
|
||||
<Listbox.Option
|
||||
key={`option-${option}-${index}`}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
"cursor-default select-none relative py-2 pl-10 pr-4",
|
||||
active ? "text-amber-900 bg-amber-100" : "text-gray-900",
|
||||
)
|
||||
}
|
||||
value={option}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={clsx("block truncate", selected ? "font-medium" : "font-normal")}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute inset-y-0 left-0 flex items-center pl-3",
|
||||
active ? "text-amber-600" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
.ring {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50%;
|
||||
border-top-color: currentColor;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
|
||||
import styles from "./spinner.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
];
|
||||
|
||||
export default function Spinner() {
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="ring m-auto text-primary-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,21 +2,20 @@ import type { LoaderFunction } from "@remix-run/node";
|
||||
import { json } from "superjson-remix";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import db from "~/utils/db.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type KeypadLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
lastRecipientCalled?: string;
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const lastCall =
|
||||
phoneNumber &&
|
||||
@ -26,7 +25,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}));
|
||||
return json<KeypadLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
lastRecipientCalled: lastCall?.recipient,
|
||||
},
|
||||
|
@ -2,19 +2,21 @@ import { type ActionFunction } from "@remix-run/node";
|
||||
import { json } from "superjson-remix";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type NewMessageActionData = {};
|
||||
type NewMessageActionData = {};
|
||||
|
||||
const action: ActionFunction = async ({ params, request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const [phoneNumber, twilioAccount] = await Promise.all([
|
||||
db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid ?? "", isCurrent: true } },
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid, isCurrent: true } },
|
||||
}),
|
||||
db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }),
|
||||
]);
|
||||
|
@ -4,8 +4,8 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type ConversationType = {
|
||||
recipient: string;
|
||||
@ -19,7 +19,8 @@ export type ConversationLoaderData = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request, params }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
return redirect("/messages");
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type MessagesLoaderData = {
|
||||
hasPhoneNumber: boolean;
|
||||
@ -19,7 +19,8 @@ type Conversation = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
|
@ -10,20 +10,14 @@ import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
|
||||
import type { PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
|
||||
|
||||
export default function PhoneCallsList() {
|
||||
const { hasOngoingSubscription, isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
const { isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
|
||||
if (!hasOngoingSubscription) {
|
||||
if (!phoneCalls || phoneCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
|
||||
if (phoneCalls.length === 0) {
|
||||
return hasOngoingSubscription ? <EmptyCalls /> : null;
|
||||
}
|
||||
if (phoneCalls.length === 0) {
|
||||
return <EmptyCalls />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type PhoneCall, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type PhoneCallMeta = {
|
||||
formattedPhoneNumber: string;
|
||||
@ -12,7 +12,6 @@ type PhoneCallMeta = {
|
||||
};
|
||||
|
||||
export type PhoneCallsLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
} & (
|
||||
| {
|
||||
@ -26,15 +25,14 @@ export type PhoneCallsLoaderData = {
|
||||
);
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
if (!phoneNumber || phoneNumber.isFetchingCalls) {
|
||||
return json<PhoneCallsLoaderData>({
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
isFetchingCalls: phoneNumber?.isFetchingCalls ?? false,
|
||||
});
|
||||
@ -46,7 +44,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
});
|
||||
return json<PhoneCallsLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
phoneCalls: phoneCalls.map((phoneCall) => ({
|
||||
...phoneCall,
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { type LoaderFunction } from "@remix-run/node";
|
||||
import Twilio from "twilio";
|
||||
|
||||
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import { decrypt, encrypt } from "~/utils/encryption";
|
||||
import db from "~/utils/db.server";
|
||||
import { commitSession } from "~/utils/session.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
import getTwilioClient from "~/utils/twilio.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
|
||||
export type TwilioTokenLoaderData = string;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { user, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
logger.warn("Twilio account is not connected");
|
||||
return null;
|
||||
@ -26,7 +26,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(twilioAccount);
|
||||
let shouldRefreshSession = false;
|
||||
let { apiKeySid, apiKeySecret } = twilioAccount;
|
||||
if (apiKeySid && apiKeySecret) {
|
||||
try {
|
||||
@ -41,7 +40,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
if (!apiKeySid || !apiKeySecret) {
|
||||
shouldRefreshSession = true;
|
||||
const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" });
|
||||
apiKeySid = apiKey.sid;
|
||||
apiKeySecret = encrypt(apiKey.secret);
|
||||
@ -52,7 +50,7 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), {
|
||||
identity: `${twilio.accountSid}__${user.id}`,
|
||||
identity: `shellphone__${twilio.accountSid}`,
|
||||
ttl: 3600,
|
||||
});
|
||||
const grant = new Twilio.jwt.AccessToken.VoiceGrant({
|
||||
@ -62,11 +60,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
accessToken.addGrant(grant);
|
||||
|
||||
const headers = new Headers({ "Content-Type": "text/plain" });
|
||||
if (shouldRefreshSession) {
|
||||
const { session } = await refreshSessionData(request);
|
||||
headers.set("Set-Cookie", await commitSession(session));
|
||||
}
|
||||
|
||||
return new Response(accessToken.toJwt(), { headers });
|
||||
};
|
||||
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
|
||||
import { addSubscriber } from "~/utils/mailchimp.server";
|
||||
import { executeWebhook } from "~/utils/discord.server";
|
||||
|
||||
export type JoinWaitlistActionData = { submitted: true };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email");
|
||||
if (!formData.get("email") || typeof email !== "string") {
|
||||
throw new Error("Something wrong happened");
|
||||
}
|
||||
|
||||
// await addSubscriber(email);
|
||||
const res = await executeWebhook(email);
|
||||
console.log(res.status);
|
||||
console.log(await res.text());
|
||||
|
||||
return json<JoinWaitlistActionData>({ submitted: true });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,41 +0,0 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const baseStyles = {
|
||||
solid: "group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
outline: "group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none",
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
solid: {
|
||||
slate: "bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900",
|
||||
primary:
|
||||
"bg-primary-600 text-white hover:text-slate-100 hover:bg-primary-500 active:bg-primary-800 active:text-primary-100 focus-visible:outline-primary-600",
|
||||
white: "bg-white text-slate-900 hover:bg-primary-50 active:bg-primary-200 active:text-slate-600 focus-visible:outline-white",
|
||||
},
|
||||
outline: {
|
||||
slate: "ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-primary-600 focus-visible:ring-slate-300",
|
||||
white: "ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white",
|
||||
},
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
(
|
||||
| {
|
||||
variant: "solid";
|
||||
color: "slate" | "primary" | "white";
|
||||
}
|
||||
| {
|
||||
variant: "outline";
|
||||
color: "slate" | "white";
|
||||
}
|
||||
) & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Button({ variant, color, className, ...props }: Props) {
|
||||
// @ts-ignore
|
||||
const fullClassName = clsx(baseStyles[variant], variantStyles[variant][color], className);
|
||||
|
||||
return <button className={fullClassName} {...props} />;
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
|
||||
import type { JoinWaitlistActionData } from "~/features/public-area/actions";
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
import { TextField } from "./fields";
|
||||
|
||||
import Alert from "~/features/core/components/alert";
|
||||
|
||||
import backgroundImage from "../images/background-call-to-action.webp";
|
||||
|
||||
export default function CallToAction() {
|
||||
const actionData = useActionData<JoinWaitlistActionData>();
|
||||
|
||||
return (
|
||||
<section id="get-started-today" className="relative overflow-hidden bg-blue-600 py-32">
|
||||
<img
|
||||
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2"
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
width={2347}
|
||||
height={1244}
|
||||
/>
|
||||
<Container className="relative">
|
||||
<div className="mx-auto max-w-lg text-center">
|
||||
<h2 className="font-mackinac font-bold text-3xl tracking-tight text-white sm:text-4xl">
|
||||
Request access
|
||||
</h2>
|
||||
<p className="mt-4 text-lg tracking-tight text-white">
|
||||
Shellphone is currently invite-only but we onboard new users on a regular basis. Enter your
|
||||
email address to join the waitlist and receive important updates in your inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
method="post"
|
||||
className="max-w-2xl mx-auto flex flex-col space-y-4 items-center mt-10 sm:flex-row sm:space-y-0 sm:space-x-4"
|
||||
>
|
||||
{actionData?.submitted ? (
|
||||
<div className="m-auto">
|
||||
<Alert
|
||||
title="You made it!"
|
||||
message="You're on the list, we will be in touch soon"
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="solid" color="white" className="w-40">
|
||||
<span>Join waitlist</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Container({ className, ...props }: Props) {
|
||||
return <div className={clsx("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8", className)} {...props} />;
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Container from "./container";
|
||||
|
||||
import backgroundImage from "../images/background-faqs.webp";
|
||||
|
||||
export default function Faqs() {
|
||||
return (
|
||||
<section id="faq" aria-labelledby="faq-title" className="relative overflow-hidden bg-slate-50 py-20 sm:py-32">
|
||||
<img
|
||||
className="absolute top-0 left-1/2 max-w-none translate-x-[-30%] -translate-y-1/4"
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
width={1558}
|
||||
height={946}
|
||||
/>
|
||||
<Container className="relative">
|
||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
id="faq-title"
|
||||
className="font-mackinac font-bold text-3xl tracking-tight text-slate-900 sm:text-4xl"
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="mt-16 grid grid-cols-1 max-w-3xl mx-auto pl-12 lg:mx-0">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<Accordion title="Does it work with 2FA messages?">
|
||||
Some banks and online services refuse to send two-factor authentication messages to a virtual
|
||||
phone number and we do not have a solution around this yet. Moreover, Twilio does not support
|
||||
receiving incoming SMS from external Alphanumeric Sender IDs is to protect accounts getting
|
||||
bombarded from spam messages from these IDs which are used to send one-way SMS.
|
||||
<br />
|
||||
With that said, we have successfully received 2FA messages from many services including WhatsApp
|
||||
and Uber. We recognize this is a common problem for people who want to switch to a virtual phone
|
||||
number and we are doing our best to find a long-term solution to receive 2FA messages.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQs() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="py-12 md:py-20">
|
||||
<div className="max-w-3xl mx-auto text-center pb-20">
|
||||
<h2 className="h2 font-mackinac">Questions & Answers</h2>
|
||||
</div>
|
||||
|
||||
<ul className="max-w-3xl mx-auto pl-12">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const Accordion: FunctionComponent<PropsWithChildren<{ title: string }>> = ({ title, children }) => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex items-center w-full text-lg font-medium text-left py-5 border-t border-gray-200">
|
||||
<svg
|
||||
className="w-4 h-4 fill-current text-rebeccapurple-500 flex-shrink-0 mr-8 -ml-12"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-90": !open,
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</svg>
|
||||
<span>{title}</span>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-300 ease-in-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="text-gray-600 overflow-hidden">
|
||||
<p className="pb-5">{children}</p>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
import type { InputHTMLAttributes, HTMLAttributes, PropsWithChildren } from "react";
|
||||
|
||||
const formClasses =
|
||||
"block w-full appearance-none rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-blue-500 sm:text-sm";
|
||||
|
||||
function Label({ id, children }: PropsWithChildren<HTMLAttributes<HTMLLabelElement>>) {
|
||||
return (
|
||||
<label htmlFor={id} className="mb-3 block text-sm font-medium text-gray-700">
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
className = "",
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <Label id={id}>{label}</Label>}
|
||||
<input id={id} type={type} {...props} className={formClasses} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
import Logo from "./logo";
|
||||
import NavLink from "./nav-link";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="py-10">
|
||||
<Container>
|
||||
<nav className="relative z-50 flex justify-between">
|
||||
<div className="flex items-center md:gap-x-12">
|
||||
<Link to="/" aria-label="Home">
|
||||
<Logo />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-5 md:gap-x-8">
|
||||
<NavLink href="/sign-in">Have an account?</NavLink>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
document.querySelector("#get-started-today")?.scrollIntoView({ behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<span>Request access</span>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</Container>
|
||||
</header>
|
||||
);
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
|
||||
/*
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin-top: -120px;
|
||||
*/
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<Container className="pt-20 pb-16 text-center lg:pt-32 landing-hero">
|
||||
<h1 className="mx-auto max-w-4xl font-mackinac font-heading text-5xl font-medium tracking-tight text-[#24185B] sm:text-7xl">
|
||||
<span className="background-primary bg-clip-text decoration-clone text-transparent">
|
||||
Calling your bank from abroad
|
||||
</span>{" "}
|
||||
just got{" "}
|
||||
<span className="relative whitespace-nowrap">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 418 42"
|
||||
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-rebeccapurple-300/70"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
|
||||
</svg>
|
||||
<span className="relative">easier</span>
|
||||
</span>{" "}
|
||||
!
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
|
||||
Coming soon, the personal cloud phone for digital nomads! Take your phone number anywhere you go 🌏
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function Logo() {
|
||||
return <img className="w-10 h-10" src="/shellphone.webp" alt="Shellphone logo" />;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
export default function NavLink({ href, children }: PropsWithChildren<{ href: string }>) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-block rounded-lg py-1 px-2 text-sm text-slate-700 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 162 KiB |
Binary file not shown.
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
@ -1,17 +0,0 @@
|
||||
import Header from "../components/header";
|
||||
import Hero from "../components/hero";
|
||||
import CallToAction from "../components/call-to-action";
|
||||
import Faqs from "../components/faqs";
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<section className="flex h-full flex-col">
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<CallToAction />
|
||||
<Faqs />
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
import { type ActionFunction, json, redirect } from "@remix-run/node";
|
||||
import { badRequest } from "remix-utils";
|
||||
import { z } from "zod";
|
||||
import SecurePassword from "secure-password";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { hashPassword, requireLoggedIn, verifyPassword } from "~/utils/auth.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { destroySession, getSession } from "~/utils/session.server";
|
||||
import deleteUserQueue from "~/queues/delete-user-data.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
if (!formData._action) {
|
||||
const errorMessage = "POST /settings/phone without any _action";
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
|
||||
switch (formData._action as Action) {
|
||||
case "deleteUser":
|
||||
return deleteUser(request);
|
||||
case "changePassword":
|
||||
return changePassword(request, formData);
|
||||
case "updateUser":
|
||||
return updateUser(request, formData);
|
||||
default:
|
||||
const errorMessage = `POST /settings/phone with an invalid _action=${formData._action}`;
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
export default action;
|
||||
|
||||
async function deleteUser(request: Request) {
|
||||
const {
|
||||
user: { id },
|
||||
} = await requireLoggedIn(request);
|
||||
|
||||
await db.user.update({
|
||||
where: { id },
|
||||
data: { hashedPassword: "pending deletion" },
|
||||
});
|
||||
await deleteUserQueue.add(`delete user ${id}`, { userId: id });
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await destroySession(await getSession(request)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ChangePasswordFailureActionData = { errors: FormError<typeof validations.changePassword>; submitted?: never };
|
||||
type ChangePasswordSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type ChangePasswordActionData = {
|
||||
changePassword: ChangePasswordFailureActionData | ChangePasswordSuccessfulActionData;
|
||||
};
|
||||
|
||||
async function changePassword(request: Request, formData: unknown) {
|
||||
const validation = validate(validations.changePassword, formData);
|
||||
if (validation.errors) {
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { errors: validation.errors },
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
user: { id },
|
||||
} = await requireLoggedIn(request);
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
const { currentPassword, newPassword } = validation.data;
|
||||
const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword);
|
||||
if ([SecurePassword.INVALID, SecurePassword.INVALID_UNRECOGNIZED_HASH, false].includes(verificationResult)) {
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { errors: { currentPassword: "Current password is incorrect" } },
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword.trim());
|
||||
await db.user.update({
|
||||
where: { id: user!.id },
|
||||
data: { hashedPassword },
|
||||
});
|
||||
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { submitted: true },
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateUserFailureActionData = { errors: FormError<typeof validations.updateUser>; submitted?: never };
|
||||
type UpdateUserSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type UpdateUserActionData = {
|
||||
updateUser: UpdateUserFailureActionData | UpdateUserSuccessfulActionData;
|
||||
};
|
||||
|
||||
async function updateUser(request: Request, formData: unknown) {
|
||||
const validation = validate(validations.updateUser, formData);
|
||||
if (validation.errors) {
|
||||
return json<UpdateUserActionData>({
|
||||
updateUser: { errors: validation.errors },
|
||||
});
|
||||
}
|
||||
|
||||
const { user } = await requireLoggedIn(request);
|
||||
const { email, fullName } = validation.data;
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email, fullName },
|
||||
});
|
||||
|
||||
return json<UpdateUserActionData>({
|
||||
updateUser: { submitted: true },
|
||||
});
|
||||
}
|
||||
|
||||
type Action = "deleteUser" | "updateUser" | "changePassword";
|
||||
|
||||
const validations = {
|
||||
deleteUser: null,
|
||||
changePassword: z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(10).max(100),
|
||||
}),
|
||||
updateUser: z.object({
|
||||
fullName: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
} as const;
|
@ -5,8 +5,7 @@ import type { Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { type FormActionData, validate } from "~/utils/validation.server";
|
||||
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import { commitSession } from "~/utils/session.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { encrypt } from "~/utils/encryption";
|
||||
@ -40,7 +39,8 @@ const action: ActionFunction = async ({ request }) => {
|
||||
export type SetPhoneNumberActionData = FormActionData<typeof validations, "setPhoneNumber">;
|
||||
|
||||
async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
return badRequest<SetPhoneNumberActionData>({
|
||||
setPhoneNumber: {
|
||||
@ -72,7 +72,6 @@ async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
});
|
||||
await setTwilioWebhooksQueue.add(`set twilio webhooks for phoneNumberId=${validation.data.phoneNumberSid}`, {
|
||||
phoneNumberId: validation.data.phoneNumberSid,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return json<SetPhoneNumberActionData>({ setPhoneNumber: { submitted: true } });
|
||||
@ -81,7 +80,8 @@ async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
export type SetTwilioCredentialsActionData = FormActionData<typeof validations, "setTwilioCredentials">;
|
||||
|
||||
async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const validation = validate(validations.setTwilioCredentials, formData);
|
||||
if (validation.errors) {
|
||||
return badRequest<SetTwilioCredentialsActionData>({ setTwilioCredentials: { errors: validation.errors } });
|
||||
@ -99,10 +99,10 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let session: Session | undefined;
|
||||
if (twilio) {
|
||||
console.log("fail");
|
||||
await db.twilioAccount.delete({ where: { accountSid: twilio?.accountSid } });
|
||||
session = (await refreshSessionData(request)).session;
|
||||
session.unset("twilio");
|
||||
}
|
||||
|
||||
return json<SetTwilioCredentialsActionData>(
|
||||
@ -112,11 +112,9 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: session
|
||||
? {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
}
|
||||
: {},
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -128,13 +126,8 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
const [phoneNumbers] = await Promise.all([
|
||||
twilioClient.incomingPhoneNumbers.list(),
|
||||
db.twilioAccount.upsert({
|
||||
where: { organizationId: organization.id },
|
||||
create: {
|
||||
organization: {
|
||||
connect: { id: organization.id },
|
||||
},
|
||||
...data,
|
||||
},
|
||||
where: { accountSid: twilioAccountSid },
|
||||
create: data,
|
||||
update: data,
|
||||
}),
|
||||
]);
|
||||
@ -143,11 +136,11 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
accountSid: twilioAccountSid,
|
||||
});
|
||||
await Promise.all(
|
||||
phoneNumbers.map(async (phoneNumber) => {
|
||||
phoneNumbers.map(async (phoneNumber, index) => {
|
||||
const phoneNumberId = phoneNumber.sid;
|
||||
logger.info(`Importing phone number with id=${phoneNumberId}`);
|
||||
try {
|
||||
await db.phoneNumber.create({
|
||||
await db.phoneNumber.createMany({
|
||||
data: {
|
||||
id: phoneNumberId,
|
||||
twilioAccountSid,
|
||||
@ -156,6 +149,7 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
isFetchingCalls: true,
|
||||
isFetchingMessages: true,
|
||||
},
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
@ -177,19 +171,25 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
}),
|
||||
);
|
||||
|
||||
const { session } = await refreshSessionData(request);
|
||||
session.set("twilio", { accountSid: twilioAccountSid, authToken });
|
||||
console.log("{ accountSid: twilioAccountSid, authToken }", { accountSid: twilioAccountSid, authToken });
|
||||
console.log("session", session.get("twilio"), session.data);
|
||||
const setCookie = await commitSession(session);
|
||||
console.log("set twilio in session", setCookie);
|
||||
|
||||
return json<SetTwilioCredentialsActionData>(
|
||||
{ setTwilioCredentials: { submitted: true } },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
"Set-Cookie": setCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshPhoneNumbers(request: Request) {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
@ -1,91 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Form, useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
|
||||
export default function DangerZone() {
|
||||
const transition = useTransition();
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "deleteUser";
|
||||
const isDeletingUser = isCurrentFormTransition && transition.state === "submitting";
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
|
||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isDeletingUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection className="border border-red-300">
|
||||
<div className="flex justify-between items-center flex-row space-x-2">
|
||||
<p>
|
||||
Once you delete your account, all of its data will be permanently deleted and any ongoing
|
||||
subscription will be cancelled.
|
||||
</p>
|
||||
|
||||
<span className="text-base font-medium">
|
||||
<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
|
||||
Delete my account
|
||||
</Button>
|
||||
</span>
|
||||
</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">
|
||||
<Form method="post">
|
||||
<button
|
||||
type="submit"
|
||||
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,
|
||||
},
|
||||
)}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
<input type="hidden" name="_action" value="deleteUser" />
|
||||
</Form>
|
||||
<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>
|
||||
);
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { UpdateUserActionData } from "~/features/settings/actions/account";
|
||||
import useSession from "~/features/core/hooks/use-session";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
|
||||
const ProfileInformations: FunctionComponent = () => {
|
||||
const { user } = useSession();
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<UpdateUserActionData>()?.updateUser;
|
||||
|
||||
const errors = actionData?.errors;
|
||||
const topErrorMessage = errors?.general;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const isSuccess = actionData?.submitted;
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "updateUser";
|
||||
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<SettingsSection
|
||||
footer={
|
||||
<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}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
|
||||
Full name
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
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"
|
||||
defaultValue={user.fullName}
|
||||
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"
|
||||
name="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"
|
||||
defaultValue={user.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_action" value="updateUser" />
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileInformations;
|
@ -1,69 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { ChangePasswordActionData } from "~/features/settings/actions/account";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
|
||||
const UpdatePassword: FunctionComponent = () => {
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<ChangePasswordActionData>()?.changePassword;
|
||||
|
||||
const topErrorMessage = actionData?.errors?.general;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const isSuccess = actionData?.submitted;
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword";
|
||||
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<SettingsSection
|
||||
footer={
|
||||
<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}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
type="password"
|
||||
tabIndex={3}
|
||||
error={actionData?.errors?.currentPassword}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
type="password"
|
||||
tabIndex={4}
|
||||
error={actionData?.errors?.newPassword}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<input type="hidden" name="_action" value="changePassword" />
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePassword;
|
@ -1,172 +0,0 @@
|
||||
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
|
||||
import clsx from "clsx";
|
||||
|
||||
import usePaymentsHistory from "../../hooks/use-payments-history";
|
||||
|
||||
export default function BillingHistory() {
|
||||
const {
|
||||
payments,
|
||||
count,
|
||||
skip,
|
||||
pagesNumber,
|
||||
currentPage,
|
||||
lastPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
goToPreviousPage,
|
||||
goToNextPage,
|
||||
setPage,
|
||||
} = usePaymentsHistory();
|
||||
|
||||
if (payments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
|
||||
<div className="px-4 sm:px-6">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Billing history</h2>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden border-t border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Amount
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span className="sr-only">View receipt</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<time>{new Date(payment.payout_date).toDateString()}</time>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: payment.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(payment.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{payment.is_paid === 1 ? "Paid" : "Upcoming"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{typeof payment.receipt_url !== "undefined" ? (
|
||||
<a
|
||||
href={payment.receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View receipt
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasPreviousPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<p className="text-sm text-gray-700 self-center">
|
||||
Page <span className="font-medium">{currentPage}</span> of{" "}
|
||||
<span className="font-medium">{lastPage}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className={clsx(
|
||||
"ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasNextPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{skip + 1}</span> to{" "}
|
||||
<span className="font-medium">{skip + payments.length}</span> of{" "}
|
||||
<span className="font-medium">{count}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<IoChevronBack className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
{pagesNumber.map((pageNumber) => (
|
||||
<button
|
||||
key={`billing-history-button-page-${pageNumber}`}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
|
||||
pageNumber === currentPage
|
||||
? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
|
||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<IoChevronForward className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import type { FunctionComponent, MouseEventHandler } from "react";
|
||||
import { HiExternalLink } from "react-icons/hi";
|
||||
|
||||
type Props = {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PaddleLink: FunctionComponent<Props> = ({ onClick, text }) => (
|
||||
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
|
||||
<HiExternalLink className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="font-medium transition-colors duration-150 border-b border-transparent hover:border-primary-500">
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default PaddleLink;
|
@ -1,139 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { type Subscription, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
import SwitchPlanModal from "./switch-plan-modal";
|
||||
|
||||
export type Plan = typeof pricing["tiers"][number];
|
||||
|
||||
function useSubscription() {
|
||||
return {
|
||||
hasActiveSubscription: false,
|
||||
subscription: null as any,
|
||||
subscribe: () => void 0,
|
||||
changePlan: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Plans() {
|
||||
const { hasActiveSubscription, subscription, subscribe, changePlan } = useSubscription();
|
||||
const [nextPlan, setNextPlan] = useState<Plan | null>(null);
|
||||
const [isSwitchPlanModalOpen, setIsSwitchPlanModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 flex flex-row flex-wrap gap-2">
|
||||
{pricing.tiers.map((tier) => {
|
||||
const isCurrentTier = subscription?.paddlePlanId === tier.planId;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
const cta = getCTA({ subscription, tier });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.title}
|
||||
className={clsx(
|
||||
"relative p-2 pt-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-1 min-w-[250px] flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 px-2">
|
||||
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
|
||||
{tier.yearly ? (
|
||||
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
|
||||
Get 2 months free!
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 flex items-baseline text-gray-900">
|
||||
<span className="text-2xl font-extrabold tracking-tight">{tier.price}€</span>
|
||||
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
|
||||
</p>
|
||||
{tier.yearly ? (
|
||||
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12}€)</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-gray-500">{tier.description}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isActiveTier}
|
||||
onClick={() => {
|
||||
if (hasActiveSubscription) {
|
||||
setNextPlan(tier);
|
||||
setIsSwitchPlanModalOpen(true);
|
||||
} else {
|
||||
// subscribe({ planId: tier.planId });
|
||||
// Panelbear.track(`Subscribe to ${tier.title}`);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
!isActiveTier
|
||||
? "bg-primary-500 text-white hover:bg-primary-600"
|
||||
: "bg-primary-50 text-primary-700 cursor-not-allowed",
|
||||
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
|
||||
)}
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SwitchPlanModal
|
||||
isOpen={isSwitchPlanModalOpen}
|
||||
nextPlan={nextPlan}
|
||||
confirm={(nextPlan: Plan) => {
|
||||
// changePlan({ planId: nextPlan.planId });
|
||||
// Panelbear.track(`Subscribe to ${nextPlan.title}`);
|
||||
setIsSwitchPlanModalOpen(false);
|
||||
}}
|
||||
closeModal={() => setIsSwitchPlanModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCTA({
|
||||
subscription,
|
||||
tier,
|
||||
}: {
|
||||
subscription?: Subscription;
|
||||
tier: typeof pricing["tiers"][number];
|
||||
}): string {
|
||||
if (!subscription) {
|
||||
return "Subscribe";
|
||||
}
|
||||
|
||||
const isCancelling = subscription.status === SubscriptionStatus.deleted;
|
||||
if (isCancelling) {
|
||||
return "Resubscribe";
|
||||
}
|
||||
|
||||
const isCurrentTier = subscription.paddlePlanId === tier.planId;
|
||||
const hasActiveSubscription = subscription.status !== SubscriptionStatus.deleted;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
if (isActiveTier) {
|
||||
return "Current plan";
|
||||
}
|
||||
|
||||
return `Switch to ${tier.title}`;
|
||||
}
|
||||
|
||||
const pricing = {
|
||||
tiers: [
|
||||
{
|
||||
title: "Yearly",
|
||||
planId: 727544,
|
||||
price: 12.5,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world, all year long.",
|
||||
yearly: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly",
|
||||
planId: 727540,
|
||||
price: 15,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world.",
|
||||
yearly: false,
|
||||
},
|
||||
],
|
||||
};
|
@ -1,52 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
import type { Plan } from "./plans";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
nextPlan: Plan | null;
|
||||
confirm: (nextPlan: Plan) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
const SwitchPlanModal: FunctionComponent<Props> = ({ isOpen, nextPlan, confirm, closeModal }) => {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Modal initialFocus={confirmButtonRef} 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>Are you sure you want to switch to {nextPlan?.title}?</ModalTitle>
|
||||
<div className="mt-2 text-gray-500">
|
||||
<p>
|
||||
You're about to switch to the <strong>{nextPlan?.title}</strong> plan. You will be
|
||||
billed immediately a prorated amount and the next billing date will be recalculated from
|
||||
today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
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 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={() => confirm(nextPlan!)}
|
||||
>
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="md:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white 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"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Nope, cancel it
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitchPlanModal;
|
@ -16,19 +16,6 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Need some help?</ModalTitle>
|
||||
<div className="mt-6 space-y-3 text-gray-500">
|
||||
<p>
|
||||
Try{" "}
|
||||
<a className="underline" href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3">
|
||||
reconnecting your Twilio account
|
||||
</a> to refresh the phone numbers.
|
||||
</p>
|
||||
<p>
|
||||
If you are stuck, pick a date & time on{" "}
|
||||
<a className="underline" href="https://calendly.com/shellphone-onboarding">
|
||||
our calendly
|
||||
</a>{" "}
|
||||
and we will help you get started!
|
||||
</p>
|
||||
<p>
|
||||
Don't miss out on free $10 Twilio credit by using{" "}
|
||||
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
|
||||
|
@ -25,7 +25,7 @@ export default function PhoneNumberForm() {
|
||||
const topErrorMessage = errors?.general ?? errors?.phoneNumberSid;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true);
|
||||
const hasFilledTwilioCredentials = twilio !== null;
|
||||
const hasFilledTwilioCredentials = twilio != null;
|
||||
|
||||
if (!hasFilledTwilioCredentials) {
|
||||
return null;
|
||||
|
@ -13,9 +13,11 @@ import Button from "~/features/settings/components/button";
|
||||
|
||||
export default function TwilioConnect() {
|
||||
const { twilio } = useSession();
|
||||
console.log("twilio", twilio);
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<SetTwilioCredentialsActionData>()?.setTwilioCredentials;
|
||||
const actionData = useActionData<any>()
|
||||
?.setTwilioCredentials as SetTwilioCredentialsActionData["setTwilioCredentials"];
|
||||
const { accountSid, authToken } = useLoaderData<PhoneSettingsLoaderData>();
|
||||
|
||||
const topErrorMessage = actionData?.errors?.general;
|
||||
@ -50,7 +52,7 @@ export default function TwilioConnect() {
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{twilio !== null ? (
|
||||
{twilio != null ? (
|
||||
<p className="text-green-700">✓ Your Twilio account is connected to Shellphone.</p>
|
||||
) : null}
|
||||
|
||||
|
@ -2,9 +2,9 @@ import { type LoaderArgs, json } from "@remix-run/node";
|
||||
import { type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { decrypt } from "~/utils/encryption";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type PhoneSettingsLoaderData = {
|
||||
accountSid?: string;
|
||||
@ -13,14 +13,15 @@ export type PhoneSettingsLoaderData = {
|
||||
};
|
||||
|
||||
const loader = async ({ request }: LoaderArgs) => {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
logger.warn("Twilio account is not connected");
|
||||
return json({ phoneNumbers: [] });
|
||||
}
|
||||
|
||||
const phoneNumbers = await db.phoneNumber.findMany({
|
||||
where: { twilioAccount: { organizationId: organization.id } },
|
||||
where: { twilioAccount: { accountSid: twilio.accountSid } },
|
||||
select: { id: true, number: true, isCurrent: true },
|
||||
orderBy: { id: Prisma.SortOrder.desc },
|
||||
});
|
||||
|
Reference in New Issue
Block a user