make app usable without account, remove extra stuff

This commit is contained in:
m5r
2023-04-29 18:30:07 +02:00
parent cb35455722
commit 03ae466c66
128 changed files with 617 additions and 14061 deletions

View File

@ -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;

View File

@ -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");
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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,
},
],
};

View File

@ -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&#39;re about to switch to the <strong>{nextPlan?.title}</strong> plan. You will be
billed immediately a prorated amount and the next billing date will be recalculated from
today.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
ref={confirmButtonRef}
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={() => confirm(nextPlan!)}
>
Yes, I&#39;m sure
</button>
<button
type="button"
className="md:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={closeModal}
>
Nope, cancel it
</button>
</div>
</Modal>
);
};
export default SwitchPlanModal;

View File

@ -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&#39;t miss out on free $10 Twilio credit by using{" "}
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">

View File

@ -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;

View File

@ -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}

View File

@ -2,9 +2,9 @@ import { type LoaderArgs, json } from "@remix-run/node";
import { type PhoneNumber, Prisma } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import logger from "~/utils/logger.server";
import { decrypt } from "~/utils/encryption";
import { getSession } from "~/utils/session.server";
export type PhoneSettingsLoaderData = {
accountSid?: string;
@ -13,14 +13,15 @@ export type PhoneSettingsLoaderData = {
};
const loader = async ({ request }: LoaderArgs) => {
const { organization, twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) {
logger.warn("Twilio account is not connected");
return json({ phoneNumbers: [] });
}
const phoneNumbers = await db.phoneNumber.findMany({
where: { twilioAccount: { organizationId: organization.id } },
where: { twilioAccount: { accountSid: twilio.accountSid } },
select: { id: true, number: true, isCurrent: true },
orderBy: { id: Prisma.SortOrder.desc },
});