Open to public
This commit is contained in:
commit
a8844bb9a6
@ -1,12 +1,9 @@
|
||||
node_modules
|
||||
.env
|
||||
/.idea
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
||||
|
||||
# build artifacts
|
||||
/.cache
|
||||
/public/build
|
||||
/build
|
||||
/server/index.js
|
||||
/server/index.js
|
||||
|
37
.github/workflows/main.yml
vendored
37
.github/workflows/main.yml
vendored
@ -25,40 +25,3 @@ jobs:
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npx tsc
|
||||
|
||||
deploy_development:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
needs: [lint, typecheck]
|
||||
name: Deploy development environment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: superfly/flyctl-actions@master
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
with:
|
||||
args: "deploy --strategy rolling -c ./fly.dev.toml"
|
||||
- uses: appleboy/discord-action@master
|
||||
with:
|
||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
args: "https://dev.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
||||
|
||||
|
||||
deploy_production:
|
||||
if: github.ref == 'refs/heads/production'
|
||||
needs: [lint, typecheck]
|
||||
name: Deploy production environment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: superfly/flyctl-actions@master
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
with:
|
||||
args: "deploy --strategy rolling"
|
||||
- uses: appleboy/discord-action@master
|
||||
with:
|
||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
args: "https://www.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,7 +11,3 @@ node_modules
|
||||
/.idea
|
||||
|
||||
.env
|
||||
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
@ -1 +0,0 @@
|
||||
export default {};
|
@ -40,23 +40,6 @@ invariant(
|
||||
typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string",
|
||||
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`,
|
||||
);
|
||||
invariant(typeof process.env.FATHOM_SITE_ID === "string", `Please define the "FATHOM_SITE_ID" environment variable`);
|
||||
invariant(
|
||||
typeof process.env.MAILCHIMP_API_KEY === "string",
|
||||
`Please define the "MAILCHIMP_API_KEY" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.MAILCHIMP_AUDIENCE_ID === "string",
|
||||
`Please define the "MAILCHIMP_AUDIENCE_ID" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.DISCORD_WEBHOOK_ID === "string",
|
||||
`Please define the "DISCORD_WEBHOOK_ID" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.DISCORD_WEBHOOK_TOKEN === "string",
|
||||
`Please define the "DISCORD_WEBHOOK_TOKEN" environment variable`,
|
||||
);
|
||||
|
||||
export default {
|
||||
app: {
|
||||
@ -67,35 +50,15 @@ export default {
|
||||
},
|
||||
aws: {
|
||||
region: process.env.AWS_REGION,
|
||||
ses: {
|
||||
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
|
||||
fromEmail: process.env.AWS_SES_FROM_EMAIL,
|
||||
},
|
||||
s3: {
|
||||
accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_S3_ACCESS_KEY_SECRET,
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
webhookId: process.env.DISCORD_WEBHOOK_ID,
|
||||
webhookToken: process.env.DISCORD_WEBHOOK_TOKEN,
|
||||
},
|
||||
fathom: {
|
||||
siteId: process.env.FATHOM_SITE_ID,
|
||||
domain: process.env.FATHOM_CUSTOM_DOMAIN,
|
||||
},
|
||||
mailchimp: {
|
||||
apiKey: process.env.MAILCHIMP_API_KEY,
|
||||
audienceId: process.env.MAILCHIMP_AUDIENCE_ID,
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
},
|
||||
sentry: {
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
},
|
||||
webPush: {
|
||||
privateKey: process.env.WEB_PUSH_VAPID_PRIVATE_KEY,
|
||||
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
|
||||
|
@ -1,4 +0,0 @@
|
||||
import { CronJob } from "~/utils/queue.server";
|
||||
import backup from "~/utils/backup-db.server";
|
||||
|
||||
export default CronJob("daily db backup", () => backup("daily"), "0 0 * * *");
|
@ -1,3 +0,0 @@
|
||||
import registerPurgeExpiredSession from "./purge-expired-sessions";
|
||||
|
||||
export default [registerPurgeExpiredSession];
|
@ -1,4 +0,0 @@
|
||||
import { CronJob } from "~/utils/queue.server";
|
||||
import backup from "~/utils/backup-db.server";
|
||||
|
||||
export default CronJob("monthly db backup", () => backup("monthly"), "0 0 1 * *");
|
@ -1,14 +0,0 @@
|
||||
import db from "~/utils/db.server";
|
||||
import { CronJob } from "~/utils/queue.server";
|
||||
|
||||
export default CronJob(
|
||||
"purge expired sessions",
|
||||
async () => {
|
||||
await db.session.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
});
|
||||
},
|
||||
"0 0 * * *",
|
||||
);
|
@ -1,4 +0,0 @@
|
||||
import { CronJob } from "~/utils/queue.server";
|
||||
import backup from "~/utils/backup-db.server";
|
||||
|
||||
export default CronJob("weekly db backup", () => backup("weekly"), "0 0 * * 0");
|
@ -1,15 +1,5 @@
|
||||
import { hydrate } from "react-dom";
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
|
||||
if (window.shellphoneConfig.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: window.shellphoneConfig.sentry.dsn,
|
||||
tracesSampleRate: 1.0,
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
});
|
||||
}
|
||||
|
||||
hydrate(<RemixBrowser />, document);
|
||||
|
||||
|
@ -1,63 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { type User, TokenType } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { sendForgotPasswordEmail } from "~/mailers/forgot-password-mailer.server";
|
||||
import { generateToken, hashToken } from "~/utils/token.server";
|
||||
import { ForgotPassword } from "../validations";
|
||||
|
||||
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
|
||||
|
||||
type ForgotPasswordFailureActionData = { errors: FormError<typeof ForgotPassword>; submitted?: never };
|
||||
type ForgotPasswordSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type ForgotPasswordActionData = ForgotPasswordFailureActionData | ForgotPasswordSuccessfulActionData;
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(ForgotPassword, formData);
|
||||
if (validation.errors) {
|
||||
return json<ForgotPasswordFailureActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
|
||||
// always wait the same amount of time so attackers can't tell the difference whether a user is found
|
||||
await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]);
|
||||
|
||||
// return the same result whether a password reset email was sent or not
|
||||
return json<ForgotPasswordSuccessfulActionData>({ submitted: true });
|
||||
};
|
||||
|
||||
export default action;
|
||||
|
||||
async function updatePassword(user: User | null) {
|
||||
const membership = await db.membership.findFirst({ where: { userId: user?.id } });
|
||||
if (!user || !membership) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const hashedToken = hashToken(token);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS);
|
||||
|
||||
await db.token.deleteMany({ where: { type: TokenType.RESET_PASSWORD, userId: user.id } });
|
||||
await db.token.create({
|
||||
data: {
|
||||
user: { connect: { id: user.id } },
|
||||
membership: { connect: { id: membership.id } },
|
||||
type: TokenType.RESET_PASSWORD,
|
||||
expiresAt,
|
||||
hashedToken,
|
||||
sentTo: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
await sendForgotPasswordEmail({
|
||||
to: user.email,
|
||||
token,
|
||||
userName: user.fullName,
|
||||
});
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { GlobalRole, MembershipRole } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { authenticate, hashPassword } from "~/utils/auth.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { Register } from "../validations";
|
||||
|
||||
export type RegisterActionData = {
|
||||
errors: FormError<typeof Register>;
|
||||
};
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(Register, formData);
|
||||
if (validation.errors) {
|
||||
return json<RegisterActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const { fullName, email, password } = validation.data;
|
||||
const hashedPassword = await hashPassword(password.trim());
|
||||
try {
|
||||
await db.user.create({
|
||||
data: {
|
||||
fullName: fullName.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
hashedPassword,
|
||||
role: GlobalRole.CUSTOMER,
|
||||
memberships: {
|
||||
create: {
|
||||
role: MembershipRole.OWNER,
|
||||
organization: {
|
||||
create: {}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
|
||||
if (error.code === "P2002") {
|
||||
if (error.meta.target[0] === "email") {
|
||||
return json<RegisterActionData>({
|
||||
errors: { general: "An account with this email address already exists" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json<RegisterActionData>({
|
||||
errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` },
|
||||
});
|
||||
}
|
||||
|
||||
return authenticate({ email, password, request, failureRedirect: "/register" });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,56 +0,0 @@
|
||||
import { type ActionFunction, json, redirect } from "@remix-run/node";
|
||||
import { TokenType } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { authenticate, hashPassword } from "~/utils/auth.server";
|
||||
import { ResetPasswordError } from "~/utils/errors";
|
||||
import { hashToken } from "~/utils/token.server";
|
||||
import { ResetPassword } from "../validations";
|
||||
|
||||
export type ResetPasswordActionData = { errors: FormError<typeof ResetPassword> };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
return redirect("/forgot-password");
|
||||
}
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const validation = validate(ResetPassword, { ...formData, token });
|
||||
if (validation.errors) {
|
||||
return json<ResetPasswordActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const hashedToken = hashToken(token);
|
||||
const savedToken = await db.token.findFirst({
|
||||
where: { hashedToken, type: TokenType.RESET_PASSWORD },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!savedToken) {
|
||||
logger.warn(`No token found with hashedToken=${hashedToken}`);
|
||||
throw new ResetPasswordError();
|
||||
}
|
||||
|
||||
await db.token.delete({ where: { id: savedToken.id } });
|
||||
|
||||
if (savedToken.expiresAt < new Date()) {
|
||||
logger.warn(`Token with hashedToken=${hashedToken} is expired since ${savedToken.expiresAt.toUTCString()}`);
|
||||
throw new ResetPasswordError();
|
||||
}
|
||||
|
||||
const password = validation.data.password.trim();
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { email } = await db.user.update({
|
||||
where: { id: savedToken.userId },
|
||||
data: { hashedPassword },
|
||||
});
|
||||
|
||||
await db.session.deleteMany({ where: { userId: savedToken.userId } });
|
||||
|
||||
return authenticate({ email, password, request });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,23 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
|
||||
import { SignIn } from "../validations";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { authenticate } from "~/utils/auth.server";
|
||||
|
||||
export type SignInActionData = { errors: FormError<typeof SignIn> };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.clone().formData());
|
||||
const validation = validate(SignIn, formData);
|
||||
if (validation.errors) {
|
||||
return json<SignInActionData>({ errors: validation.errors });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
const successRedirect = redirectTo ? decodeURIComponent(redirectTo) : null;
|
||||
const { email, password } = validation.data;
|
||||
return authenticate({ email, password, request, successRedirect });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,11 +0,0 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
|
||||
import { requireLoggedOut } from "~/utils/auth.server";
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,25 +0,0 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
|
||||
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
export type RegisterLoaderData = { errors: { general: string } } | null;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const errorMessage = getErrorMessage(session);
|
||||
if (errorMessage) {
|
||||
return json<RegisterLoaderData>(
|
||||
{ errors: { general: errorMessage } },
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,23 +0,0 @@
|
||||
import { type LoaderFunction, redirect } from "@remix-run/node";
|
||||
|
||||
import { requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
return redirect("/forgot-password");
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,25 +0,0 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
|
||||
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
|
||||
export type SignInLoaderData = { errors: { general: string } } | null;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const session = await getSession(request);
|
||||
const errorMessage = getErrorMessage(session);
|
||||
if (errorMessage) {
|
||||
return json<SignInLoaderData>(
|
||||
{ errors: { general: errorMessage } },
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await requireLoggedOut(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default loader;
|
@ -1,49 +0,0 @@
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { ForgotPasswordActionData } from "../actions/forgot-password";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const actionData = useActionData<ForgotPasswordActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Forgot your password?
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
|
||||
{actionData?.submitted ? (
|
||||
<p className="text-center">
|
||||
If your email is in our system, you will receive instructions to reset your password shortly.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
tabIndex={2}
|
||||
className="w-full flex justify-center py-2 px-4 text-base font-medium"
|
||||
>
|
||||
Send reset password link
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { RegisterActionData } from "../actions/register";
|
||||
import type { RegisterLoaderData } from "../loaders/register";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const loaderData = useLoaderData<RegisterLoaderData>();
|
||||
const actionData = useActionData<RegisterActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
const topErrorMessage = loaderData?.errors?.general || actionData?.errors?.general;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||
<Link
|
||||
to="/sign-in"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
|
||||
>
|
||||
Already have an account?
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
|
||||
{topErrorMessage ? (
|
||||
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage!} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="fullName"
|
||||
type="text"
|
||||
label="Full name"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.fullName}
|
||||
tabIndex={1}
|
||||
/>
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={2}
|
||||
/>
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={3}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
tabIndex={4}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import { Form, useActionData, useSearchParams, useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import type { ResetPasswordActionData } from "../actions/reset-password";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const actionData = useActionData<ResetPasswordActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Set a new password</h2>
|
||||
</header>
|
||||
|
||||
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
label="New Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="passwordConfirmation"
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.passwordConfirmation}
|
||||
tabIndex={2}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={transition.state === "submitting"}
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": isSubmitting,
|
||||
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
|
||||
},
|
||||
)}
|
||||
tabIndex={3}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useSearchParams, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { SignInActionData } from "../actions/sign-in";
|
||||
import type { SignInLoaderData } from "../loaders/sign-in";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "~/features/core/components/button";
|
||||
|
||||
export default function SignInPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const loaderData = useLoaderData<SignInLoaderData>();
|
||||
const actionData = useActionData<SignInActionData>();
|
||||
const transition = useTransition();
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
return (
|
||||
<section>
|
||||
<header>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Welcome back!</h2>
|
||||
{/*<p className="mt-2 text-center text-sm leading-5 text-gray-600">
|
||||
Need an account?
|
||||
<Link
|
||||
to="/register"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
|
||||
>
|
||||
Create yours for free
|
||||
</Link>
|
||||
</p>*/}
|
||||
</header>
|
||||
|
||||
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
|
||||
{loaderData?.errors ? (
|
||||
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
|
||||
<Alert title="Oops, there was an issue" message={loaderData.errors.general} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.email}
|
||||
tabIndex={1}
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
disabled={isSubmitting}
|
||||
error={actionData?.errors?.password}
|
||||
tabIndex={2}
|
||||
sideLabel={
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
prefetch="intent"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition ease-in-out duration-150"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={transition.state === "submitting"} tabIndex={3}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const password = z.string().min(10).max(100);
|
||||
|
||||
export const Register = z.object({
|
||||
fullName: z.string().nonempty(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const SignIn = z.object({
|
||||
email: z.string().email(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const ForgotPassword = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const ResetPassword = z
|
||||
.object({
|
||||
password: password,
|
||||
passwordConfirmation: password,
|
||||
token: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirmation, {
|
||||
message: "Passwords don't match",
|
||||
path: ["passwordConfirmation"], // set the path of the error
|
||||
});
|
||||
|
||||
export const AcceptInvitation = z.object({
|
||||
fullName: z.string(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const AcceptAuthedInvitation = z.object({
|
||||
token: z.string(),
|
||||
});
|
@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { validate } from "~/utils/validation.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.clone().formData();
|
||||
@ -31,7 +31,6 @@ const action: ActionFunction = async ({ request }) => {
|
||||
export default action;
|
||||
|
||||
async function subscribe(request: Request) {
|
||||
const { organization } = await requireLoggedIn(request);
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
|
||||
@ -42,17 +41,16 @@ async function subscribe(request: Request) {
|
||||
}
|
||||
|
||||
const { subscription } = validation.data;
|
||||
const membership = await db.membership.findFirst({
|
||||
where: { id: organization.membershipId },
|
||||
});
|
||||
if (!membership) {
|
||||
return notFound("Phone number not found");
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.notificationSubscription.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
twilioAccountSid: twilio.accountSid,
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
|
@ -1,26 +0,0 @@
|
||||
import type { ButtonHTMLAttributes, FunctionComponent } from "react";
|
||||
import { useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const Button: FunctionComponent<Props> = ({ children, ...props }) => {
|
||||
const transition = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": transition.state === "submitting",
|
||||
"bg-primary-600 hover:bg-primary-700": transition.state !== "submitting",
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
@ -1,35 +0,0 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function InactiveSubscription() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
||||
<span className="hidden md:inline-block md:align-middle md:h-screen">​</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
|
||||
<div className="text-center my-auto p-4">
|
||||
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
You don't have any active subscription
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 max-w-sm mx-auto break-normal whitespace-normal">
|
||||
You need an active subscription to use this feature.
|
||||
<br />
|
||||
Head over to your settings to pick a plan.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={() => navigate("/settings/billing")}
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Choose a plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { HiCheck as CheckIcon, HiSelector as SelectorIcon } from "react-icons/hi";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Option = { name: string; value: string };
|
||||
|
||||
type Props = {
|
||||
options: Option[];
|
||||
onChange: (selectedValue: Option) => void;
|
||||
value: Option;
|
||||
};
|
||||
|
||||
export default function Select({ options, onChange, value }: Props) {
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">{value.name}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{options.map((option, index) => (
|
||||
<Listbox.Option
|
||||
key={`option-${option}-${index}`}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
"cursor-default select-none relative py-2 pl-10 pr-4",
|
||||
active ? "text-amber-900 bg-amber-100" : "text-gray-900",
|
||||
)
|
||||
}
|
||||
value={option}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={clsx("block truncate", selected ? "font-medium" : "font-normal")}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute inset-y-0 left-0 flex items-center pl-3",
|
||||
active ? "text-amber-600" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
.ring {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50%;
|
||||
border-top-color: currentColor;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
|
||||
import styles from "./spinner.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
];
|
||||
|
||||
export default function Spinner() {
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="ring m-auto text-primary-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import * as Fathom from "fathom-client";
|
||||
|
||||
export default function useFathom() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const { fathom, app } = window.shellphoneConfig;
|
||||
Fathom.load(fathom.siteId, {
|
||||
spa: "history",
|
||||
url: fathom.domain ? `https://${fathom.domain}/script.js` : undefined,
|
||||
includedDomains: [app.baseUrl.replace("https://", "")],
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Fathom.trackPageview();
|
||||
}, [location]);
|
||||
}
|
@ -2,21 +2,20 @@ import type { LoaderFunction } from "@remix-run/node";
|
||||
import { json } from "superjson-remix";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import db from "~/utils/db.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type KeypadLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
lastRecipientCalled?: string;
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const lastCall =
|
||||
phoneNumber &&
|
||||
@ -26,7 +25,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}));
|
||||
return json<KeypadLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
lastRecipientCalled: lastCall?.recipient,
|
||||
},
|
||||
|
@ -2,19 +2,21 @@ import { type ActionFunction } from "@remix-run/node";
|
||||
import { json } from "superjson-remix";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type NewMessageActionData = {};
|
||||
type NewMessageActionData = {};
|
||||
|
||||
const action: ActionFunction = async ({ params, request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const [phoneNumber, twilioAccount] = await Promise.all([
|
||||
db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid ?? "", isCurrent: true } },
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid, isCurrent: true } },
|
||||
}),
|
||||
db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }),
|
||||
]);
|
||||
|
@ -4,8 +4,8 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type ConversationType = {
|
||||
recipient: string;
|
||||
@ -19,7 +19,8 @@ export type ConversationLoaderData = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request, params }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
return redirect("/messages");
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type MessagesLoaderData = {
|
||||
hasPhoneNumber: boolean;
|
||||
@ -19,7 +19,8 @@ type Conversation = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
|
@ -10,20 +10,14 @@ import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
|
||||
import type { PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
|
||||
|
||||
export default function PhoneCallsList() {
|
||||
const { hasOngoingSubscription, isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
const { isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
|
||||
if (!hasOngoingSubscription) {
|
||||
if (!phoneCalls || phoneCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
|
||||
if (phoneCalls.length === 0) {
|
||||
return hasOngoingSubscription ? <EmptyCalls /> : null;
|
||||
}
|
||||
if (phoneCalls.length === 0) {
|
||||
return <EmptyCalls />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type PhoneCall, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type PhoneCallMeta = {
|
||||
formattedPhoneNumber: string;
|
||||
@ -12,7 +12,6 @@ type PhoneCallMeta = {
|
||||
};
|
||||
|
||||
export type PhoneCallsLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
} & (
|
||||
| {
|
||||
@ -26,15 +25,14 @@ export type PhoneCallsLoaderData = {
|
||||
);
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
if (!phoneNumber || phoneNumber.isFetchingCalls) {
|
||||
return json<PhoneCallsLoaderData>({
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
isFetchingCalls: phoneNumber?.isFetchingCalls ?? false,
|
||||
});
|
||||
@ -46,7 +44,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
});
|
||||
return json<PhoneCallsLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
phoneCalls: phoneCalls.map((phoneCall) => ({
|
||||
...phoneCall,
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { type LoaderFunction } from "@remix-run/node";
|
||||
import Twilio from "twilio";
|
||||
|
||||
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import { decrypt, encrypt } from "~/utils/encryption";
|
||||
import db from "~/utils/db.server";
|
||||
import { commitSession } from "~/utils/session.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
import getTwilioClient from "~/utils/twilio.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
|
||||
export type TwilioTokenLoaderData = string;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { user, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
logger.warn("Twilio account is not connected");
|
||||
return null;
|
||||
@ -26,7 +26,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(twilioAccount);
|
||||
let shouldRefreshSession = false;
|
||||
let { apiKeySid, apiKeySecret } = twilioAccount;
|
||||
if (apiKeySid && apiKeySecret) {
|
||||
try {
|
||||
@ -41,7 +40,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
if (!apiKeySid || !apiKeySecret) {
|
||||
shouldRefreshSession = true;
|
||||
const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" });
|
||||
apiKeySid = apiKey.sid;
|
||||
apiKeySecret = encrypt(apiKey.secret);
|
||||
@ -52,7 +50,7 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), {
|
||||
identity: `${twilio.accountSid}__${user.id}`,
|
||||
identity: `shellphone__${twilio.accountSid}`,
|
||||
ttl: 3600,
|
||||
});
|
||||
const grant = new Twilio.jwt.AccessToken.VoiceGrant({
|
||||
@ -62,11 +60,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
accessToken.addGrant(grant);
|
||||
|
||||
const headers = new Headers({ "Content-Type": "text/plain" });
|
||||
if (shouldRefreshSession) {
|
||||
const { session } = await refreshSessionData(request);
|
||||
headers.set("Set-Cookie", await commitSession(session));
|
||||
}
|
||||
|
||||
return new Response(accessToken.toJwt(), { headers });
|
||||
};
|
||||
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
|
||||
import { addSubscriber } from "~/utils/mailchimp.server";
|
||||
import { executeWebhook } from "~/utils/discord.server";
|
||||
|
||||
export type JoinWaitlistActionData = { submitted: true };
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email");
|
||||
if (!formData.get("email") || typeof email !== "string") {
|
||||
throw new Error("Something wrong happened");
|
||||
}
|
||||
|
||||
// await addSubscriber(email);
|
||||
const res = await executeWebhook(email);
|
||||
console.log(res.status);
|
||||
console.log(await res.text());
|
||||
|
||||
return json<JoinWaitlistActionData>({ submitted: true });
|
||||
};
|
||||
|
||||
export default action;
|
@ -1,41 +0,0 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const baseStyles = {
|
||||
solid: "group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
outline: "group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none",
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
solid: {
|
||||
slate: "bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900",
|
||||
primary:
|
||||
"bg-primary-600 text-white hover:text-slate-100 hover:bg-primary-500 active:bg-primary-800 active:text-primary-100 focus-visible:outline-primary-600",
|
||||
white: "bg-white text-slate-900 hover:bg-primary-50 active:bg-primary-200 active:text-slate-600 focus-visible:outline-white",
|
||||
},
|
||||
outline: {
|
||||
slate: "ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-primary-600 focus-visible:ring-slate-300",
|
||||
white: "ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white",
|
||||
},
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
(
|
||||
| {
|
||||
variant: "solid";
|
||||
color: "slate" | "primary" | "white";
|
||||
}
|
||||
| {
|
||||
variant: "outline";
|
||||
color: "slate" | "white";
|
||||
}
|
||||
) & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Button({ variant, color, className, ...props }: Props) {
|
||||
// @ts-ignore
|
||||
const fullClassName = clsx(baseStyles[variant], variantStyles[variant][color], className);
|
||||
|
||||
return <button className={fullClassName} {...props} />;
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
|
||||
import type { JoinWaitlistActionData } from "~/features/public-area/actions";
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
import { TextField } from "./fields";
|
||||
|
||||
import Alert from "~/features/core/components/alert";
|
||||
|
||||
import backgroundImage from "../images/background-call-to-action.webp";
|
||||
|
||||
export default function CallToAction() {
|
||||
const actionData = useActionData<JoinWaitlistActionData>();
|
||||
|
||||
return (
|
||||
<section id="get-started-today" className="relative overflow-hidden bg-blue-600 py-32">
|
||||
<img
|
||||
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2"
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
width={2347}
|
||||
height={1244}
|
||||
/>
|
||||
<Container className="relative">
|
||||
<div className="mx-auto max-w-lg text-center">
|
||||
<h2 className="font-mackinac font-bold text-3xl tracking-tight text-white sm:text-4xl">
|
||||
Request access
|
||||
</h2>
|
||||
<p className="mt-4 text-lg tracking-tight text-white">
|
||||
Shellphone is currently invite-only but we onboard new users on a regular basis. Enter your
|
||||
email address to join the waitlist and receive important updates in your inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
method="post"
|
||||
className="max-w-2xl mx-auto flex flex-col space-y-4 items-center mt-10 sm:flex-row sm:space-y-0 sm:space-x-4"
|
||||
>
|
||||
{actionData?.submitted ? (
|
||||
<div className="m-auto">
|
||||
<Alert
|
||||
title="You made it!"
|
||||
message="You're on the list, we will be in touch soon"
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="solid" color="white" className="w-40">
|
||||
<span>Join waitlist</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Container({ className, ...props }: Props) {
|
||||
return <div className={clsx("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8", className)} {...props} />;
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Container from "./container";
|
||||
|
||||
import backgroundImage from "../images/background-faqs.webp";
|
||||
|
||||
export default function Faqs() {
|
||||
return (
|
||||
<section id="faq" aria-labelledby="faq-title" className="relative overflow-hidden bg-slate-50 py-20 sm:py-32">
|
||||
<img
|
||||
className="absolute top-0 left-1/2 max-w-none translate-x-[-30%] -translate-y-1/4"
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
width={1558}
|
||||
height={946}
|
||||
/>
|
||||
<Container className="relative">
|
||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
id="faq-title"
|
||||
className="font-mackinac font-bold text-3xl tracking-tight text-slate-900 sm:text-4xl"
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="mt-16 grid grid-cols-1 max-w-3xl mx-auto pl-12 lg:mx-0">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<Accordion title="Does it work with 2FA messages?">
|
||||
Some banks and online services refuse to send two-factor authentication messages to a virtual
|
||||
phone number and we do not have a solution around this yet. Moreover, Twilio does not support
|
||||
receiving incoming SMS from external Alphanumeric Sender IDs is to protect accounts getting
|
||||
bombarded from spam messages from these IDs which are used to send one-way SMS.
|
||||
<br />
|
||||
With that said, we have successfully received 2FA messages from many services including WhatsApp
|
||||
and Uber. We recognize this is a common problem for people who want to switch to a virtual phone
|
||||
number and we are doing our best to find a long-term solution to receive 2FA messages.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQs() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="py-12 md:py-20">
|
||||
<div className="max-w-3xl mx-auto text-center pb-20">
|
||||
<h2 className="h2 font-mackinac">Questions & Answers</h2>
|
||||
</div>
|
||||
|
||||
<ul className="max-w-3xl mx-auto pl-12">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const Accordion: FunctionComponent<PropsWithChildren<{ title: string }>> = ({ title, children }) => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex items-center w-full text-lg font-medium text-left py-5 border-t border-gray-200">
|
||||
<svg
|
||||
className="w-4 h-4 fill-current text-rebeccapurple-500 flex-shrink-0 mr-8 -ml-12"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-90": !open,
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</svg>
|
||||
<span>{title}</span>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-300 ease-in-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="text-gray-600 overflow-hidden">
|
||||
<p className="pb-5">{children}</p>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
import type { InputHTMLAttributes, HTMLAttributes, PropsWithChildren } from "react";
|
||||
|
||||
const formClasses =
|
||||
"block w-full appearance-none rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-blue-500 sm:text-sm";
|
||||
|
||||
function Label({ id, children }: PropsWithChildren<HTMLAttributes<HTMLLabelElement>>) {
|
||||
return (
|
||||
<label htmlFor={id} className="mb-3 block text-sm font-medium text-gray-700">
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
className = "",
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <Label id={id}>{label}</Label>}
|
||||
<input id={id} type={type} {...props} className={formClasses} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
import Logo from "./logo";
|
||||
import NavLink from "./nav-link";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="py-10">
|
||||
<Container>
|
||||
<nav className="relative z-50 flex justify-between">
|
||||
<div className="flex items-center md:gap-x-12">
|
||||
<Link to="/" aria-label="Home">
|
||||
<Logo />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-5 md:gap-x-8">
|
||||
<NavLink href="/sign-in">Have an account?</NavLink>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
document.querySelector("#get-started-today")?.scrollIntoView({ behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<span>Request access</span>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</Container>
|
||||
</header>
|
||||
);
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import Button from "./button";
|
||||
import Container from "./container";
|
||||
|
||||
/*
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin-top: -120px;
|
||||
*/
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<Container className="pt-20 pb-16 text-center lg:pt-32 landing-hero">
|
||||
<h1 className="mx-auto max-w-4xl font-mackinac font-heading text-5xl font-medium tracking-tight text-[#24185B] sm:text-7xl">
|
||||
<span className="background-primary bg-clip-text decoration-clone text-transparent">
|
||||
Calling your bank from abroad
|
||||
</span>{" "}
|
||||
just got{" "}
|
||||
<span className="relative whitespace-nowrap">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 418 42"
|
||||
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-rebeccapurple-300/70"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
|
||||
</svg>
|
||||
<span className="relative">easier</span>
|
||||
</span>{" "}
|
||||
!
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
|
||||
Coming soon, the personal cloud phone for digital nomads! Take your phone number anywhere you go 🌏
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function Logo() {
|
||||
return <img className="w-10 h-10" src="/shellphone.webp" alt="Shellphone logo" />;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
export default function NavLink({ href, children }: PropsWithChildren<{ href: string }>) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-block rounded-lg py-1 px-2 text-sm text-slate-700 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 162 KiB |
Binary file not shown.
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
@ -1,17 +0,0 @@
|
||||
import Header from "../components/header";
|
||||
import Hero from "../components/hero";
|
||||
import CallToAction from "../components/call-to-action";
|
||||
import Faqs from "../components/faqs";
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<section className="flex h-full flex-col">
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<CallToAction />
|
||||
<Faqs />
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
import { type ActionFunction, json, redirect } from "@remix-run/node";
|
||||
import { badRequest } from "remix-utils";
|
||||
import { z } from "zod";
|
||||
import SecurePassword from "secure-password";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { hashPassword, requireLoggedIn, verifyPassword } from "~/utils/auth.server";
|
||||
import { type FormError, validate } from "~/utils/validation.server";
|
||||
import { destroySession, getSession } from "~/utils/session.server";
|
||||
import deleteUserQueue from "~/queues/delete-user-data.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
if (!formData._action) {
|
||||
const errorMessage = "POST /settings/phone without any _action";
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
|
||||
switch (formData._action as Action) {
|
||||
case "deleteUser":
|
||||
return deleteUser(request);
|
||||
case "changePassword":
|
||||
return changePassword(request, formData);
|
||||
case "updateUser":
|
||||
return updateUser(request, formData);
|
||||
default:
|
||||
const errorMessage = `POST /settings/phone with an invalid _action=${formData._action}`;
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
export default action;
|
||||
|
||||
async function deleteUser(request: Request) {
|
||||
const {
|
||||
user: { id },
|
||||
} = await requireLoggedIn(request);
|
||||
|
||||
await db.user.update({
|
||||
where: { id },
|
||||
data: { hashedPassword: "pending deletion" },
|
||||
});
|
||||
await deleteUserQueue.add(`delete user ${id}`, { userId: id });
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await destroySession(await getSession(request)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ChangePasswordFailureActionData = { errors: FormError<typeof validations.changePassword>; submitted?: never };
|
||||
type ChangePasswordSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type ChangePasswordActionData = {
|
||||
changePassword: ChangePasswordFailureActionData | ChangePasswordSuccessfulActionData;
|
||||
};
|
||||
|
||||
async function changePassword(request: Request, formData: unknown) {
|
||||
const validation = validate(validations.changePassword, formData);
|
||||
if (validation.errors) {
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { errors: validation.errors },
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
user: { id },
|
||||
} = await requireLoggedIn(request);
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
const { currentPassword, newPassword } = validation.data;
|
||||
const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword);
|
||||
if ([SecurePassword.INVALID, SecurePassword.INVALID_UNRECOGNIZED_HASH, false].includes(verificationResult)) {
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { errors: { currentPassword: "Current password is incorrect" } },
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword.trim());
|
||||
await db.user.update({
|
||||
where: { id: user!.id },
|
||||
data: { hashedPassword },
|
||||
});
|
||||
|
||||
return json<ChangePasswordActionData>({
|
||||
changePassword: { submitted: true },
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateUserFailureActionData = { errors: FormError<typeof validations.updateUser>; submitted?: never };
|
||||
type UpdateUserSuccessfulActionData = { errors?: never; submitted: true };
|
||||
export type UpdateUserActionData = {
|
||||
updateUser: UpdateUserFailureActionData | UpdateUserSuccessfulActionData;
|
||||
};
|
||||
|
||||
async function updateUser(request: Request, formData: unknown) {
|
||||
const validation = validate(validations.updateUser, formData);
|
||||
if (validation.errors) {
|
||||
return json<UpdateUserActionData>({
|
||||
updateUser: { errors: validation.errors },
|
||||
});
|
||||
}
|
||||
|
||||
const { user } = await requireLoggedIn(request);
|
||||
const { email, fullName } = validation.data;
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email, fullName },
|
||||
});
|
||||
|
||||
return json<UpdateUserActionData>({
|
||||
updateUser: { submitted: true },
|
||||
});
|
||||
}
|
||||
|
||||
type Action = "deleteUser" | "updateUser" | "changePassword";
|
||||
|
||||
const validations = {
|
||||
deleteUser: null,
|
||||
changePassword: z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(10).max(100),
|
||||
}),
|
||||
updateUser: z.object({
|
||||
fullName: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
} as const;
|
@ -5,8 +5,7 @@ import type { Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { type FormActionData, validate } from "~/utils/validation.server";
|
||||
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import { commitSession } from "~/utils/session.server";
|
||||
import { commitSession, getSession } from "~/utils/session.server";
|
||||
import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { encrypt } from "~/utils/encryption";
|
||||
@ -40,7 +39,8 @@ const action: ActionFunction = async ({ request }) => {
|
||||
export type SetPhoneNumberActionData = FormActionData<typeof validations, "setPhoneNumber">;
|
||||
|
||||
async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
return badRequest<SetPhoneNumberActionData>({
|
||||
setPhoneNumber: {
|
||||
@ -72,7 +72,6 @@ async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
});
|
||||
await setTwilioWebhooksQueue.add(`set twilio webhooks for phoneNumberId=${validation.data.phoneNumberSid}`, {
|
||||
phoneNumberId: validation.data.phoneNumberSid,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return json<SetPhoneNumberActionData>({ setPhoneNumber: { submitted: true } });
|
||||
@ -81,7 +80,8 @@ async function setPhoneNumber(request: Request, formData: unknown) {
|
||||
export type SetTwilioCredentialsActionData = FormActionData<typeof validations, "setTwilioCredentials">;
|
||||
|
||||
async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const validation = validate(validations.setTwilioCredentials, formData);
|
||||
if (validation.errors) {
|
||||
return badRequest<SetTwilioCredentialsActionData>({ setTwilioCredentials: { errors: validation.errors } });
|
||||
@ -99,10 +99,10 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let session: Session | undefined;
|
||||
if (twilio) {
|
||||
console.log("fail");
|
||||
await db.twilioAccount.delete({ where: { accountSid: twilio?.accountSid } });
|
||||
session = (await refreshSessionData(request)).session;
|
||||
session.unset("twilio");
|
||||
}
|
||||
|
||||
return json<SetTwilioCredentialsActionData>(
|
||||
@ -112,11 +112,9 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: session
|
||||
? {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
}
|
||||
: {},
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -128,13 +126,8 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
const [phoneNumbers] = await Promise.all([
|
||||
twilioClient.incomingPhoneNumbers.list(),
|
||||
db.twilioAccount.upsert({
|
||||
where: { organizationId: organization.id },
|
||||
create: {
|
||||
organization: {
|
||||
connect: { id: organization.id },
|
||||
},
|
||||
...data,
|
||||
},
|
||||
where: { accountSid: twilioAccountSid },
|
||||
create: data,
|
||||
update: data,
|
||||
}),
|
||||
]);
|
||||
@ -143,11 +136,11 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
accountSid: twilioAccountSid,
|
||||
});
|
||||
await Promise.all(
|
||||
phoneNumbers.map(async (phoneNumber) => {
|
||||
phoneNumbers.map(async (phoneNumber, index) => {
|
||||
const phoneNumberId = phoneNumber.sid;
|
||||
logger.info(`Importing phone number with id=${phoneNumberId}`);
|
||||
try {
|
||||
await db.phoneNumber.create({
|
||||
await db.phoneNumber.createMany({
|
||||
data: {
|
||||
id: phoneNumberId,
|
||||
twilioAccountSid,
|
||||
@ -156,6 +149,7 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
isFetchingCalls: true,
|
||||
isFetchingMessages: true,
|
||||
},
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
@ -177,19 +171,25 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
|
||||
}),
|
||||
);
|
||||
|
||||
const { session } = await refreshSessionData(request);
|
||||
session.set("twilio", { accountSid: twilioAccountSid, authToken });
|
||||
console.log("{ accountSid: twilioAccountSid, authToken }", { accountSid: twilioAccountSid, authToken });
|
||||
console.log("session", session.get("twilio"), session.data);
|
||||
const setCookie = await commitSession(session);
|
||||
console.log("set twilio in session", setCookie);
|
||||
|
||||
return json<SetTwilioCredentialsActionData>(
|
||||
{ setTwilioCredentials: { submitted: true } },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
"Set-Cookie": setCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshPhoneNumbers(request: Request) {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
@ -1,91 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Form, useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
|
||||
export default function DangerZone() {
|
||||
const transition = useTransition();
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "deleteUser";
|
||||
const isDeletingUser = isCurrentFormTransition && transition.state === "submitting";
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
|
||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isDeletingUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection className="border border-red-300">
|
||||
<div className="flex justify-between items-center flex-row space-x-2">
|
||||
<p>
|
||||
Once you delete your account, all of its data will be permanently deleted and any ongoing
|
||||
subscription will be cancelled.
|
||||
</p>
|
||||
|
||||
<span className="text-base font-medium">
|
||||
<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
|
||||
Delete my account
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal initialFocus={modalCancelButtonRef} isOpen={isConfirmationModalOpen} onClose={closeModal}>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Delete my account</ModalTitle>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Are you sure you want to delete your account? Your subscription will be cancelled and
|
||||
your data permanently deleted.
|
||||
</p>
|
||||
<p>
|
||||
You are free to create a new account with the same email address if you ever wish to
|
||||
come back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<Form method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 md:ml-3 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-red-400 cursor-not-allowed": isDeletingUser,
|
||||
"bg-red-600 hover:bg-red-700": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
<input type="hidden" name="_action" value="deleteUser" />
|
||||
</Form>
|
||||
<button
|
||||
ref={modalCancelButtonRef}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-gray-50 cursor-not-allowed": isDeletingUser,
|
||||
"hover:bg-gray-50": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
onClick={closeModal}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { UpdateUserActionData } from "~/features/settings/actions/account";
|
||||
import useSession from "~/features/core/hooks/use-session";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
|
||||
const ProfileInformations: FunctionComponent = () => {
|
||||
const { user } = useSession();
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<UpdateUserActionData>()?.updateUser;
|
||||
|
||||
const errors = actionData?.errors;
|
||||
const topErrorMessage = errors?.general;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const isSuccess = actionData?.submitted;
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "updateUser";
|
||||
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<SettingsSection
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
|
||||
Full name
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
tabIndex={1}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
defaultValue={user.fullName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
tabIndex={2}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
defaultValue={user.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_action" value="updateUser" />
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileInformations;
|
@ -1,69 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Form, useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import type { ChangePasswordActionData } from "~/features/settings/actions/account";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import LabeledTextField from "~/features/core/components/labeled-text-field";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
|
||||
const UpdatePassword: FunctionComponent = () => {
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<ChangePasswordActionData>()?.changePassword;
|
||||
|
||||
const topErrorMessage = actionData?.errors?.general;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const isSuccess = actionData?.submitted;
|
||||
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword";
|
||||
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<SettingsSection
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LabeledTextField
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
type="password"
|
||||
tabIndex={3}
|
||||
error={actionData?.errors?.currentPassword}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<LabeledTextField
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
type="password"
|
||||
tabIndex={4}
|
||||
error={actionData?.errors?.newPassword}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<input type="hidden" name="_action" value="changePassword" />
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePassword;
|
@ -1,172 +0,0 @@
|
||||
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
|
||||
import clsx from "clsx";
|
||||
|
||||
import usePaymentsHistory from "../../hooks/use-payments-history";
|
||||
|
||||
export default function BillingHistory() {
|
||||
const {
|
||||
payments,
|
||||
count,
|
||||
skip,
|
||||
pagesNumber,
|
||||
currentPage,
|
||||
lastPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
goToPreviousPage,
|
||||
goToNextPage,
|
||||
setPage,
|
||||
} = usePaymentsHistory();
|
||||
|
||||
if (payments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
|
||||
<div className="px-4 sm:px-6">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Billing history</h2>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden border-t border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Amount
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span className="sr-only">View receipt</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<time>{new Date(payment.payout_date).toDateString()}</time>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: payment.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(payment.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{payment.is_paid === 1 ? "Paid" : "Upcoming"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{typeof payment.receipt_url !== "undefined" ? (
|
||||
<a
|
||||
href={payment.receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View receipt
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasPreviousPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<p className="text-sm text-gray-700 self-center">
|
||||
Page <span className="font-medium">{currentPage}</span> of{" "}
|
||||
<span className="font-medium">{lastPage}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className={clsx(
|
||||
"ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasNextPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{skip + 1}</span> to{" "}
|
||||
<span className="font-medium">{skip + payments.length}</span> of{" "}
|
||||
<span className="font-medium">{count}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<IoChevronBack className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
{pagesNumber.map((pageNumber) => (
|
||||
<button
|
||||
key={`billing-history-button-page-${pageNumber}`}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
|
||||
pageNumber === currentPage
|
||||
? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
|
||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<IoChevronForward className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import type { FunctionComponent, MouseEventHandler } from "react";
|
||||
import { HiExternalLink } from "react-icons/hi";
|
||||
|
||||
type Props = {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PaddleLink: FunctionComponent<Props> = ({ onClick, text }) => (
|
||||
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
|
||||
<HiExternalLink className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="font-medium transition-colors duration-150 border-b border-transparent hover:border-primary-500">
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default PaddleLink;
|
@ -1,139 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { type Subscription, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
import SwitchPlanModal from "./switch-plan-modal";
|
||||
|
||||
export type Plan = typeof pricing["tiers"][number];
|
||||
|
||||
function useSubscription() {
|
||||
return {
|
||||
hasActiveSubscription: false,
|
||||
subscription: null as any,
|
||||
subscribe: () => void 0,
|
||||
changePlan: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Plans() {
|
||||
const { hasActiveSubscription, subscription, subscribe, changePlan } = useSubscription();
|
||||
const [nextPlan, setNextPlan] = useState<Plan | null>(null);
|
||||
const [isSwitchPlanModalOpen, setIsSwitchPlanModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 flex flex-row flex-wrap gap-2">
|
||||
{pricing.tiers.map((tier) => {
|
||||
const isCurrentTier = subscription?.paddlePlanId === tier.planId;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
const cta = getCTA({ subscription, tier });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.title}
|
||||
className={clsx(
|
||||
"relative p-2 pt-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-1 min-w-[250px] flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 px-2">
|
||||
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
|
||||
{tier.yearly ? (
|
||||
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
|
||||
Get 2 months free!
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 flex items-baseline text-gray-900">
|
||||
<span className="text-2xl font-extrabold tracking-tight">{tier.price}€</span>
|
||||
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
|
||||
</p>
|
||||
{tier.yearly ? (
|
||||
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12}€)</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-gray-500">{tier.description}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isActiveTier}
|
||||
onClick={() => {
|
||||
if (hasActiveSubscription) {
|
||||
setNextPlan(tier);
|
||||
setIsSwitchPlanModalOpen(true);
|
||||
} else {
|
||||
// subscribe({ planId: tier.planId });
|
||||
// Panelbear.track(`Subscribe to ${tier.title}`);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
!isActiveTier
|
||||
? "bg-primary-500 text-white hover:bg-primary-600"
|
||||
: "bg-primary-50 text-primary-700 cursor-not-allowed",
|
||||
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
|
||||
)}
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SwitchPlanModal
|
||||
isOpen={isSwitchPlanModalOpen}
|
||||
nextPlan={nextPlan}
|
||||
confirm={(nextPlan: Plan) => {
|
||||
// changePlan({ planId: nextPlan.planId });
|
||||
// Panelbear.track(`Subscribe to ${nextPlan.title}`);
|
||||
setIsSwitchPlanModalOpen(false);
|
||||
}}
|
||||
closeModal={() => setIsSwitchPlanModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCTA({
|
||||
subscription,
|
||||
tier,
|
||||
}: {
|
||||
subscription?: Subscription;
|
||||
tier: typeof pricing["tiers"][number];
|
||||
}): string {
|
||||
if (!subscription) {
|
||||
return "Subscribe";
|
||||
}
|
||||
|
||||
const isCancelling = subscription.status === SubscriptionStatus.deleted;
|
||||
if (isCancelling) {
|
||||
return "Resubscribe";
|
||||
}
|
||||
|
||||
const isCurrentTier = subscription.paddlePlanId === tier.planId;
|
||||
const hasActiveSubscription = subscription.status !== SubscriptionStatus.deleted;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
if (isActiveTier) {
|
||||
return "Current plan";
|
||||
}
|
||||
|
||||
return `Switch to ${tier.title}`;
|
||||
}
|
||||
|
||||
const pricing = {
|
||||
tiers: [
|
||||
{
|
||||
title: "Yearly",
|
||||
planId: 727544,
|
||||
price: 12.5,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world, all year long.",
|
||||
yearly: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly",
|
||||
planId: 727540,
|
||||
price: 15,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world.",
|
||||
yearly: false,
|
||||
},
|
||||
],
|
||||
};
|
@ -1,52 +0,0 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
import type { Plan } from "./plans";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
nextPlan: Plan | null;
|
||||
confirm: (nextPlan: Plan) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
const SwitchPlanModal: FunctionComponent<Props> = ({ isOpen, nextPlan, confirm, closeModal }) => {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Modal initialFocus={confirmButtonRef} isOpen={isOpen} onClose={closeModal}>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Are you sure you want to switch to {nextPlan?.title}?</ModalTitle>
|
||||
<div className="mt-2 text-gray-500">
|
||||
<p>
|
||||
You're about to switch to the <strong>{nextPlan?.title}</strong> plan. You will be
|
||||
billed immediately a prorated amount and the next billing date will be recalculated from
|
||||
today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={() => confirm(nextPlan!)}
|
||||
>
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="md:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Nope, cancel it
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitchPlanModal;
|
@ -16,19 +16,6 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Need some help?</ModalTitle>
|
||||
<div className="mt-6 space-y-3 text-gray-500">
|
||||
<p>
|
||||
Try{" "}
|
||||
<a className="underline" href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3">
|
||||
reconnecting your Twilio account
|
||||
</a> to refresh the phone numbers.
|
||||
</p>
|
||||
<p>
|
||||
If you are stuck, pick a date & time on{" "}
|
||||
<a className="underline" href="https://calendly.com/shellphone-onboarding">
|
||||
our calendly
|
||||
</a>{" "}
|
||||
and we will help you get started!
|
||||
</p>
|
||||
<p>
|
||||
Don't miss out on free $10 Twilio credit by using{" "}
|
||||
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
|
||||
|
@ -5,7 +5,7 @@ import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import useSession from "~/features/core/hooks/use-session";
|
||||
import type { PhoneSettingsLoaderData } from "~/features/settings/loaders/phone";
|
||||
import type loader from "~/features/settings/loaders/phone";
|
||||
import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone";
|
||||
import clsx from "clsx";
|
||||
|
||||
@ -13,8 +13,8 @@ export default function PhoneNumberForm() {
|
||||
const { twilio } = useSession();
|
||||
const fetcher = useFetcher();
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<SetPhoneNumberActionData>()?.setPhoneNumber;
|
||||
const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers;
|
||||
const actionData = useActionData<any>()?.setPhoneNumber;
|
||||
const availablePhoneNumbers = useLoaderData<typeof loader>().phoneNumbers;
|
||||
|
||||
const actionSubmitted = transition.submission?.formData.get("_action");
|
||||
const isCurrentFormTransition =
|
||||
@ -25,7 +25,7 @@ export default function PhoneNumberForm() {
|
||||
const topErrorMessage = errors?.general ?? errors?.phoneNumberSid;
|
||||
const isError = typeof topErrorMessage !== "undefined";
|
||||
const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true);
|
||||
const hasFilledTwilioCredentials = twilio !== null;
|
||||
const hasFilledTwilioCredentials = twilio != null;
|
||||
|
||||
if (!hasFilledTwilioCredentials) {
|
||||
return null;
|
||||
|
@ -13,9 +13,11 @@ import Button from "~/features/settings/components/button";
|
||||
|
||||
export default function TwilioConnect() {
|
||||
const { twilio } = useSession();
|
||||
console.log("twilio", twilio);
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData<SetTwilioCredentialsActionData>()?.setTwilioCredentials;
|
||||
const actionData = useActionData<any>()
|
||||
?.setTwilioCredentials as SetTwilioCredentialsActionData["setTwilioCredentials"];
|
||||
const { accountSid, authToken } = useLoaderData<PhoneSettingsLoaderData>();
|
||||
|
||||
const topErrorMessage = actionData?.errors?.general;
|
||||
@ -50,7 +52,7 @@ export default function TwilioConnect() {
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{twilio !== null ? (
|
||||
{twilio != null ? (
|
||||
<p className="text-green-700">✓ Your Twilio account is connected to Shellphone.</p>
|
||||
) : null}
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
import { type LoaderArgs, json } from "@remix-run/node";
|
||||
import { type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { decrypt } from "~/utils/encryption";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type PhoneSettingsLoaderData = {
|
||||
accountSid?: string;
|
||||
@ -12,20 +12,21 @@ export type PhoneSettingsLoaderData = {
|
||||
phoneNumbers: Pick<PhoneNumber, "id" | "number" | "isCurrent">[];
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { organization, twilio } = await requireLoggedIn(request);
|
||||
const loader = async ({ request }: LoaderArgs) => {
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
logger.warn("Twilio account is not connected");
|
||||
return json<PhoneSettingsLoaderData>({ phoneNumbers: [] });
|
||||
return json({ phoneNumbers: [] });
|
||||
}
|
||||
|
||||
const phoneNumbers = await db.phoneNumber.findMany({
|
||||
where: { twilioAccount: { organizationId: organization.id } },
|
||||
where: { twilioAccount: { accountSid: twilio.accountSid } },
|
||||
select: { id: true, number: true, isCurrent: true },
|
||||
orderBy: { id: Prisma.SortOrder.desc },
|
||||
});
|
||||
|
||||
return json<PhoneSettingsLoaderData>({
|
||||
return json({
|
||||
accountSid: twilio.accountSid,
|
||||
authToken: decrypt(twilio.authToken),
|
||||
phoneNumbers,
|
||||
|
@ -1,21 +0,0 @@
|
||||
import sendEmail from "~/utils/mailer.server";
|
||||
import serverConfig from "~/config/config.server";
|
||||
import { render } from "./renderer/renderer.server";
|
||||
|
||||
type Params = {
|
||||
to: string;
|
||||
token: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export async function sendForgotPasswordEmail({ to, token, userName }: Params) {
|
||||
const origin = serverConfig.app.baseUrl;
|
||||
const resetUrl = `${origin}/reset-password?token=${token}`;
|
||||
const html = await render("forgot-password", { action_url: resetUrl, name: userName });
|
||||
|
||||
return sendEmail({
|
||||
recipients: to,
|
||||
subject: "Reset your password",
|
||||
html,
|
||||
});
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" class="email-footer w-570 mx-auto text-center sm:w-full">
|
||||
<tr>
|
||||
<td align="center" class="content-cell p-45 text-base">
|
||||
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
|
||||
© {{ page.year }} {{ page.company.product }}. All rights reserved.
|
||||
</p>
|
||||
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
|
||||
{{ page.company.name }} {{{ page.company.address }}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
@ -1,15 +0,0 @@
|
||||
<tr>
|
||||
<td align="center" class="email-masthead">
|
||||
<a
|
||||
href="https://remixtape.dev"
|
||||
class="email-masthead_name text-base font-bold no-underline text-gray-postmark-light"
|
||||
style="text-shadow: 0 1px 0 #ffffff"
|
||||
>
|
||||
<img
|
||||
width="128px"
|
||||
src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📼</text></svg>"
|
||||
alt="Your logo"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
@ -1,32 +0,0 @@
|
||||
.button {
|
||||
@apply inline-block text-white no-underline;
|
||||
background-color: #3869d4;
|
||||
border-top: 10px solid #3869d4;
|
||||
border-right: 18px solid #3869d4;
|
||||
border-bottom: 10px solid #3869d4;
|
||||
border-left: 18px solid #3869d4;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.button--green {
|
||||
background-color: #22bc66;
|
||||
border-top: 10px solid #22bc66;
|
||||
border-right: 18px solid #22bc66;
|
||||
border-bottom: 10px solid #22bc66;
|
||||
border-left: 18px solid #22bc66;
|
||||
}
|
||||
|
||||
.button--red {
|
||||
background-color: #ff6136;
|
||||
border-top: 10px solid #ff6136;
|
||||
border-right: 18px solid #ff6136;
|
||||
border-bottom: 10px solid #ff6136;
|
||||
border-left: 18px solid #ff6136;
|
||||
}
|
||||
|
||||
@screen sm {
|
||||
.button {
|
||||
@apply w-full text-center !important;
|
||||
}
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
@import "buttons";
|
||||
|
||||
.purchase_heading {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #eaeaec;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.purchase_heading p {
|
||||
@apply text-xxs leading-24 m-0;
|
||||
color: #85878e;
|
||||
}
|
||||
|
||||
.purchase_footer {
|
||||
@apply pt-16 text-base align-middle;
|
||||
border-top-width: 1px;
|
||||
border-top-color: #eaeaec;
|
||||
border-top-style: solid;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
@apply mt-25 pt-25 border-t;
|
||||
border-top-color: #eaeaec;
|
||||
border-top-style: solid;
|
||||
}
|
||||
|
||||
.discount {
|
||||
@apply w-full p-24 bg-gray-postmark-lightest;
|
||||
border: 2px dashed #cbcccf;
|
||||
}
|
||||
|
||||
.email-masthead {
|
||||
@apply py-24 text-base text-center;
|
||||
}
|
||||
|
||||
@screen dark {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
@apply bg-gray-postmark-darker text-white !important;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@apply text-white !important;
|
||||
}
|
||||
|
||||
.attributes_content,
|
||||
.discount {
|
||||
@apply bg-gray-postmark-darkest !important;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
body {
|
||||
@apply m-0 p-0 w-full;
|
||||
word-break: break-word;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
@apply max-w-full leading-full align-middle;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.mso-leading-exactly {
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
<!DOCTYPE {{{ page.doctype || 'html' }}}>
|
||||
<html lang="{{ page.language || 'en' }}" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="{{ page.charset || 'utf-8' }}" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<if condition="page.title">
|
||||
<title>{{{ page.title }}}</title>
|
||||
</if>
|
||||
<if condition="page.googleFonts">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?{{{ page.googleFonts }}}&display=swap"
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
/>
|
||||
</if>
|
||||
<if condition="page.css">
|
||||
<style>
|
||||
{{{ page.css }}}
|
||||
</style>
|
||||
</if>
|
||||
<block name="head"></block>
|
||||
</head>
|
||||
<body class="{{ page.bodyClass }}">
|
||||
<if condition="page.preheader">
|
||||
<div class="hidden">
|
||||
{{{ page.preheader }}}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
‌  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||
 ͏ ͏ ͏ ͏ ͏
|
||||
</div>
|
||||
</if>
|
||||
<div
|
||||
role="article"
|
||||
aria-roledescription="email"
|
||||
aria-label="{{{ page.title || '' }}}"
|
||||
lang="{{ page.language || 'en' }}"
|
||||
>
|
||||
<block name="template"></block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -1,93 +0,0 @@
|
||||
---
|
||||
bodyClass: bg-gray-postmark-lighter
|
||||
---
|
||||
|
||||
<extends src="app/mailers/renderer/html/layouts/main.html">
|
||||
<block name="template">
|
||||
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="email-content w-full">
|
||||
<component src="app/mailers/renderer/html/components/header.html"></component>
|
||||
<tr>
|
||||
<td class="email-body w-full">
|
||||
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
|
||||
<tr>
|
||||
<td class="px-45 py-24">
|
||||
<div class="text-base">
|
||||
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
|
||||
Hi {{ name }},
|
||||
</h1>
|
||||
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||
You recently requested to reset your password for your
|
||||
{{ page.company.product }} account. Use the button below to reset it.
|
||||
<strong
|
||||
>This password reset is only valid for the next 24
|
||||
hours.</strong
|
||||
>
|
||||
</p>
|
||||
<table align="center" class="w-full text-center my-30 mx-auto">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td align="center" class="text-base">
|
||||
<a
|
||||
href="{{ action_url }}"
|
||||
class="button button--green"
|
||||
target="_blank"
|
||||
>
|
||||
Reset your password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||
If you did not request a password reset, you can safely ignore this
|
||||
email.
|
||||
</p>
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p
|
||||
class="
|
||||
mt-6
|
||||
mb-20
|
||||
text-xs
|
||||
leading-24
|
||||
text-gray-postmark-dark
|
||||
"
|
||||
>
|
||||
If you're having trouble with the button above, copy and
|
||||
paste the URL below into your web browser.
|
||||
</p>
|
||||
<p
|
||||
class="
|
||||
mt-6
|
||||
mb-20
|
||||
text-xs
|
||||
leading-24
|
||||
text-gray-postmark-dark
|
||||
"
|
||||
>
|
||||
{{ action_url }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!--<component src="app/mailers/renderer/html/components/footer.html"></component>-->
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</block>
|
||||
</extends>
|
@ -1,18 +0,0 @@
|
||||
/* Your custom CSS resets for email */
|
||||
@import "app/mailers/renderer/html/custom/reset";
|
||||
|
||||
/* Tailwind components that are generated by plugins */
|
||||
@import "tailwindcss/components";
|
||||
|
||||
/**
|
||||
* @import here any custom components - classes that you'd want loaded
|
||||
* before the Tailwind utilities, so that the utilities could still
|
||||
* override them.
|
||||
*/
|
||||
@import "app/mailers/renderer/html/custom/postmark";
|
||||
|
||||
/* Tailwind utility classes */
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* Your custom utility classes */
|
||||
@import "app/mailers/renderer/html/custom/utilities";
|
@ -1,81 +0,0 @@
|
||||
---
|
||||
bodyClass: bg-gray-postmark-lighter
|
||||
---
|
||||
|
||||
<extends src="app/mailers/renderer/html/layouts/main.html">
|
||||
<block name="template">
|
||||
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="email-content w-full">
|
||||
<component src="app/mailers/renderer/html/components/header.html"></component>
|
||||
<tr>
|
||||
<td class="email-body w-full">
|
||||
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
|
||||
<tr>
|
||||
<td class="px-45 py-24">
|
||||
<div class="text-base">
|
||||
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
|
||||
Hi 👋,
|
||||
</h1>
|
||||
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||
{{ invitation_sender_name }} from {{ invitation_sender_organization_name }}
|
||||
has invited you to join their organization.
|
||||
Use the button below to set up your account and get started.
|
||||
<strong>
|
||||
This invitation is only valid for the next 7 days.
|
||||
</strong>
|
||||
</p>
|
||||
<table align="center" class="w-full text-center my-30 mx-auto">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td align="center" class="text-base">
|
||||
<a
|
||||
href="{{ action_url }}"
|
||||
class="button button--green"
|
||||
target="_blank"
|
||||
>
|
||||
Set up account
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||
Welcome aboard,
|
||||
<br />The {{ page.company.product }} Team
|
||||
</p>
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p
|
||||
class="mt-6 mb-20 text-xs leading-24 text-gray-postmark-dark"
|
||||
>
|
||||
If you're having trouble with the button above, copy and
|
||||
paste the URL below into your web browser.
|
||||
</p>
|
||||
<p
|
||||
class="mt-6 mb-20 text-xs leading-24 text-gray-postmark-dark"
|
||||
>
|
||||
{{ action_url }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!--<component src="app/mailers/renderer/html/components/footer.html"></component>-->
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</block>
|
||||
</extends>
|
@ -1,219 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
// @ts-ignore
|
||||
import Maizzle from "@maizzle/framework";
|
||||
|
||||
export async function render(templateName: string, locals: Record<string, string> = {}) {
|
||||
const { template, options } = getMaizzleParams(templateName, locals);
|
||||
const { html } = await Maizzle.render(template, options);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function getMaizzleParams(templateName: string, locals: Record<string, string>) {
|
||||
const template = fs
|
||||
.readFileSync(path.resolve(process.cwd(), "./app/mailers/renderer/html/templates", `${templateName}.html`))
|
||||
.toString();
|
||||
const tailwindCss = fs
|
||||
.readFileSync(path.resolve(process.cwd(), "./app/mailers/renderer/html/templates/tailwind.css"))
|
||||
.toString();
|
||||
|
||||
const options = {
|
||||
tailwind: {
|
||||
css: tailwindCss,
|
||||
config: {
|
||||
mode: "jit",
|
||||
theme: {
|
||||
screens: {
|
||||
sm: { max: "600px" },
|
||||
dark: { raw: "(prefers-color-scheme: dark)" },
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
"postmark-lightest": "#F4F4F7",
|
||||
"postmark-lighter": "#F2F4F6",
|
||||
"postmark-light": "#A8AAAF",
|
||||
"postmark-dark": "#51545E",
|
||||
"postmark-darker": "#333333",
|
||||
"postmark-darkest": "#222222",
|
||||
"postmark-meta": "#85878E",
|
||||
},
|
||||
blue: {
|
||||
postmark: "#3869D4",
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
screen: "100vw",
|
||||
full: "100%",
|
||||
px: "1px",
|
||||
0: "0",
|
||||
2: "2px",
|
||||
3: "3px",
|
||||
4: "4px",
|
||||
5: "5px",
|
||||
6: "6px",
|
||||
7: "7px",
|
||||
8: "8px",
|
||||
9: "9px",
|
||||
10: "10px",
|
||||
11: "11px",
|
||||
12: "12px",
|
||||
14: "14px",
|
||||
16: "16px",
|
||||
20: "20px",
|
||||
21: "21px",
|
||||
24: "24px",
|
||||
25: "25px",
|
||||
28: "28px",
|
||||
30: "30px",
|
||||
32: "32px",
|
||||
35: "35px",
|
||||
36: "36px",
|
||||
40: "40px",
|
||||
44: "44px",
|
||||
45: "45px",
|
||||
48: "48px",
|
||||
52: "52px",
|
||||
56: "56px",
|
||||
60: "60px",
|
||||
64: "64px",
|
||||
72: "72px",
|
||||
80: "80px",
|
||||
96: "96px",
|
||||
570: "570px",
|
||||
600: "600px",
|
||||
"1/2": "50%",
|
||||
"1/3": "33.333333%",
|
||||
"2/3": "66.666667%",
|
||||
"1/4": "25%",
|
||||
"2/4": "50%",
|
||||
"3/4": "75%",
|
||||
"1/5": "20%",
|
||||
"2/5": "40%",
|
||||
"3/5": "60%",
|
||||
"4/5": "80%",
|
||||
"1/6": "16.666667%",
|
||||
"2/6": "33.333333%",
|
||||
"3/6": "50%",
|
||||
"4/6": "66.666667%",
|
||||
"5/6": "83.333333%",
|
||||
"1/12": "8.333333%",
|
||||
"2/12": "16.666667%",
|
||||
"3/12": "25%",
|
||||
"4/12": "33.333333%",
|
||||
"5/12": "41.666667%",
|
||||
"6/12": "50%",
|
||||
"7/12": "58.333333%",
|
||||
"8/12": "66.666667%",
|
||||
"9/12": "75%",
|
||||
"10/12": "83.333333%",
|
||||
"11/12": "91.666667%",
|
||||
},
|
||||
borderRadius: {
|
||||
none: "0px",
|
||||
sm: "2px",
|
||||
DEFAULT: "4px",
|
||||
md: "6px",
|
||||
lg: "8px",
|
||||
xl: "12px",
|
||||
"2xl": "16px",
|
||||
"3xl": "24px",
|
||||
full: "9999px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Nunito Sans"', "-apple-system", '"Segoe UI"', "sans-serif"],
|
||||
serif: ["Constantia", "Georgia", "serif"],
|
||||
mono: ["Menlo", "Consolas", "monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
0: "0",
|
||||
xxs: "12px",
|
||||
xs: "13px",
|
||||
sm: "14px",
|
||||
base: "16px",
|
||||
lg: "18px",
|
||||
xl: "20px",
|
||||
"2xl": "24px",
|
||||
"3xl": "30px",
|
||||
"4xl": "36px",
|
||||
"5xl": "48px",
|
||||
"6xl": "60px",
|
||||
"7xl": "72px",
|
||||
"8xl": "96px",
|
||||
"9xl": "128px",
|
||||
},
|
||||
inset: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
letterSpacing: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
lineHeight: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
maxHeight: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
maxWidth: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
xs: "160px",
|
||||
sm: "192px",
|
||||
md: "224px",
|
||||
lg: "256px",
|
||||
xl: "288px",
|
||||
"2xl": "336px",
|
||||
"3xl": "384px",
|
||||
"4xl": "448px",
|
||||
"5xl": "512px",
|
||||
"6xl": "576px",
|
||||
"7xl": "640px",
|
||||
}),
|
||||
minHeight: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
minWidth: (theme: TailwindThemeHelper) => ({
|
||||
...theme("spacing"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
corePlugins: {
|
||||
animation: false,
|
||||
backgroundOpacity: false,
|
||||
borderOpacity: false,
|
||||
divideOpacity: false,
|
||||
placeholderOpacity: false,
|
||||
textOpacity: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
maizzle: {
|
||||
build: {
|
||||
posthtml: {
|
||||
expressions: {
|
||||
locals,
|
||||
},
|
||||
},
|
||||
},
|
||||
company: {
|
||||
name: "Capsule Corp.",
|
||||
address: `<br>39 Robinson Rd, #11-01<br>Singapore 068911`,
|
||||
product: "Remixtape",
|
||||
sender: "Mokhtar",
|
||||
mailto: "mokhtar@remixtape.dev",
|
||||
},
|
||||
googleFonts: "family=Nunito+Sans:wght@400;700",
|
||||
year: () => new Date().getFullYear(),
|
||||
inlineCSS: true,
|
||||
prettify: true,
|
||||
removeUnusedCSS: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
template,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
type TailwindThemeHelper = (str: string) => {};
|
@ -1,26 +0,0 @@
|
||||
import sendEmail from "~/utils/mailer.server";
|
||||
import serverConfig from "~/config/config.server";
|
||||
import { render } from "./renderer/renderer.server";
|
||||
|
||||
type Params = {
|
||||
to: string;
|
||||
token: string;
|
||||
userName: string;
|
||||
organizationName: string;
|
||||
};
|
||||
|
||||
export async function sendTeamInvitationEmail({ to, token, userName, organizationName }: Params) {
|
||||
const origin = serverConfig.app.baseUrl;
|
||||
const invitationUrl = `${origin}/accept-invitation?token=${token}`;
|
||||
const html = await render("team-invitation", {
|
||||
action_url: invitationUrl,
|
||||
invitation_sender_name: userName,
|
||||
invitation_sender_organization_name: organizationName,
|
||||
});
|
||||
|
||||
return sendEmail({
|
||||
recipients: to,
|
||||
subject: `${userName} has invited you to work with them in Remixtape`,
|
||||
html,
|
||||
});
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
import { Queue } from "~/utils/queue.server";
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { deleteOrganizationEntities } from "~/utils/organization.server";
|
||||
|
||||
type Payload = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export default Queue<Payload>("delete user data", async ({ data }) => {
|
||||
const { userId } = data;
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
memberships: {
|
||||
include: { organization: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
user.memberships.map(async (membership) => {
|
||||
switch (membership.role) {
|
||||
case MembershipRole.OWNER: {
|
||||
await deleteOrganizationEntities(membership.organization);
|
||||
break;
|
||||
}
|
||||
case MembershipRole.USER: {
|
||||
await db.membership.delete({ where: { id: membership.id } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await db.user.delete({ where: { id: user.id } });
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
logger.warn("Could not delete user because it has already been deleted");
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
@ -1,4 +1,3 @@
|
||||
import deleteUserDataQueue from "./delete-user-data.server";
|
||||
import fetchPhoneCallsQueue from "./fetch-phone-calls.server";
|
||||
import insertPhoneCallsQueue from "./insert-phone-calls.server";
|
||||
import fetchMessagesQueue from "./fetch-messages.server";
|
||||
@ -7,7 +6,6 @@ import setTwilioWebhooksQueue from "./set-twilio-webhooks.server";
|
||||
import setTwilioApiKeyQueue from "./set-twilio-api-key.server";
|
||||
|
||||
export default [
|
||||
deleteUserDataQueue,
|
||||
fetchPhoneCallsQueue,
|
||||
insertPhoneCallsQueue,
|
||||
fetchMessagesQueue,
|
||||
|
@ -16,15 +16,7 @@ export default Queue<Payload>("notify incoming message", async ({ data }) => {
|
||||
where: { id: phoneNumberId },
|
||||
select: {
|
||||
twilioAccount: {
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: { notificationSubscription: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { notificationSubscriptions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -32,10 +24,7 @@ export default Queue<Payload>("notify incoming message", async ({ data }) => {
|
||||
logger.warn(`No phone number found with id=${phoneNumberId}`);
|
||||
return;
|
||||
}
|
||||
const subscriptions = phoneNumber.twilioAccount.organization.memberships.flatMap(
|
||||
(membership) => membership.notificationSubscription,
|
||||
);
|
||||
|
||||
const subscriptions = phoneNumber.twilioAccount.notificationSubscriptions;
|
||||
const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
|
||||
const message = await twilioClient.messages.get(messageSid).fetch();
|
||||
const payload = buildMessageNotificationPayload(message);
|
||||
|
@ -8,13 +8,12 @@ import { decrypt } from "~/utils/encryption";
|
||||
|
||||
type Payload = {
|
||||
phoneNumberId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
|
||||
const { phoneNumberId, organizationId } = data;
|
||||
const { phoneNumberId } = data;
|
||||
const phoneNumber = await db.phoneNumber.findFirst({
|
||||
where: { id: phoneNumberId, twilioAccount: { organizationId } },
|
||||
where: { id: phoneNumberId },
|
||||
include: {
|
||||
twilioAccount: {
|
||||
select: { accountSid: true, twimlAppSid: true, authToken: true },
|
||||
@ -33,7 +32,7 @@ export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
|
||||
|
||||
await Promise.all([
|
||||
db.twilioAccount.update({
|
||||
where: { organizationId },
|
||||
where: { accountSid: twilioAccount.accountSid },
|
||||
data: { twimlAppSid },
|
||||
}),
|
||||
twilioClient.incomingPhoneNumbers.get(phoneNumber.id).update({
|
||||
|
61
app/root.tsx
61
app/root.tsx
@ -1,74 +1,17 @@
|
||||
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||
import { type LinksFunction, type LoaderFunction, json } from "@remix-run/node";
|
||||
import {
|
||||
Link,
|
||||
Links,
|
||||
LiveReload,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
import { Link, Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useCatch } from "@remix-run/react";
|
||||
|
||||
import config from "~/config/config.server";
|
||||
import useFathom from "~/features/core/hooks/use-fathom";
|
||||
import Logo from "~/features/core/components/logo";
|
||||
|
||||
import styles from "./styles/tailwind.css";
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
shellphoneConfig: LoaderData["shellphoneConfig"];
|
||||
}
|
||||
}
|
||||
|
||||
type LoaderData = {
|
||||
shellphoneConfig: {
|
||||
sentry: {
|
||||
dsn?: string;
|
||||
};
|
||||
fathom: {
|
||||
siteId: string;
|
||||
domain?: string;
|
||||
};
|
||||
app: {
|
||||
baseUrl: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
export const loader: LoaderFunction = () => {
|
||||
return json<LoaderData>({
|
||||
shellphoneConfig: {
|
||||
sentry: {
|
||||
dsn: config.sentry.dsn,
|
||||
},
|
||||
fathom: {
|
||||
siteId: config.fathom.siteId,
|
||||
domain: config.fathom.domain,
|
||||
},
|
||||
app: {
|
||||
baseUrl: config.app.baseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { shellphoneConfig } = useLoaderData<LoaderData>();
|
||||
useFathom();
|
||||
return (
|
||||
<Document>
|
||||
<Outlet />
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.shellphoneConfig=${JSON.stringify(shellphoneConfig)};`,
|
||||
}}
|
||||
/>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { type LinksFunction, type LoaderFunction, json } from "@remix-run/node";
|
||||
import { Outlet, useCatch, useLoaderData, useMatches } from "@remix-run/react";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { Outlet, useCatch, useMatches } from "@remix-run/react";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import type { SessionData } from "~/utils/session.server";
|
||||
import Footer from "~/features/core/components/footer";
|
||||
import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier";
|
||||
import Notification from "~/features/core/components/notification";
|
||||
@ -12,6 +10,7 @@ import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker
|
||||
import useDevice from "~/features/phone-calls/hooks/use-device";
|
||||
import footerStyles from "~/features/core/components/footer.css";
|
||||
import appStyles from "~/styles/app.css";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: appStyles },
|
||||
@ -24,10 +23,10 @@ export type AppLoaderData = {
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const sessionData = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
|
||||
return json<AppLoaderData>({
|
||||
sessionData,
|
||||
sessionData: { twilio: session.data.twilio },
|
||||
config: {
|
||||
webPushPublicKey: serverConfig.webPush.publicKey,
|
||||
},
|
||||
@ -37,14 +36,9 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
export default function __App() {
|
||||
useDevice();
|
||||
useServiceWorkerRevalidate();
|
||||
const { sessionData } = useLoaderData<AppLoaderData>();
|
||||
const matches = useMatches();
|
||||
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setUser(sessionData.user);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
|
||||
|
@ -3,7 +3,6 @@ import { useLoaderData } from "superjson-remix";
|
||||
|
||||
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
|
||||
import PageTitle from "~/features/core/components/page-title";
|
||||
import InactiveSubscription from "~/features/core/components/inactive-subscription";
|
||||
import PhoneCallsList from "~/features/phone-calls/components/phone-calls-list";
|
||||
import callsLoader, { type PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
@ -15,7 +14,7 @@ export const meta: MetaFunction = () => ({
|
||||
export const loader = callsLoader;
|
||||
|
||||
export default function PhoneCalls() {
|
||||
const { hasPhoneNumber, hasOngoingSubscription } = useLoaderData<PhoneCallsLoaderData>();
|
||||
const { hasPhoneNumber } = useLoaderData<PhoneCallsLoaderData>();
|
||||
|
||||
if (!hasPhoneNumber) {
|
||||
return (
|
||||
@ -26,20 +25,6 @@ export default function PhoneCalls() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOngoingSubscription) {
|
||||
return (
|
||||
<>
|
||||
<InactiveSubscription />
|
||||
<div className="filter blur-sm select-none absolute top-0 w-full h-full z-0">
|
||||
<PageTitle title="Calls" />
|
||||
<section className="relative flex flex-grow flex-col">
|
||||
<PhoneCallsList />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle className="pl-12" title="Calls" />
|
||||
|
@ -11,7 +11,6 @@ import useOnBackspacePress from "~/features/keypad/hooks/use-on-backspace-press"
|
||||
import Keypad from "~/features/keypad/components/keypad";
|
||||
import BlurredKeypad from "~/features/keypad/components/blurred-keypad";
|
||||
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
|
||||
import InactiveSubscription from "~/features/core/components/inactive-subscription";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
import { usePhoneNumber, usePressDigit, useRemoveDigit } from "~/features/keypad/hooks/atoms";
|
||||
|
||||
@ -22,17 +21,13 @@ export const meta: MetaFunction = () => ({
|
||||
export const loader = keypadLoader;
|
||||
|
||||
export default function KeypadPage() {
|
||||
const { hasOngoingSubscription, hasPhoneNumber, lastRecipientCalled } = useLoaderData<KeypadLoaderData>();
|
||||
const { hasPhoneNumber, lastRecipientCalled } = useLoaderData<KeypadLoaderData>();
|
||||
const navigate = useNavigate();
|
||||
const [phoneNumber, setPhoneNumber] = usePhoneNumber();
|
||||
const removeDigit = useRemoveDigit();
|
||||
const pressDigit = usePressDigit();
|
||||
const onBackspacePress = useOnBackspacePress();
|
||||
useKeyPress((key) => {
|
||||
if (!hasOngoingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Backspace") {
|
||||
return removeDigit();
|
||||
}
|
||||
@ -49,15 +44,6 @@ export default function KeypadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOngoingSubscription) {
|
||||
return (
|
||||
<>
|
||||
<InactiveSubscription />
|
||||
<BlurredKeypad />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black">
|
||||
@ -68,7 +54,7 @@ export default function KeypadPage() {
|
||||
<Keypad>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!hasPhoneNumber || !hasOngoingSubscription) {
|
||||
if (!hasPhoneNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,6 @@ import clsx from "clsx";
|
||||
import {
|
||||
IoLogOutOutline,
|
||||
IoNotificationsOutline,
|
||||
IoCardOutline,
|
||||
IoCallOutline,
|
||||
IoPersonCircleOutline,
|
||||
IoHelpBuoyOutline,
|
||||
@ -14,11 +13,8 @@ import Divider from "~/features/settings/components/divider";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
const subNavigation = [
|
||||
{ name: "Account", to: "/settings/account", icon: IoPersonCircleOutline },
|
||||
{ name: "Phone", to: "/settings/phone", icon: IoCallOutline },
|
||||
{ name: "Billing", to: "/settings/billing", icon: IoCardOutline },
|
||||
{ name: "Notifications", to: "/settings/notifications", icon: IoNotificationsOutline },
|
||||
{ name: "Support", to: "/settings/support", icon: IoHelpBuoyOutline },
|
||||
];
|
||||
|
||||
export const meta: MetaFunction = () => ({
|
||||
@ -62,15 +58,6 @@ export default function SettingsLayout() {
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
<Link
|
||||
to="/sign-out"
|
||||
className="group text-gray-900 hover:text-gray-900 hover:bg-gray-50 rounded-md px-3 py-2 flex items-center text-sm font-medium"
|
||||
>
|
||||
<IoLogOutOutline className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
|
||||
Log out
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
@ -1,18 +0,0 @@
|
||||
import accountAction from "~/features/settings/actions/account";
|
||||
import ProfileInformations from "~/features/settings/components/account/profile-informations";
|
||||
import UpdatePassword from "~/features/settings/components/account/update-password";
|
||||
import DangerZone from "~/features/settings/components/account/danger-zone";
|
||||
|
||||
export const action = accountAction;
|
||||
|
||||
export default function Account() {
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<ProfileInformations />
|
||||
|
||||
<UpdatePassword />
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
import { SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
import usePaymentsHistory from "~/features/settings/hooks/use-payments-history";
|
||||
import SettingsSection from "~/features/settings/components/settings-section";
|
||||
import BillingHistory from "~/features/settings/components/billing/billing-history";
|
||||
import Divider from "~/features/settings/components/divider";
|
||||
import Plans from "~/features/settings/components/billing/plans";
|
||||
import PaddleLink from "~/features/settings/components/billing/paddle-link";
|
||||
|
||||
function useSubscription() {
|
||||
return {
|
||||
subscription: null as any,
|
||||
cancelSubscription: () => void 0,
|
||||
updatePaymentMethod: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Billing() {
|
||||
const { count: paymentsCount } = usePaymentsHistory();
|
||||
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription();
|
||||
|
||||
return (
|
||||
<>
|
||||
{subscription ? (
|
||||
<SettingsSection>
|
||||
{subscription.status === SubscriptionStatus.deleted ? (
|
||||
<p>
|
||||
Your {plansName[subscription.paddlePlanId]?.toLowerCase()} subscription is cancelled and
|
||||
will expire on {subscription.cancellationEffectiveDate!.toLocaleDateString()}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>Current plan: {subscription.paddlePlanId}</p>
|
||||
<PaddleLink
|
||||
onClick={() => updatePaymentMethod(/*{ updateUrl: subscription.updateUrl }*/)}
|
||||
text="Update payment method"
|
||||
/>
|
||||
<PaddleLink
|
||||
onClick={() => cancelSubscription(/*{ cancelUrl: subscription.cancelUrl }*/)}
|
||||
text="Cancel subscription"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{paymentsCount > 0 ? (
|
||||
<>
|
||||
<BillingHistory />
|
||||
|
||||
<div className="hidden lg:block lg:py-3">
|
||||
<Divider />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Plans />
|
||||
<p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const plansName: Record<number, string> = {
|
||||
727544: "Yearly",
|
||||
727540: "Monthly",
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
|
||||
export const loader: LoaderFunction = () => redirect("/settings/account");
|
||||
export const loader: LoaderFunction = () => redirect("/settings/phone");
|
||||
|
@ -1,9 +0,0 @@
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<div>
|
||||
<a className="underline" href="mailto:support@shellphone.app">
|
||||
Email us
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { Link, Outlet } from "@remix-run/react";
|
||||
|
||||
import Logo from "~/features/core/components/logo";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 px-8">
|
||||
<div className="mx-auto">
|
||||
<Link to="/" prefetch="intent">
|
||||
<Logo className="mx-auto w-16" />
|
||||
</Link>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
import ForgotPasswordPage from "~/features/auth/pages/forgot-password";
|
||||
import forgotPasswordAction from "~/features/auth/actions/forgot-password";
|
||||
import forgotPasswordLoader from "~/features/auth/loaders/forgot-password";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
export const action = forgotPasswordAction;
|
||||
export const loader = forgotPasswordLoader;
|
||||
export const meta: MetaFunction = () => ({
|
||||
...getSeoMeta({ title: "Forgot password" }),
|
||||
robots: "noindex",
|
||||
googlebot: "noindex",
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
import RegisterPage from "~/features/auth/pages/register";
|
||||
import registerAction from "~/features/auth/actions/register";
|
||||
import registerLoader from "~/features/auth/loaders/register";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
export default RegisterPage;
|
||||
export const action = registerAction;
|
||||
export const loader = registerLoader;
|
||||
export const meta: MetaFunction = () => ({
|
||||
...getSeoMeta({ title: "Register" }),
|
||||
robots: "noindex",
|
||||
googlebot: "noindex",
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
import ResetPasswordPage from "~/features/auth/pages/reset-password";
|
||||
import resetPasswordAction from "~/features/auth/actions/reset-password";
|
||||
import resetPasswordLoader from "~/features/auth/loaders/reset-password";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
export default ResetPasswordPage;
|
||||
export const action = resetPasswordAction;
|
||||
export const loader = resetPasswordLoader;
|
||||
export const meta: MetaFunction = () => ({
|
||||
...getSeoMeta({ title: "Reset password" }),
|
||||
robots: "noindex",
|
||||
googlebot: "noindex",
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
import SignInPage from "~/features/auth/pages/sign-in";
|
||||
import signInAction from "~/features/auth/actions/sign-in";
|
||||
import signInLoader from "~/features/auth/loaders/sign-in";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
export default SignInPage;
|
||||
export const action = signInAction;
|
||||
export const loader = signInLoader;
|
||||
export const meta: MetaFunction = () => ({
|
||||
...getSeoMeta({ title: "Sign in" }),
|
||||
robots: "noindex",
|
||||
googlebot: "noindex",
|
||||
});
|
@ -1,9 +0,0 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
|
||||
import authenticator from "~/utils/authenticator.server";
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const redirectTo = searchParams.get("redirectTo") ?? "/";
|
||||
await authenticator.logout(request, { redirectTo });
|
||||
};
|
@ -26,7 +26,7 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
throw new Error(`Queue "${queueName}"'s scheduler is not running`);
|
||||
}
|
||||
}),
|
||||
db.user.count(),
|
||||
db.twilioAccount.count(),
|
||||
fetch(url.toString(), { method: "HEAD" }).then((r) => {
|
||||
if (!r.ok) return Promise.reject(r);
|
||||
}),
|
||||
|
@ -1,17 +1,5 @@
|
||||
import type { LinksFunction, MetaFunction } from "@remix-run/node";
|
||||
import { type LoaderArgs, redirect } from "@remix-run/node";
|
||||
|
||||
import joinWaitlistAction from "~/features/public-area/actions/index";
|
||||
import IndexPage from "~/features/public-area/pages/index";
|
||||
import { getSeoMeta } from "~/utils/seo";
|
||||
|
||||
import styles from "../styles/index.css";
|
||||
|
||||
export const action = joinWaitlistAction;
|
||||
|
||||
export const meta: MetaFunction = () => ({
|
||||
...getSeoMeta({ title: "", description: "Welcome to Remixtape!" }),
|
||||
});
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||
|
||||
export default IndexPage;
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
return redirect("/messages");
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user