remixed v0

This commit is contained in:
m5r
2022-05-14 12:22:06 +02:00
parent 9275d4499b
commit 98b89ae0f7
338 changed files with 22549 additions and 44628 deletions

View File

@ -0,0 +1,63 @@
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,
});
}

View File

@ -0,0 +1,56 @@
import { type ActionFunction, json } from "@remix-run/node";
import { GlobalRole, MembershipRole } from "@prisma/client";
import db from "~/utils/db.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 { orgName, 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: { name: orgName },
},
},
},
},
});
} catch (error: any) {
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;

View File

@ -0,0 +1,56 @@
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;

View File

@ -0,0 +1,22 @@
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 { email, password } = validation.data;
return authenticate({ email, password, request, successRedirect: redirectTo });
};
export default action;

View File

@ -0,0 +1,11 @@
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;

View File

@ -0,0 +1,25 @@
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;

View File

@ -0,0 +1,23 @@
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;

View File

@ -0,0 +1,25 @@
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;

View File

@ -0,0 +1,49 @@
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>
);
}

View File

@ -0,0 +1,83 @@
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="orgName"
type="text"
label="Organization name"
disabled={isSubmitting}
error={actionData?.errors?.orgName}
tabIndex={1}
/>
<LabeledTextField
name="fullName"
type="text"
label="Full name"
disabled={isSubmitting}
error={actionData?.errors?.fullName}
tabIndex={2}
/>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={3}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={4}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={5}
>
Register
</Button>
</Form>
</section>
);
}

View File

@ -0,0 +1,55 @@
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>
);
}

View File

@ -0,0 +1,75 @@
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?&nbsp;
<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>
);
}

View File

@ -0,0 +1,41 @@
import { z } from "zod";
export const password = z.string().min(10).max(100);
export const Register = z.object({
orgName: z.string().nonempty(),
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(),
});

View File

@ -0,0 +1,51 @@
import type { FunctionComponent, ReactChild } from "react";
type AlertVariant = "error" | "success" | "info" | "warning";
type AlertVariantProps = {
backgroundColor: string;
titleTextColor: string;
messageTextColor: string;
};
type Props = {
title: ReactChild;
message: ReactChild;
variant: AlertVariant;
};
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
error: {
backgroundColor: "bg-red-50",
titleTextColor: "text-red-800",
messageTextColor: "text-red-700",
},
success: {
backgroundColor: "bg-green-50",
titleTextColor: "text-green-800",
messageTextColor: "text-green-700",
},
info: {
backgroundColor: "bg-primary-50",
titleTextColor: "text-primary-800",
messageTextColor: "text-primary-700",
},
warning: {
backgroundColor: "bg-yellow-50",
titleTextColor: "text-yellow-800",
messageTextColor: "text-yellow-700",
},
};
const Alert: FunctionComponent<Props> = ({ title, message, variant }) => {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
</div>
);
};
export default Alert;

View File

@ -0,0 +1,26 @@
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;

View File

@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { NavLink } from "@remix-run/react";
import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5";
import clsx from "clsx";
export default function Footer() {
return (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
style={{ flex: "0 0 50px" }}
>
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
<FooterLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
<FooterLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
</footer>
);
}
type FooterLinkProps = {
path: string;
label: string;
icon: ReactNode;
};
function FooterLink({ path, label, icon }: FooterLinkProps) {
return (
<div className="flex flex-col items-center justify-around h-full">
<NavLink
to={path}
prefetch="none"
className={({ isActive }) =>
clsx("flex flex-col items-center", {
"text-primary-500": isActive,
"text-[#959595]": !isActive,
})
}
>
{icon}
<span className="text-xs">{label}</span>
</NavLink>
</div>
);
}

View File

@ -0,0 +1,35 @@
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">&#8203;</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&#39;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 up 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>
);
}

View File

@ -0,0 +1,46 @@
import type { FunctionComponent, InputHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
type Props = {
name: string;
label: ReactNode;
sideLabel?: ReactNode;
type?: "text" | "password" | "email";
error?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const LabeledTextField: FunctionComponent<Props> = ({ name, label, sideLabel, type = "text", error, ...props }) => {
const hasSideLabel = !!sideLabel;
return (
<div className="mb-6">
<label
htmlFor={name}
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !hasSideLabel,
"flex justify-between": hasSideLabel,
})}
>
{label}
{sideLabel ?? null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id={name}
name={name}
type={type}
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"
required
{...props}
/>
</div>
{error ? (
<div role="alert" className="text-red-600">
{error}
</div>
) : null}
</div>
);
};
export default LabeledTextField;

View File

@ -0,0 +1,13 @@
import type { FunctionComponent } from "react";
type Props = {
className?: string;
};
const Logo: FunctionComponent<Props> = ({ className }) => (
<div className={className}>
<img src="/shellphone.png" alt="app logo" />
</div>
);
export default Logo;

View File

@ -0,0 +1,25 @@
import { Link } from "@remix-run/react";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function MissingTwilioCredentials() {
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">
<Link
to="/settings/account"
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"
>
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Set up my phone number
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react";
import { Fragment } from "react";
import { Transition, Dialog } from "@headlessui/react";
type Props = {
initialFocus?: MutableRefObject<HTMLElement | null> | undefined;
isOpen: boolean;
onClose: () => void;
};
const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog
className="fixed z-30 inset-0 overflow-y-auto"
initialFocus={initialFocus}
onClose={onClose}
open={isOpen}
static
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center md:block md:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden md:inline-block md:align-middle md:h-screen">&#8203;</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
enterTo="opacity-100 translate-y-0 md:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 md:scale-100"
leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
{children}
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
export const ModalTitle: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{children}
</Dialog.Title>
);
export default Modal;

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

@ -0,0 +1,23 @@
export default function PhoneInitLoader() {
return (
<div className="px-4 my-auto text-center space-y-2">
<svg
className="animate-spin mx-auto h-5 w-5 text-primary-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p>We&#39;re finalizing your &#128026;phone initialization.</p>
<p>
You don&#39;t have to refresh this page, we will do it automatically for you when your phone is ready.
</p>
</div>
);
}

View File

@ -0,0 +1,68 @@
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>
);
}

View File

@ -0,0 +1,15 @@
.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);
}
}

View File

@ -0,0 +1,15 @@
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>
);
}

View File

@ -0,0 +1,52 @@
import { DateTime } from "luxon";
export function formatDate(date: Date, config?: Intl.DateTimeFormatOptions): string {
const dateFormatter = Intl.DateTimeFormat(
"en-US",
config ?? {
day: "2-digit",
month: "short",
year: "numeric",
},
);
return dateFormatter.format(date);
}
export function formatTime(date: Date, config?: Intl.DateTimeFormatOptions): string {
const timeFormatter = Intl.DateTimeFormat(
"en-US",
config ?? {
hour: "2-digit",
minute: "2-digit",
},
);
return timeFormatter.format(date);
}
export function formatRelativeDate(date: Date): string {
const dateTime = DateTime.fromJSDate(date);
const now = new Date();
const diff = dateTime.diffNow("days");
const isToday =
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear();
if (isToday) {
return dateTime.toFormat("HH:mm", { locale: "en-US" });
}
const isYesterday = diff.days >= -2;
if (isYesterday) {
return "Yesterday";
}
const isDuringLastWeek = diff.days >= -7;
if (isDuringLastWeek) {
return dateTime.weekdayLong;
}
return dateTime.toFormat("dd/MM/yyyy", { locale: "en-US" });
}

View File

@ -0,0 +1,13 @@
import { useMatches } from "@remix-run/react";
import type { SessionOrganization, SessionUser } from "~/utils/auth.server";
export default function useSession() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as SessionUser & { currentOrganization: SessionOrganization };
}

View File

@ -0,0 +1,54 @@
import type { FunctionComponent } from "react";
import { useRef } from "react";
import { useNavigate } from "@remix-run/react";
import { Link } from "react-router-dom";
import Modal, { ModalTitle } from "~/features/core/components/modal";
type Props = {
isOpen: boolean;
closeModal: () => void;
};
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
const navigate = useNavigate();
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 to="/settings/phone" className="underline">
phone settings
</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 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={() => navigate("/settings/phone")}
>
Take me there
</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}
>
I got it, thanks!
</button>
</div>
</Modal>
);
};
export default KeypadErrorModal;

View File

@ -0,0 +1,90 @@
import type { FunctionComponent, PropsWithChildren } from "react";
import type { PressHookProps } from "@react-aria/interactions";
import { usePress } from "@react-aria/interactions";
type Props = {
onDigitPressProps: (digit: string) => PressHookProps;
onZeroPressProps: PressHookProps;
};
const Keypad: FunctionComponent<PropsWithChildren<Props>> = ({ children, onDigitPressProps, onZeroPressProps }) => {
return (
<section>
<Row>
<Digit onPressProps={onDigitPressProps} digit="1" />
<Digit onPressProps={onDigitPressProps} digit="2">
<DigitLetters>ABC</DigitLetters>
</Digit>
<Digit onPressProps={onDigitPressProps} digit="3">
<DigitLetters>DEF</DigitLetters>
</Digit>
</Row>
<Row>
<Digit onPressProps={onDigitPressProps} digit="4">
<DigitLetters>GHI</DigitLetters>
</Digit>
<Digit onPressProps={onDigitPressProps} digit="5">
<DigitLetters>JKL</DigitLetters>
</Digit>
<Digit onPressProps={onDigitPressProps} digit="6">
<DigitLetters>MNO</DigitLetters>
</Digit>
</Row>
<Row>
<Digit onPressProps={onDigitPressProps} digit="7">
<DigitLetters>PQRS</DigitLetters>
</Digit>
<Digit onPressProps={onDigitPressProps} digit="8">
<DigitLetters>TUV</DigitLetters>
</Digit>
<Digit onPressProps={onDigitPressProps} digit="9">
<DigitLetters>WXYZ</DigitLetters>
</Digit>
</Row>
<Row>
<Digit onPressProps={onDigitPressProps} digit="*" />
<ZeroDigit onPressProps={onZeroPressProps} />
<Digit onPressProps={onDigitPressProps} digit="#" />
</Row>
{typeof children !== "undefined" ? <Row>{children}</Row> : null}
</section>
);
};
export default Keypad;
const Row: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div>
);
const DigitLetters: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
type DigitProps = {
digit: string;
onPressProps: Props["onDigitPressProps"];
};
const Digit: FunctionComponent<PropsWithChildren<DigitProps>> = ({ children, digit, onPressProps }) => {
const { pressProps } = usePress(onPressProps(digit));
return (
<div {...pressProps} className="text-3xl cursor-pointer select-none">
{digit}
{children}
</div>
);
};
type ZeroDigitProps = {
onPressProps: Props["onZeroPressProps"];
};
const ZeroDigit: FunctionComponent<PropsWithChildren<ZeroDigitProps>> = ({ onPressProps }) => {
const { pressProps } = usePress(onPressProps);
return (
<div {...pressProps} className="text-3xl cursor-pointer select-none">
0 <DigitLetters>+</DigitLetters>
</div>
);
};

View File

@ -0,0 +1,17 @@
import { useCallback, useEffect } from "react";
export default function useKeyPress(onKeyPress: (key: string) => void) {
const onKeyDown = useCallback(
({ key }: KeyboardEvent) => {
onKeyPress(key);
},
[onKeyPress],
);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
}

View File

@ -0,0 +1,81 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import { useLoaderData, useParams } from "@remix-run/react";
import clsx from "clsx";
import { Direction } from "@prisma/client";
import NewMessageArea from "./new-message-area";
import { formatDate, formatTime } from "~/features/core/helpers/date-formatter";
import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient";
export default function Conversation() {
const params = useParams<{ recipient: string }>();
const recipient = decodeURIComponent(params.recipient ?? "");
const { conversation } = useLoaderData<ConversationLoaderData>();
const messages = useMemo(() => conversation?.messages ?? [], [conversation?.messages]);
const messagesListRef = useRef<HTMLUListElement>(null);
useEffect(() => {
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
}, [messages, messagesListRef]);
return (
<>
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
<ul ref={messagesListRef}>
{messages.length === 0 ? "empty state" : null}
{messages.map((message, index) => {
const isOutbound = message.direction === Direction.Outbound;
const nextMessage = messages![index + 1];
const previousMessage = messages![index - 1];
const isNextMessageFromSameSender = message.from === nextMessage?.from;
const isPreviousMessageFromSameSender = message.from === previousMessage?.from;
const messageSentAt = new Date(message.sentAt);
const previousMessageSentAt = previousMessage ? new Date(previousMessage.sentAt) : null;
const quarter = Math.floor(messageSentAt.getMinutes() / 15);
const sameQuarter =
previousMessage &&
messageSentAt.getTime() - previousMessageSentAt!.getTime() < 15 * 60 * 1000 &&
quarter === Math.floor(previousMessageSentAt!.getMinutes() / 15);
const shouldGroupMessages = previousMessageSentAt && sameQuarter;
return (
<li key={message.id}>
{(!isPreviousMessageFromSameSender || !shouldGroupMessages) && (
<div className="flex py-2 space-x-1 text-xs justify-center">
<strong>
{formatDate(new Date(message.sentAt), {
weekday: "long",
day: "2-digit",
month: "short",
})}
</strong>
<span>{formatTime(new Date(message.sentAt))}</span>
</div>
)}
<div
className={clsx(
isNextMessageFromSameSender ? "pb-1" : "pb-2",
isOutbound ? "text-right" : "text-left",
)}
>
<span
className={clsx(
"inline-block whitespace-pre-line text-left w-[fit-content] p-2 rounded-lg text-white",
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
)}
>
{message.content}
</span>
</div>
</li>
);
})}
</ul>
</div>
<Suspense fallback={null}>
<NewMessageArea recipient={recipient} />
</Suspense>
</>
);
}

View File

@ -0,0 +1,41 @@
import { Link, useLoaderData } from "@remix-run/react";
import { IoChevronForward } from "react-icons/io5";
import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
import PhoneInitLoader from "~/features/core/components/phone-init-loader";
import EmptyMessages from "./empty-messages";
import type { MessagesLoaderData } from "~/routes/__app/messages";
export default function ConversationsList() {
const { conversations } = useLoaderData<MessagesLoaderData>();
if (!conversations) {
// we're still importing messages from twilio
return <PhoneInitLoader />;
}
if (Object.keys(conversations).length === 0) {
return <EmptyMessages />;
}
return (
<ul className="divide-y">
{Object.values(conversations).map(({ recipient, formattedPhoneNumber, lastMessage }) => {
return (
<li key={`sms-conversation-${recipient}`} className="py-2 px-4">
<Link to={`/messages/${recipient}`} className="flex flex-col">
<div className="flex flex-row justify-between">
<span className="font-medium">{formattedPhoneNumber ?? recipient}</span>
<div className="text-gray-700 flex flex-row gap-x-1">
{formatRelativeDate(lastMessage.sentAt)}
<IoChevronForward className="w-4 h-4 my-auto" />
</div>
</div>
<div className="line-clamp-2 text-gray-700">{lastMessage.content}</div>
</Link>
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,32 @@
import { IoCreateOutline, IoMailOutline } from "react-icons/io5";
// import { useAtom } from "jotai";
// import { bottomSheetOpenAtom } from "../pages/messages";
export default function EmptyMessages() {
// const setIsBottomSheetOpen = useAtom(bottomSheetOpenAtom)[1];
// const openNewMessageArea = () => setIsBottomSheetOpen(true);
const openNewMessageArea = () => void 0;
return (
<div className="text-center my-auto">
<IoMailOutline 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&#39;t have any messages yet</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by sending a message
<br />
to someone you know.
</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={openNewMessageArea}
>
<IoCreateOutline className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Type a new message
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import type { FunctionComponent } from "react";
import { IoSend } from "react-icons/io5";
import { type Message, Direction, MessageStatus } from "@prisma/client";
import useSession from "~/features/core/hooks/use-session";
type Props = {
recipient: string;
onSend?: () => void;
};
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
const { currentOrganization, /*hasOngoingSubscription*/ } = useSession();
// const phoneNumber = useCurrentPhoneNumber();
// const sendMessageMutation = useMutation(sendMessage)[0];
const onSubmit = async () => {
/*const id = uuidv4();
const message: Message = {
id,
organizationId: organization!.id,
phoneNumberId: phoneNumber!.id,
from: phoneNumber!.number,
to: recipient,
content: hasOngoingSubscription
? content
: content + "\n\nSent from Shellphone (https://www.shellphone.app)",
direction: Direction.Outbound,
status: MessageStatus.Queued,
sentAt: new Date(),
};*/
/*await setConversationsQueryData(
(conversations) => {
const nextConversations = { ...conversations };
if (!nextConversations[recipient]) {
nextConversations[recipient] = {
recipient,
formattedPhoneNumber: recipient,
messages: [],
};
}
nextConversations[recipient]!.messages = [...nextConversations[recipient]!.messages, message];
return Object.fromEntries(
Object.entries(nextConversations).sort(
([, a], [, b]) =>
b.messages[b.messages.length - 1]!.sentAt.getTime() -
a.messages[a.messages.length - 1]!.sentAt.getTime(),
),
);
},
{ refetch: false },
);*/
// setValue("content", "");
// onSend?.();
};
return (
<form
onSubmit={onSubmit}
className="absolute bottom-0 w-screen backdrop-filter backdrop-blur-xl bg-white bg-opacity-75 border-t flex flex-row h-16 p-2 pr-0"
>
<textarea
name="content"
className="resize-none flex-1"
autoCapitalize="sentences"
autoCorrect="on"
placeholder="Text message"
rows={1}
spellCheck
required
/>
<button type="submit">
<IoSend className="h-8 w-8 pl-1 pr-2" />
</button>
</form>
);
};
export default NewMessageArea;
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@ -0,0 +1,56 @@
import type { LoaderFunction } from "@remix-run/node";
import { type Message, Prisma, Direction, SubscriptionStatus } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
export type MessagesLoaderData = {
user: {
hasFilledTwilioCredentials: boolean;
hasPhoneNumber: boolean;
};
conversations: Record<string, Conversation> | undefined;
};
type Conversation = {
recipient: string;
formattedPhoneNumber: string;
messages: Message[];
};
const loader: LoaderFunction = async ({ request }) => {
const { id, organizations } = await requireLoggedIn(request);
const user = await db.user.findFirst({
where: { id },
select: {
id: true,
fullName: true,
email: true,
role: true,
memberships: {
include: {
organization: {
include: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
},
},
},
},
});
const organization = user!.memberships[0]!.organization;
// const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken);
};
export default loader;

View File

@ -0,0 +1,34 @@
import { Link } from "@remix-run/react";
import { IoKeypad } from "react-icons/io5";
export default function EmptyMessages() {
return (
<div className="text-center my-auto">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M34 40h10v-4a6 6 0 00-10.712-3.714M34 40H14m20 0v-4a9.971 9.971 0 00-.712-3.714M14 40H4v-4a6 6 0 0110.713-3.714M14 40v-4c0-1.313.253-2.566.713-3.714m0 0A10.003 10.003 0 0124 26c4.21 0 7.813 2.602 9.288 6.286M30 14a6 6 0 11-12 0 6 6 0 0112 0zm12 6a4 4 0 11-8 0 4 4 0 018 0zm-28 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">You don&#39;t have any phone calls yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by calling someone you know.</p>
<div className="mt-6">
<Link
to="/keypad"
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
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { Link, useLoaderData } from "@remix-run/react";
import { HiPhoneMissedCall, HiPhoneIncoming, HiPhoneOutgoing } from "react-icons/hi";
import clsx from "clsx";
import { Direction, CallStatus } from "@prisma/client";
import PhoneInitLoader from "~/features/core/components/phone-init-loader";
import EmptyCalls from "../components/empty-calls";
import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
import type { PhoneCallsLoaderData } from "~/routes/__app/calls";
export default function PhoneCallsList() {
const { hasOngoingSubscription } = { hasOngoingSubscription: false };
const { phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
if (!phoneCalls) {
return hasOngoingSubscription ? <PhoneInitLoader /> : null;
}
if (phoneCalls.length === 0) {
return hasOngoingSubscription ? <EmptyCalls /> : null;
}
return (
<ul className="divide-y">
{phoneCalls.map((phoneCall) => {
const isOutboundCall = phoneCall.direction === Direction.Outbound;
const isInboundCall = phoneCall.direction === Direction.Inbound;
const isMissedCall = isInboundCall && phoneCall.status === CallStatus.NoAnswer;
const formattedRecipient = isOutboundCall
? phoneCall.toMeta.formattedPhoneNumber
: phoneCall.fromMeta.formattedPhoneNumber;
const recipient = isOutboundCall ? phoneCall.to : phoneCall.from;
return (
<li key={phoneCall.id} className="py-2 px-4 hover:bg-gray-200 hover:bg-opacity-50">
<Link to={`/outgoing-call/${recipient}`} className="flex flex-row">
<div className="h-4 w-4 mt-1">
{isOutboundCall ? <HiPhoneOutgoing className="text-[#C4C4C6]" /> : null}
{isInboundCall && !isMissedCall ? <HiPhoneIncoming className="text-[#C4C4C6]" /> : null}
{isInboundCall && isMissedCall ? (
<HiPhoneMissedCall className="text-[#C4C4C6]" />
) : null}
</div>
<div className="flex flex-col items-start justify-center ml-4">
<span className={clsx("font-medium", isMissedCall && "text-[#FF362A]")}>
{formattedRecipient}
</span>
<span className="text-[#89898C] text-sm">
{isOutboundCall ? phoneCall.toMeta.country : phoneCall.fromMeta.country}
</span>
</div>
<span className="text-[#89898C] text-sm self-center ml-auto">
{formatRelativeDate(new Date(phoneCall.createdAt))}
</span>
</Link>
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,20 @@
import type { FunctionComponent, PropsWithChildren } from "react";
import Header from "./header";
import Footer from "./footer";
const BaseLayout: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<>
<section className="font-inter antialiased bg-white text-gray-900 tracking-tight">
<section className="flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-grow">{children}</main>
<Footer />
</section>
</section>
</>
);
export default BaseLayout;

View File

@ -0,0 +1,31 @@
import { useState } from "react";
export default function CTAForm() {
const [{ isSubmitted }, setState] = useState({ isSubmitted: false });
const onSubmit = () => setState({ isSubmitted: true });
return (
<form onSubmit={onSubmit}>
{isSubmitted ? (
<p className="text-center md:text-left mt-2 opacity-75 text-green-900 text-md">
You&#39;re on the list! We will be in touch soon
</p>
) : (
<div className="flex flex-col sm:flex-row justify-center w-full md:max-w-md md:mx-0">
<input
name="email"
type="email"
className="form-input w-full mb-2 sm:mb-0 sm:mr-2 focus:outline-none focus:ring-rebeccapurple-500 focus:border-rebeccapurple-500"
placeholder="Enter your email address"
/>
<button
type="submit"
className="btn text-white bg-rebeccapurple-500 hover:bg-rebeccapurple-400 flex-shrink-0"
>
Request access
</button>
</div>
)}
</form>
);
}

View File

@ -0,0 +1,86 @@
import type { FunctionComponent, PropsWithChildren } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import clsx from "clsx";
export default 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&#39;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&#39;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>
);
};

View File

@ -0,0 +1,40 @@
import type { FunctionComponent } from "react";
import { Link, type LinkProps } from "@remix-run/react";
export default function Footer() {
// TODO
const isDisabled = true;
if (isDisabled) {
return null;
}
return (
<footer className="bg-white">
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
<nav className="-mx-5 -my-2 flex flex-wrap justify-center" aria-label="Footer">
<FooterLink to="/blog" name="Blog" />
<FooterLink to="/privacy" name="Privacy Policy" />
<FooterLink to="/terms" name="Terms of Service" />
<FooterLink to="mailto:support@shellphone.app" name="Email Us" />
</nav>
<p className="mt-8 text-center text-base text-gray-400">
&copy; 2021 Capsule Corp. Dev Pte. Ltd. All rights reserved.
{/*&copy; 2021 Mokhtar Mial All rights reserved.*/}
</p>
</div>
</footer>
);
}
type Props = {
to: LinkProps["to"];
name: string;
};
const FooterLink: FunctionComponent<Props> = ({ to, name }) => (
<div className="px-5 py-2">
<Link to={to} className="text-base text-gray-500 hover:text-gray-900">
{name}
</Link>
</div>
);

View File

@ -0,0 +1,240 @@
import { Fragment, useState, useRef, useEffect } from "react";
import { Link, type LinkProps } from "@remix-run/react";
import { Dialog, Transition } from "@headlessui/react";
import { IoClose } from "react-icons/io5";
function Header() {
return (
<header className="absolute inset-x-0 top-0 z-10 w-full">
<div className="px-4 mx-auto sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 lg:h-20">
<div className="hidden lg:flex lg:items-center lg:justify-center lg:ml-10 lg:mr-auto lg:space-x-10">
<a
href="#"
title=""
className="text-base text-black transition-all duration-200 hover:text-opacity-80"
>
{" "}
Features{" "}
</a>
<a
href="#"
title=""
className="text-base text-black transition-all duration-200 hover:text-opacity-80"
>
{" "}
Solutions{" "}
</a>
<a
href="#"
title=""
className="text-base text-black transition-all duration-200 hover:text-opacity-80"
>
{" "}
Resources{" "}
</a>
<a
href="#"
title=""
className="text-base text-black transition-all duration-200 hover:text-opacity-80"
>
{" "}
Pricing{" "}
</a>
</div>
</div>
</div>
</header>
);
}
function Headerold() {
return (
<header className="absolute w-full z-30 inset-x-0 top-0">
<div className="px-4 mx-auto sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20">
<div className="flex-shrink-0 mr-5">
<Link to="/" className="block">
<img className="w-10 h-10" src="/shellphone.png" alt="Shellphone logo" />
</Link>
</div>
<nav className="hidden md:flex md:flex-grow">
<ul className="flex items-center justify-center ml-10 mr-auto space-x-10">
<li>
<DesktopNavLink to="/features" label="Features" />
</li>
<li>
<DesktopNavLink to="/roadmap" label="Roadmap" />
</li>
<li>
<DesktopNavLink to="/open" label="Open Metrics" />
</li>
<li>
<DesktopNavLink to="/pricing" label="Pricing" />
</li>
</ul>
</nav>
<MobileNav />
</div>
</div>
</header>
);
}
type NavLinkProps = {
to: LinkProps["to"];
label: string;
};
function DesktopNavLink({ to, label }: NavLinkProps) {
return (
<Link to={to} className="text-base text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out">
{label}
</Link>
);
}
function MobileNav() {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const mobileNav = useRef<HTMLDivElement>(null);
// close the mobile menu on click outside
useEffect(() => {
const clickHandler = ({ target }: MouseEvent) => {
if (!mobileNav.current || !trigger.current) {
return;
}
console.log(mobileNav.current.contains(target as Node));
if (
!mobileNavOpen ||
mobileNav.current.contains(target as Node) ||
trigger.current.contains(target as Node)
) {
return;
}
setMobileNavOpen(false);
};
document.addEventListener("click", clickHandler);
return () => document.removeEventListener("click", clickHandler);
});
// close the mobile menu if the esc key is pressed
useEffect(() => {
const keyHandler = ({ keyCode }: KeyboardEvent) => {
if (!mobileNavOpen || keyCode !== 27) return;
setMobileNavOpen(false);
};
document.addEventListener("keydown", keyHandler);
return () => document.removeEventListener("keydown", keyHandler);
});
return (
<div className="inline-flex md:hidden">
<button
ref={trigger}
className={`hamburger ${mobileNavOpen && "active"}`}
aria-controls="mobile-nav"
aria-expanded={mobileNavOpen}
onClick={() => setMobileNavOpen(!mobileNavOpen)}
>
<span className="sr-only">Menu</span>
<svg
className="w-6 h-6 fill-current text-gray-900 hover:text-gray-900 transition duration-150 ease-in-out"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="4" width="24" height="2" rx="1" />
<rect y="11" width="24" height="2" rx="1" />
<rect y="18" width="24" height="2" rx="1" />
</svg>
</button>
<Transition.Root show={mobileNavOpen} as={Fragment}>
<Dialog as="div" className="fixed z-40 inset-0 overflow-hidden" onClose={setMobileNavOpen}>
<div className="absolute inset-0 overflow-hidden">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="absolute inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div ref={mobileNav} className="w-screen max-w-[16rem] sm:max-w-sm">
<div className="h-full flex flex-col py-6 bg-white shadow-xl overflow-y-scroll">
<div className="px-4 sm:px-6">
<div className="flex items-start justify-between">
<Dialog.Title className="text-lg font-medium text-gray-900">
Shellphone
</Dialog.Title>
<div className="ml-3 h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rebeccapurple-500"
onClick={() => setMobileNavOpen(false)}
>
<span className="sr-only">Close panel</span>
<IoClose className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div className="mt-6 relative flex-1 px-4 sm:px-6">
<div className="absolute inset-0 px-4 sm:px-6">
<ul className="space-y-4">
<li>
<MobileNavLink to="/features" label="Features" />
</li>
<li>
<MobileNavLink to="/roadmap" label="Roadmap" />
</li>
<li>
<MobileNavLink to="open" label="Open Metrics" />
</li>
<li>
<MobileNavLink to="/pricing" label="Pricing" />
</li>
</ul>
</div>
</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</div>
);
function MobileNavLink({ to, label }: NavLinkProps) {
return (
<Link to={to} onClick={() => setMobileNavOpen(false)} className="text-base flex text-gray-600 hover:text-gray-900">
{label}
</Link>
);
}
}
export default Headerold;

View File

@ -0,0 +1,51 @@
import CTAForm from "./cta-form";
import mockupImage from "../images/phone-mockup.png";
export default function Hero() {
return (
<div className="relative bg-gradient-to-b from-rebeccapurple-100 to-rebeccapurple-200">
<section className="overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-stretch lg:min-h-screen lg:max-h-[900px]">
<div className="flex items-center justify-center w-full lg:order-2 lg:w-7/12">
<div className="h-full px-4 pt-24 pb-16 sm:px-6 lg:px-24 2xl:px-32 lg:pt-40 lg:pb-14">
<div className="flex flex-col flex-1 justify-center h-full space-y-8">
<h1 className="font-heading text-4xl leading-none lg:leading-tight xl:text-5xl xl:leading-tight">
<span className="bg-gradient-to-br from-rebeccapurple-500 to-indigo-600 bg-clip-text decoration-clone text-transparent">
Take your phone number
</span>{" "}
<span className="text-[#24185B]">anywhere you dgo</span>
</h1>
<p className="text-base lg:text-lg xl:text-xl text-black">
Coming soon! &#128026; Keep your phone number and pay less for your communications,
even abroad.
</p>
<CTAForm />
<div className="max-w-lg mx-auto md:mx-0">
<span className="block md:inline mx-2">
<em> </em>Free trial
</span>
<span className="block md:inline mx-2">
<em> </em>No credit card required
</span>
<span className="block md:inline mx-2">
<em> </em>Cancel anytime
</span>
</div>
</div>
</div>
</div>
<div className="relative w-full overflow-hidden lg:w-5/12 lg:order-1">
<div className="lg:absolute lg:bottom-0 lg:left-0">
<img className="w-full" src={mockupImage} alt="App screenshot on a phone" />
</div>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { FunctionComponent, PropsWithChildren } from "react";
import BaseLayout from "./base-layout";
type Props = {
title?: string;
};
const Layout: FunctionComponent<PropsWithChildren<Props>> = ({ children, title }) => (
<BaseLayout>
<section className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="pt-32 pb-10 md:pt-34 md:pb-16">
{title ? (
<div className="max-w-5xl mx-auto">
<h1 className="h1 mb-16 text-navy font-extrabold font-mackinac">{title}</h1>
</div>
) : null}
<div className="max-w-3xl mx-auto text-lg xl:text-xl flow-root">{children}</div>
</div>
</section>
</BaseLayout>
);
export default Layout;

View File

@ -0,0 +1,36 @@
import { IoClose } from "react-icons/io5";
export default function ReferralBanner() {
// TODO
const isDisabled = true;
if (isDisabled) {
return null;
}
return (
<div className="relative bg-rebeccapurple-600 z-40">
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
<div className="pr-16 sm:text-center sm:px-16">
<p className="font-medium text-white">
<span>&#127881; New: Get one month free for every friend that joins and subscribe!</span>
<span className="block sm:ml-2 sm:inline-block">
<a href="#" className="text-white font-bold underline">
{" "}
Learn more <span aria-hidden="true">&rarr;</span>
</a>
</span>
</p>
</div>
<div className="absolute inset-y-0 right-0 pt-1 pr-1 flex items-start sm:pt-1 sm:pr-2 sm:items-start">
<button
type="button"
className="flex p-2 rounded-md hover:bg-rebeccapurple-500 focus:outline-none focus:ring-2 focus:ring-white"
>
<span className="sr-only">Dismiss</span>
<IoClose className="h-6 w-6 text-white" aria-hidden="true" />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
export default function Testimonials() {
return (
<div className="bg-rebeccapurple-600">
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8">
<p className="text-xl text-white text-center text-base font-semibold uppercase text-gray-600 tracking-wider">
Trusted by digital nomads in
<div className="h-[2rem] relative flex">
<span className="location">Bali</span>
<span className="location">Tulum</span>
<span className="location">Tbilissi</span>
<span className="location">Bansko</span>
<span className="location">Zanzibar</span>
<span className="location">Mauritius</span>
<span className="location">Amsterdam</span>
</div>
</p>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -0,0 +1,23 @@
import Header from "../components/header";
import Footer from "../components/footer";
import ReferralBanner from "../components/referral-banner";
import Hero from "../components/hero";
import FAQs from "../components/faqs";
export default function IndexPage() {
return (
<section className="font-inter antialiased bg-white text-gray-900 tracking-tight">
<section className="flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-grow">
<ReferralBanner />
<Hero />
<FAQs />
</main>
<Footer />
</section>
</section>
);
}

View File

@ -0,0 +1,90 @@
import { useRef, useState } from "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 [isDeletingUser, setIsDeletingUser] = useState(false);
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
const closeModal = () => {
if (isDeletingUser) {
return;
}
setIsConfirmationModalOpen(false);
};
const onConfirm = () => {
setIsDeletingUser(true);
// return deleteUserMutation(); // TODO
};
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">
<button
type="button"
className={clsx(
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 md:ml-3 md:w-auto md:text-sm",
{
"bg-red-400 cursor-not-allowed": isDeletingUser,
"bg-red-600 hover:bg-red-700": !isDeletingUser,
},
)}
onClick={onConfirm}
disabled={isDeletingUser}
>
Delete my account
</button>
<button
ref={modalCancelButtonRef}
type="button"
className={clsx(
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
{
"bg-gray-50 cursor-not-allowed": isDeletingUser,
"hover:bg-gray-50": !isDeletingUser,
},
)}
onClick={closeModal}
disabled={isDeletingUser}
>
Cancel
</button>
</div>
</Modal>
</SettingsSection>
);
}

View File

@ -0,0 +1,83 @@
import type { FunctionComponent } from "react";
import { useActionData, useTransition } from "@remix-run/react";
import Alert from "../../../core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
import useSession from "~/features/core/hooks/use-session";
const ProfileInformations: FunctionComponent = () => {
const user = useSession();
const transition = useTransition();
const actionData = useActionData();
const isSubmitting = transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const error = actionData?.error;
const isError = !!error;
const onSubmit = async () => {
// await updateUserMutation({ email, fullName }); // TODO
};
return (
<form onSubmit={onSubmit}>
<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}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={error} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<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>
</SettingsSection>
</form>
);
};
export default ProfileInformations;

View File

@ -0,0 +1,85 @@
import type { FunctionComponent } from "react";
import Alert from "~/features/core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
import { useActionData, useTransition } from "@remix-run/react";
const UpdatePassword: FunctionComponent = () => {
const transition = useTransition();
const actionData = useActionData();
const isSubmitting = transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const error = actionData?.error;
const isError = !!error;
const onSubmit = async () => {
// await changePasswordMutation({ currentPassword, newPassword }); // TODO
};
return (
<form onSubmit={onSubmit}>
<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}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={error} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div>
<label
htmlFor="currentPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Current password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="currentPassword"
name="currentPassword"
type="password"
tabIndex={3}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
required
/>
</div>
</div>
<div>
<label
htmlFor="newPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>New password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPassword"
name="newPassword"
type="password"
tabIndex={4}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
required
/>
</div>
</div>
</SettingsSection>
</form>
);
};
export default UpdatePassword;

View File

@ -0,0 +1,172 @@
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>
);
}

View File

@ -0,0 +1,18 @@
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;

View File

@ -0,0 +1,139 @@
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,
},
],
};

View File

@ -0,0 +1,52 @@
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&#39;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&#39;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;

View File

@ -0,0 +1,48 @@
import type { ButtonHTMLAttributes, FunctionComponent, MouseEventHandler, PropsWithChildren } from "react";
import clsx from "clsx";
type Props = {
variant: Variant;
onClick?: MouseEventHandler;
isDisabled?: boolean;
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
};
const Button: FunctionComponent<PropsWithChildren<Props>> = ({ children, type, variant, onClick, isDisabled }) => {
return (
<button
onClick={onClick}
type={type}
className={clsx(
"inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2",
{
[VARIANTS_STYLES[variant].base]: !isDisabled,
[VARIANTS_STYLES[variant].disabled]: isDisabled,
},
)}
disabled={isDisabled}
>
{children}
</button>
);
};
export default Button;
type Variant = "error" | "default";
type VariantStyle = {
base: string;
disabled: string;
};
const VARIANTS_STYLES: Record<Variant, VariantStyle> = {
error: {
base: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
disabled: "bg-red-400 cursor-not-allowed focus:ring-red-500",
},
default: {
base: "bg-primary-600 hover:bg-primary-700 focus:ring-primary-500",
disabled: "bg-primary-400 cursor-not-allowed focus:ring-primary-500",
},
};

View File

@ -0,0 +1,11 @@
import clsx from "clsx";
export default function Divider({ className = "" }) {
return (
<div className={clsx(className, "relative")}>
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import type { FunctionComponent } from "react";
import { useRef } from "react";
import Modal, { ModalTitle } from "~/features/core/components/modal";
type Props = {
isHelpModalOpen: boolean;
closeModal: () => void;
};
const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) => {
const modalCloseButtonRef = useRef<HTMLButtonElement>(null);
return (
<Modal initialFocus={modalCloseButtonRef} isOpen={isHelpModalOpen} 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>Need help finding your Twilio credentials?</ModalTitle>
<div className="mt-6 space-y-3 text-gray-500">
<p>
You can check out our{" "}
<a className="underline" href="https://docs.shellphone.app/guide/getting-started">
getting started
</a>{" "}
guide to set up your account with your Twilio credentials.
</p>
<p>
If you feel 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&#39;t miss out on free $10 Twilio credit by using{" "}
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
our referral link
</a>
.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
ref={modalCloseButtonRef}
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"
onClick={closeModal}
>
Noted, thanks the help!
</button>
</div>
</Modal>
);
};
export default HelpModal;

View File

@ -0,0 +1,94 @@
import { useActionData, useCatch, useTransition } from "@remix-run/react";
import Button from "../button";
import SettingsSection from "../settings-section";
import Alert from "~/features/core/components/alert";
export default function PhoneNumberForm() {
const transition = useTransition();
const actionData = useActionData();
const isSubmitting = transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const error = actionData?.error;
const isError = !!error;
const hasFilledTwilioCredentials = false; // Boolean(currentOrganization?.twilioAccountSid && currentOrganization?.twilioAuthToken)
const availablePhoneNumbers: any[] = [];
const onSubmit = async () => {
// await setPhoneNumberMutation({ phoneNumberSid }); // TODO
};
if (!hasFilledTwilioCredentials) {
return null;
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<SettingsSection
className="relative"
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}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={error} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
Phone number
</label>
<select
id="phoneNumberSid"
name="phoneNumberSid"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
>
<option value="none" />
{availablePhoneNumbers?.map(({ sid, phoneNumber }) => (
<option value={sid} key={sid}>
{phoneNumber}
</option>
))}
</select>
</SettingsSection>
</form>
);
}
export function CatchBoundary() {
const caught = useCatch();
return (
<Alert
variant="error"
title="Authorization error"
message={
<>
<p>
We failed to fetch your Twilio phone numbers. Make sure both your SID and your auth token are
valid and that your Twilio account isn&#39;t suspended.
{caught.data ? <a href={caught.data.moreInfo}>Related Twilio docs</a> : null}
</p>
<button className="inline-flex pt-2 text-left" onClick={window.location.reload}>
<span className="transition-colors duration-150 border-b border-red-200 hover:border-red-500">
Try again
</span>
</button>
</>
}
/>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from "react";
import { useTransition } from "@remix-run/react";
import { IoHelpCircle } from "react-icons/io5";
import HelpModal from "./help-modal";
import Button from "../button";
import SettingsSection from "../settings-section";
export default function TwilioApiForm() {
const transition = useTransition();
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
const isSubmitting = transition.state === "submitting";
const onSubmit = async () => {
// await setTwilioApiFieldsMutation({ twilioAccountSid, twilioAuthToken }); // TODO
};
return (
<>
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<SettingsSection
className="relative"
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}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
<button onClick={() => setIsHelpModalOpen(true)} className="absolute top-2 right-2">
<IoHelpCircle className="w-6 h-6 text-primary-700" />
</button>
<article>
Shellphone needs some informations about your Twilio account to securely use your phone numbers.
</article>
<div className="w-full">
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
Twilio Account SID
</label>
<input
id="twilioAccountSid"
name="twilioAccountSid"
type="text"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
<div className="w-full">
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
Twilio Auth Token
</label>
<input
id="twilioAuthToken"
name="twilioAuthToken"
type="text"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</SettingsSection>
</form>
<HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} />
</>
);
}

View File

@ -0,0 +1,16 @@
import type { FunctionComponent, ReactNode, PropsWithChildren } from "react";
import clsx from "clsx";
type Props = {
className?: string;
footer?: ReactNode;
};
const SettingsSection: FunctionComponent<PropsWithChildren<Props>> = ({ children, footer, className }) => (
<section className={clsx(className, "shadow sm:rounded-md sm:overflow-hidden")}>
<div className="bg-white space-y-6 py-6 px-4 sm:p-6">{children}</div>
{footer ?? null}
</section>
);
export default SettingsSection;

View File

@ -0,0 +1,38 @@
type Payment = {
id: number;
subscription_id: number;
amount: number;
currency: string;
payout_date: string;
is_paid: number;
is_one_off_charge: boolean;
receipt_url?: string;
};
export default function usePaymentsHistory() {
const payments: Payment[] = [];
const count = 0;
const skip = 0;
const pagesNumber = [1];
const currentPage = 0;
const lastPage = 0;
const hasPreviousPage = false;
const hasNextPage = false;
const goToPreviousPage = () => void 0;
const goToNextPage = () => void 0;
const setPage = (page: number) => void 0;
return {
payments,
count,
skip,
pagesNumber,
currentPage,
lastPage,
hasPreviousPage,
hasNextPage,
goToPreviousPage,
goToNextPage,
setPage,
};
}