remixed v0
This commit is contained in:
63
app/features/auth/actions/forgot-password.ts
Normal file
63
app/features/auth/actions/forgot-password.ts
Normal 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,
|
||||
});
|
||||
}
|
56
app/features/auth/actions/register.ts
Normal file
56
app/features/auth/actions/register.ts
Normal 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;
|
56
app/features/auth/actions/reset-password.ts
Normal file
56
app/features/auth/actions/reset-password.ts
Normal 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;
|
22
app/features/auth/actions/sign-in.ts
Normal file
22
app/features/auth/actions/sign-in.ts
Normal 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;
|
11
app/features/auth/loaders/forgot-password.ts
Normal file
11
app/features/auth/loaders/forgot-password.ts
Normal 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;
|
25
app/features/auth/loaders/register.ts
Normal file
25
app/features/auth/loaders/register.ts
Normal 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;
|
23
app/features/auth/loaders/reset-password.ts
Normal file
23
app/features/auth/loaders/reset-password.ts
Normal 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;
|
25
app/features/auth/loaders/sign-in.ts
Normal file
25
app/features/auth/loaders/sign-in.ts
Normal 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;
|
49
app/features/auth/pages/forgot-password.tsx
Normal file
49
app/features/auth/pages/forgot-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
83
app/features/auth/pages/register.tsx
Normal file
83
app/features/auth/pages/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
app/features/auth/pages/reset-password.tsx
Normal file
55
app/features/auth/pages/reset-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
app/features/auth/pages/sign-in.tsx
Normal file
75
app/features/auth/pages/sign-in.tsx
Normal 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?
|
||||
<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>
|
||||
);
|
||||
}
|
41
app/features/auth/validations.ts
Normal file
41
app/features/auth/validations.ts
Normal 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(),
|
||||
});
|
51
app/features/core/components/alert.tsx
Normal file
51
app/features/core/components/alert.tsx
Normal 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;
|
26
app/features/core/components/button.tsx
Normal file
26
app/features/core/components/button.tsx
Normal 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;
|
44
app/features/core/components/footer.tsx
Normal file
44
app/features/core/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
app/features/core/components/inactive-subscription.tsx
Normal file
35
app/features/core/components/inactive-subscription.tsx
Normal 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">​</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 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>
|
||||
);
|
||||
}
|
46
app/features/core/components/labeled-text-field.tsx
Normal file
46
app/features/core/components/labeled-text-field.tsx
Normal 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;
|
13
app/features/core/components/logo.tsx
Normal file
13
app/features/core/components/logo.tsx
Normal 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;
|
25
app/features/core/components/missing-twilio-credentials.tsx
Normal file
25
app/features/core/components/missing-twilio-credentials.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
61
app/features/core/components/modal.tsx
Normal file
61
app/features/core/components/modal.tsx
Normal 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">​</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;
|
17
app/features/core/components/page-title.tsx
Normal file
17
app/features/core/components/page-title.tsx
Normal 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;
|
23
app/features/core/components/phone-init-loader.tsx
Normal file
23
app/features/core/components/phone-init-loader.tsx
Normal 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're finalizing your 🐚phone initialization.</p>
|
||||
<p>
|
||||
You don't have to refresh this page, we will do it automatically for you when your phone is ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
68
app/features/core/components/select.tsx
Normal file
68
app/features/core/components/select.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
app/features/core/components/spinner.css
Normal file
15
app/features/core/components/spinner.css
Normal 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);
|
||||
}
|
||||
}
|
15
app/features/core/components/spinner.tsx
Normal file
15
app/features/core/components/spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
52
app/features/core/helpers/date-formatter.ts
Normal file
52
app/features/core/helpers/date-formatter.ts
Normal 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" });
|
||||
}
|
13
app/features/core/hooks/use-session.ts
Normal file
13
app/features/core/hooks/use-session.ts
Normal 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 };
|
||||
}
|
54
app/features/keypad/components/keypad-error-modal.tsx
Normal file
54
app/features/keypad/components/keypad-error-modal.tsx
Normal 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 🐚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;
|
90
app/features/keypad/components/keypad.tsx
Normal file
90
app/features/keypad/components/keypad.tsx
Normal 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>
|
||||
);
|
||||
};
|
17
app/features/keypad/hooks/use-key-press.ts
Normal file
17
app/features/keypad/hooks/use-key-press.ts
Normal 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]);
|
||||
}
|
81
app/features/messages/components/conversation.tsx
Normal file
81
app/features/messages/components/conversation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
41
app/features/messages/components/conversations-list.tsx
Normal file
41
app/features/messages/components/conversations-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
app/features/messages/components/empty-messages.tsx
Normal file
32
app/features/messages/components/empty-messages.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
88
app/features/messages/components/new-message-area.tsx
Normal file
88
app/features/messages/components/new-message-area.tsx
Normal 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);
|
||||
});
|
||||
}
|
56
app/features/messages/loaders/messages.ts
Normal file
56
app/features/messages/loaders/messages.ts
Normal 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;
|
34
app/features/phone-calls/components/empty-calls.tsx
Normal file
34
app/features/phone-calls/components/empty-calls.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
63
app/features/phone-calls/components/phone-calls-list.tsx
Normal file
63
app/features/phone-calls/components/phone-calls-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
app/features/public-area/components/base-layout.tsx
Normal file
20
app/features/public-area/components/base-layout.tsx
Normal 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;
|
31
app/features/public-area/components/cta-form.tsx
Normal file
31
app/features/public-area/components/cta-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
86
app/features/public-area/components/faqs.tsx
Normal file
86
app/features/public-area/components/faqs.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
40
app/features/public-area/components/footer.tsx
Normal file
40
app/features/public-area/components/footer.tsx
Normal 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">
|
||||
© 2021 Capsule Corp. Dev Pte. Ltd. All rights reserved.
|
||||
{/*© 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>
|
||||
);
|
240
app/features/public-area/components/header.tsx
Normal file
240
app/features/public-area/components/header.tsx
Normal 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;
|
51
app/features/public-area/components/hero.tsx
Normal file
51
app/features/public-area/components/hero.tsx
Normal 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! 🐚 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>
|
||||
);
|
||||
}
|
25
app/features/public-area/components/layout.tsx
Normal file
25
app/features/public-area/components/layout.tsx
Normal 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;
|
36
app/features/public-area/components/referral-banner.tsx
Normal file
36
app/features/public-area/components/referral-banner.tsx
Normal 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>🎉 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">→</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>
|
||||
);
|
||||
}
|
20
app/features/public-area/components/testimonials.tsx
Normal file
20
app/features/public-area/components/testimonials.tsx
Normal 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>
|
||||
);
|
||||
}
|
BIN
app/features/public-area/images/iphone-mockup.png
Normal file
BIN
app/features/public-area/images/iphone-mockup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
app/features/public-area/images/mockup-image-01.jpg
Normal file
BIN
app/features/public-area/images/mockup-image-01.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
BIN
app/features/public-area/images/mockup-image-01.png
Normal file
BIN
app/features/public-area/images/mockup-image-01.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 195 KiB |
BIN
app/features/public-area/images/phone-mockup.png
Normal file
BIN
app/features/public-area/images/phone-mockup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 MiB |
23
app/features/public-area/pages/index.tsx
Normal file
23
app/features/public-area/pages/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
90
app/features/settings/components/account/danger-zone.tsx
Normal file
90
app/features/settings/components/account/danger-zone.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
85
app/features/settings/components/account/update-password.tsx
Normal file
85
app/features/settings/components/account/update-password.tsx
Normal 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;
|
172
app/features/settings/components/billing/billing-history.tsx
Normal file
172
app/features/settings/components/billing/billing-history.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
app/features/settings/components/billing/paddle-link.tsx
Normal file
18
app/features/settings/components/billing/paddle-link.tsx
Normal 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;
|
139
app/features/settings/components/billing/plans.tsx
Normal file
139
app/features/settings/components/billing/plans.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
@ -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'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;
|
48
app/features/settings/components/button.tsx
Normal file
48
app/features/settings/components/button.tsx
Normal 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",
|
||||
},
|
||||
};
|
11
app/features/settings/components/divider.tsx
Normal file
11
app/features/settings/components/divider.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
app/features/settings/components/phone/help-modal.tsx
Normal file
57
app/features/settings/components/phone/help-modal.tsx
Normal 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'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;
|
94
app/features/settings/components/phone/phone-number-form.tsx
Normal file
94
app/features/settings/components/phone/phone-number-form.tsx
Normal 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'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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
66
app/features/settings/components/phone/twilio-api-form.tsx
Normal file
66
app/features/settings/components/phone/twilio-api-form.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
16
app/features/settings/components/settings-section.tsx
Normal file
16
app/features/settings/components/settings-section.tsx
Normal 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;
|
38
app/features/settings/hooks/use-payments-history.ts
Normal file
38
app/features/settings/hooks/use-payments-history.ts
Normal 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,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user