remixed v0
This commit is contained in:
90
app/features/settings/components/account/danger-zone.tsx
Normal file
90
app/features/settings/components/account/danger-zone.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
|
||||
export default function DangerZone() {
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
|
||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isDeletingUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationModalOpen(false);
|
||||
};
|
||||
const onConfirm = () => {
|
||||
setIsDeletingUser(true);
|
||||
// return deleteUserMutation(); // TODO
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection className="border border-red-300">
|
||||
<div className="flex justify-between items-center flex-row space-x-2">
|
||||
<p>
|
||||
Once you delete your account, all of its data will be permanently deleted and any ongoing
|
||||
subscription will be cancelled.
|
||||
</p>
|
||||
|
||||
<span className="text-base font-medium">
|
||||
<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
|
||||
Delete my account
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal initialFocus={modalCancelButtonRef} isOpen={isConfirmationModalOpen} onClose={closeModal}>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Delete my account</ModalTitle>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Are you sure you want to delete your account? Your subscription will be cancelled and
|
||||
your data permanently deleted.
|
||||
</p>
|
||||
<p>
|
||||
You are free to create a new account with the same email address if you ever wish to
|
||||
come back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 md:ml-3 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-red-400 cursor-not-allowed": isDeletingUser,
|
||||
"bg-red-600 hover:bg-red-700": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
onClick={onConfirm}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
<button
|
||||
ref={modalCancelButtonRef}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
|
||||
{
|
||||
"bg-gray-50 cursor-not-allowed": isDeletingUser,
|
||||
"hover:bg-gray-50": !isDeletingUser,
|
||||
},
|
||||
)}
|
||||
onClick={closeModal}
|
||||
disabled={isDeletingUser}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
import Alert from "../../../core/components/alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import useSession from "~/features/core/hooks/use-session";
|
||||
|
||||
const ProfileInformations: FunctionComponent = () => {
|
||||
const user = useSession();
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData();
|
||||
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
const isSuccess = actionData?.submitted === true;
|
||||
const error = actionData?.error;
|
||||
const isError = !!error;
|
||||
|
||||
const onSubmit = async () => {
|
||||
// await updateUserMutation({ email, fullName }); // TODO
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<SettingsSection
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={error} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
|
||||
Full name
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
tabIndex={1}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
defaultValue={user.fullName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
tabIndex={2}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
defaultValue={user.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileInformations;
|
85
app/features/settings/components/account/update-password.tsx
Normal file
85
app/features/settings/components/account/update-password.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import { useActionData, useTransition } from "@remix-run/react";
|
||||
|
||||
const UpdatePassword: FunctionComponent = () => {
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData();
|
||||
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
const isSuccess = actionData?.submitted === true;
|
||||
const error = actionData?.error;
|
||||
const isError = !!error;
|
||||
|
||||
const onSubmit = async () => {
|
||||
// await changePasswordMutation({ currentPassword, newPassword }); // TODO
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<SettingsSection
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={error} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
<div>Current password</div>
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
tabIndex={3}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
|
||||
>
|
||||
<div>New password</div>
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
tabIndex={4}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePassword;
|
172
app/features/settings/components/billing/billing-history.tsx
Normal file
172
app/features/settings/components/billing/billing-history.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
|
||||
import clsx from "clsx";
|
||||
|
||||
import usePaymentsHistory from "../../hooks/use-payments-history";
|
||||
|
||||
export default function BillingHistory() {
|
||||
const {
|
||||
payments,
|
||||
count,
|
||||
skip,
|
||||
pagesNumber,
|
||||
currentPage,
|
||||
lastPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
goToPreviousPage,
|
||||
goToNextPage,
|
||||
setPage,
|
||||
} = usePaymentsHistory();
|
||||
|
||||
if (payments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
|
||||
<div className="px-4 sm:px-6">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Billing history</h2>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden border-t border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Amount
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span className="sr-only">View receipt</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<time>{new Date(payment.payout_date).toDateString()}</time>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: payment.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(payment.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{payment.is_paid === 1 ? "Paid" : "Upcoming"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{typeof payment.receipt_url !== "undefined" ? (
|
||||
<a
|
||||
href={payment.receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View receipt
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasPreviousPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<p className="text-sm text-gray-700 self-center">
|
||||
Page <span className="font-medium">{currentPage}</span> of{" "}
|
||||
<span className="font-medium">{lastPage}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className={clsx(
|
||||
"ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
||||
!hasNextPage && "invisible",
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{skip + 1}</span> to{" "}
|
||||
<span className="font-medium">{skip + payments.length}</span> of{" "}
|
||||
<span className="font-medium">{count}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<IoChevronBack className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
{pagesNumber.map((pageNumber) => (
|
||||
<button
|
||||
key={`billing-history-button-page-${pageNumber}`}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
|
||||
pageNumber === currentPage
|
||||
? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
|
||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<IoChevronForward className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
18
app/features/settings/components/billing/paddle-link.tsx
Normal file
18
app/features/settings/components/billing/paddle-link.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { FunctionComponent, MouseEventHandler } from "react";
|
||||
import { HiExternalLink } from "react-icons/hi";
|
||||
|
||||
type Props = {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PaddleLink: FunctionComponent<Props> = ({ onClick, text }) => (
|
||||
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
|
||||
<HiExternalLink className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="font-medium transition-colors duration-150 border-b border-transparent hover:border-primary-500">
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default PaddleLink;
|
139
app/features/settings/components/billing/plans.tsx
Normal file
139
app/features/settings/components/billing/plans.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { type Subscription, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
import SwitchPlanModal from "./switch-plan-modal";
|
||||
|
||||
export type Plan = typeof pricing["tiers"][number];
|
||||
|
||||
function useSubscription() {
|
||||
return {
|
||||
hasActiveSubscription: false,
|
||||
subscription: null as any,
|
||||
subscribe: () => void 0,
|
||||
changePlan: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Plans() {
|
||||
const { hasActiveSubscription, subscription, subscribe, changePlan } = useSubscription();
|
||||
const [nextPlan, setNextPlan] = useState<Plan | null>(null);
|
||||
const [isSwitchPlanModalOpen, setIsSwitchPlanModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 flex flex-row flex-wrap gap-2">
|
||||
{pricing.tiers.map((tier) => {
|
||||
const isCurrentTier = subscription?.paddlePlanId === tier.planId;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
const cta = getCTA({ subscription, tier });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.title}
|
||||
className={clsx(
|
||||
"relative p-2 pt-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-1 min-w-[250px] flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 px-2">
|
||||
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
|
||||
{tier.yearly ? (
|
||||
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
|
||||
Get 2 months free!
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-4 flex items-baseline text-gray-900">
|
||||
<span className="text-2xl font-extrabold tracking-tight">{tier.price}€</span>
|
||||
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
|
||||
</p>
|
||||
{tier.yearly ? (
|
||||
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12}€)</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-gray-500">{tier.description}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isActiveTier}
|
||||
onClick={() => {
|
||||
if (hasActiveSubscription) {
|
||||
setNextPlan(tier);
|
||||
setIsSwitchPlanModalOpen(true);
|
||||
} else {
|
||||
// subscribe({ planId: tier.planId });
|
||||
// Panelbear.track(`Subscribe to ${tier.title}`);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
!isActiveTier
|
||||
? "bg-primary-500 text-white hover:bg-primary-600"
|
||||
: "bg-primary-50 text-primary-700 cursor-not-allowed",
|
||||
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
|
||||
)}
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SwitchPlanModal
|
||||
isOpen={isSwitchPlanModalOpen}
|
||||
nextPlan={nextPlan}
|
||||
confirm={(nextPlan: Plan) => {
|
||||
// changePlan({ planId: nextPlan.planId });
|
||||
// Panelbear.track(`Subscribe to ${nextPlan.title}`);
|
||||
setIsSwitchPlanModalOpen(false);
|
||||
}}
|
||||
closeModal={() => setIsSwitchPlanModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCTA({
|
||||
subscription,
|
||||
tier,
|
||||
}: {
|
||||
subscription?: Subscription;
|
||||
tier: typeof pricing["tiers"][number];
|
||||
}): string {
|
||||
if (!subscription) {
|
||||
return "Subscribe";
|
||||
}
|
||||
|
||||
const isCancelling = subscription.status === SubscriptionStatus.deleted;
|
||||
if (isCancelling) {
|
||||
return "Resubscribe";
|
||||
}
|
||||
|
||||
const isCurrentTier = subscription.paddlePlanId === tier.planId;
|
||||
const hasActiveSubscription = subscription.status !== SubscriptionStatus.deleted;
|
||||
const isActiveTier = hasActiveSubscription && isCurrentTier;
|
||||
if (isActiveTier) {
|
||||
return "Current plan";
|
||||
}
|
||||
|
||||
return `Switch to ${tier.title}`;
|
||||
}
|
||||
|
||||
const pricing = {
|
||||
tiers: [
|
||||
{
|
||||
title: "Yearly",
|
||||
planId: 727544,
|
||||
price: 12.5,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world, all year long.",
|
||||
yearly: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly",
|
||||
planId: 727540,
|
||||
price: 15,
|
||||
frequency: "/month",
|
||||
description: "Text and call anyone, anywhere in the world.",
|
||||
yearly: false,
|
||||
},
|
||||
],
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
import type { Plan } from "./plans";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
nextPlan: Plan | null;
|
||||
confirm: (nextPlan: Plan) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
const SwitchPlanModal: FunctionComponent<Props> = ({ isOpen, nextPlan, confirm, closeModal }) => {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Modal initialFocus={confirmButtonRef} isOpen={isOpen} onClose={closeModal}>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Are you sure you want to switch to {nextPlan?.title}?</ModalTitle>
|
||||
<div className="mt-2 text-gray-500">
|
||||
<p>
|
||||
You're about to switch to the <strong>{nextPlan?.title}</strong> plan. You will be
|
||||
billed immediately a prorated amount and the next billing date will be recalculated from
|
||||
today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={() => confirm(nextPlan!)}
|
||||
>
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="md:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Nope, cancel it
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitchPlanModal;
|
48
app/features/settings/components/button.tsx
Normal file
48
app/features/settings/components/button.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import type { ButtonHTMLAttributes, FunctionComponent, MouseEventHandler, PropsWithChildren } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
variant: Variant;
|
||||
onClick?: MouseEventHandler;
|
||||
isDisabled?: boolean;
|
||||
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
};
|
||||
|
||||
const Button: FunctionComponent<PropsWithChildren<Props>> = ({ children, type, variant, onClick, isDisabled }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
type={type}
|
||||
className={clsx(
|
||||
"inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
{
|
||||
[VARIANTS_STYLES[variant].base]: !isDisabled,
|
||||
[VARIANTS_STYLES[variant].disabled]: isDisabled,
|
||||
},
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
||||
type Variant = "error" | "default";
|
||||
|
||||
type VariantStyle = {
|
||||
base: string;
|
||||
disabled: string;
|
||||
};
|
||||
|
||||
const VARIANTS_STYLES: Record<Variant, VariantStyle> = {
|
||||
error: {
|
||||
base: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
||||
disabled: "bg-red-400 cursor-not-allowed focus:ring-red-500",
|
||||
},
|
||||
default: {
|
||||
base: "bg-primary-600 hover:bg-primary-700 focus:ring-primary-500",
|
||||
disabled: "bg-primary-400 cursor-not-allowed focus:ring-primary-500",
|
||||
},
|
||||
};
|
11
app/features/settings/components/divider.tsx
Normal file
11
app/features/settings/components/divider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Divider({ className = "" }) {
|
||||
return (
|
||||
<div className={clsx(className, "relative")}>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
57
app/features/settings/components/phone/help-modal.tsx
Normal file
57
app/features/settings/components/phone/help-modal.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||
|
||||
type Props = {
|
||||
isHelpModalOpen: boolean;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) => {
|
||||
const modalCloseButtonRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<Modal initialFocus={modalCloseButtonRef} isOpen={isHelpModalOpen} onClose={closeModal}>
|
||||
<div className="md:flex md:items-start">
|
||||
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
|
||||
<ModalTitle>Need help finding your Twilio credentials?</ModalTitle>
|
||||
<div className="mt-6 space-y-3 text-gray-500">
|
||||
<p>
|
||||
You can check out our{" "}
|
||||
<a className="underline" href="https://docs.shellphone.app/guide/getting-started">
|
||||
getting started
|
||||
</a>{" "}
|
||||
guide to set up your account with your Twilio credentials.
|
||||
</p>
|
||||
<p>
|
||||
If you feel stuck, pick a date & time on{" "}
|
||||
<a className="underline" href="https://calendly.com/shellphone-onboarding">
|
||||
our calendly
|
||||
</a>{" "}
|
||||
and we will help you get started!
|
||||
</p>
|
||||
<p>
|
||||
Don't miss out on free $10 Twilio credit by using{" "}
|
||||
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
|
||||
our referral link
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
|
||||
<button
|
||||
ref={modalCloseButtonRef}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Noted, thanks the help!
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpModal;
|
94
app/features/settings/components/phone/phone-number-form.tsx
Normal file
94
app/features/settings/components/phone/phone-number-form.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { useActionData, useCatch, useTransition } from "@remix-run/react";
|
||||
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
|
||||
export default function PhoneNumberForm() {
|
||||
const transition = useTransition();
|
||||
const actionData = useActionData();
|
||||
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
const isSuccess = actionData?.submitted === true;
|
||||
const error = actionData?.error;
|
||||
const isError = !!error;
|
||||
|
||||
const hasFilledTwilioCredentials = false; // Boolean(currentOrganization?.twilioAccountSid && currentOrganization?.twilioAuthToken)
|
||||
const availablePhoneNumbers: any[] = [];
|
||||
|
||||
const onSubmit = async () => {
|
||||
// await setPhoneNumberMutation({ phoneNumberSid }); // TODO
|
||||
};
|
||||
|
||||
if (!hasFilledTwilioCredentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||
<SettingsSection
|
||||
className="relative"
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Oops, there was an issue" message={error} variant="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="mb-8">
|
||||
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
|
||||
Phone number
|
||||
</label>
|
||||
<select
|
||||
id="phoneNumberSid"
|
||||
name="phoneNumberSid"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="none" />
|
||||
{availablePhoneNumbers?.map(({ sid, phoneNumber }) => (
|
||||
<option value={sid} key={sid}>
|
||||
{phoneNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="error"
|
||||
title="Authorization error"
|
||||
message={
|
||||
<>
|
||||
<p>
|
||||
We failed to fetch your Twilio phone numbers. Make sure both your SID and your auth token are
|
||||
valid and that your Twilio account isn't suspended.
|
||||
{caught.data ? <a href={caught.data.moreInfo}>Related Twilio docs</a> : null}
|
||||
</p>
|
||||
<button className="inline-flex pt-2 text-left" onClick={window.location.reload}>
|
||||
<span className="transition-colors duration-150 border-b border-red-200 hover:border-red-500">
|
||||
Try again
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
66
app/features/settings/components/phone/twilio-api-form.tsx
Normal file
66
app/features/settings/components/phone/twilio-api-form.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { useTransition } from "@remix-run/react";
|
||||
import { IoHelpCircle } from "react-icons/io5";
|
||||
|
||||
import HelpModal from "./help-modal";
|
||||
import Button from "../button";
|
||||
import SettingsSection from "../settings-section";
|
||||
|
||||
export default function TwilioApiForm() {
|
||||
const transition = useTransition();
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
||||
const isSubmitting = transition.state === "submitting";
|
||||
|
||||
const onSubmit = async () => {
|
||||
// await setTwilioApiFieldsMutation({ twilioAccountSid, twilioAuthToken }); // TODO
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||
<SettingsSection
|
||||
className="relative"
|
||||
footer={
|
||||
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
|
||||
<Button variant="default" type="submit" isDisabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button onClick={() => setIsHelpModalOpen(true)} className="absolute top-2 right-2">
|
||||
<IoHelpCircle className="w-6 h-6 text-primary-700" />
|
||||
</button>
|
||||
<article>
|
||||
Shellphone needs some informations about your Twilio account to securely use your phone numbers.
|
||||
</article>
|
||||
|
||||
<div className="w-full">
|
||||
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
|
||||
Twilio Account SID
|
||||
</label>
|
||||
<input
|
||||
id="twilioAccountSid"
|
||||
name="twilioAccountSid"
|
||||
type="text"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
|
||||
Twilio Auth Token
|
||||
</label>
|
||||
<input
|
||||
id="twilioAuthToken"
|
||||
name="twilioAuthToken"
|
||||
type="text"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
|
||||
<HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
16
app/features/settings/components/settings-section.tsx
Normal file
16
app/features/settings/components/settings-section.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { FunctionComponent, ReactNode, PropsWithChildren } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
const SettingsSection: FunctionComponent<PropsWithChildren<Props>> = ({ children, footer, className }) => (
|
||||
<section className={clsx(className, "shadow sm:rounded-md sm:overflow-hidden")}>
|
||||
<div className="bg-white space-y-6 py-6 px-4 sm:p-6">{children}</div>
|
||||
{footer ?? null}
|
||||
</section>
|
||||
);
|
||||
|
||||
export default SettingsSection;
|
38
app/features/settings/hooks/use-payments-history.ts
Normal file
38
app/features/settings/hooks/use-payments-history.ts
Normal file
@ -0,0 +1,38 @@
|
||||
type Payment = {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
payout_date: string;
|
||||
is_paid: number;
|
||||
is_one_off_charge: boolean;
|
||||
receipt_url?: string;
|
||||
};
|
||||
|
||||
export default function usePaymentsHistory() {
|
||||
const payments: Payment[] = [];
|
||||
const count = 0;
|
||||
const skip = 0;
|
||||
const pagesNumber = [1];
|
||||
const currentPage = 0;
|
||||
const lastPage = 0;
|
||||
const hasPreviousPage = false;
|
||||
const hasNextPage = false;
|
||||
const goToPreviousPage = () => void 0;
|
||||
const goToNextPage = () => void 0;
|
||||
const setPage = (page: number) => void 0;
|
||||
|
||||
return {
|
||||
payments,
|
||||
count,
|
||||
skip,
|
||||
pagesNumber,
|
||||
currentPage,
|
||||
lastPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
goToPreviousPage,
|
||||
goToNextPage,
|
||||
setPage,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user