remixed v0

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

344
app/routes/__app/calls.tsx Normal file
View File

@ -0,0 +1,344 @@
import { Suspense } from "react";
import { type PhoneCall, Prisma } from "@prisma/client";
import { type LoaderFunction, json } from "@remix-run/node";
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
import PageTitle from "~/features/core/components/page-title";
import Spinner from "~/features/core/components/spinner";
import InactiveSubscription from "~/features/core/components/inactive-subscription";
import PhoneCallsList from "~/features/phone-calls/components/phone-calls-list";
import { requireLoggedIn } from "~/utils/auth.server";
import db from "~/utils/db.server";
import { parsePhoneNumber } from "awesome-phonenumber";
type PhoneCallMeta = {
formattedPhoneNumber: string;
country: string | "unknown";
};
export type PhoneCallsLoaderData = { phoneCalls: (PhoneCall & { toMeta: PhoneCallMeta; fromMeta: PhoneCallMeta })[] };
export const loader: LoaderFunction = async ({ request }) => {
const { organizations } = await requireLoggedIn(request);
const organizationId = organizations[0].id;
const phoneNumberId = "";
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId, id: phoneNumberId } });
if (phoneNumber?.isFetchingCalls) {
return;
}
const phoneCalls = await db.phoneCall.findMany({
where: { phoneNumberId },
orderBy: { createdAt: Prisma.SortOrder.desc },
});
return json<PhoneCallsLoaderData>({
phoneCalls: phoneCalls.map((phoneCall) => ({
...phoneCall,
fromMeta: getPhoneNumberMeta(phoneCall.from),
toMeta: getPhoneNumberMeta(phoneCall.to),
})),
});
function getPhoneNumberMeta(rawPhoneNumber: string) {
const countries: Record<string, string> = {
AF: "Afghanistan",
AL: "Albania",
DZ: "Algeria",
AS: "American Samoa",
AD: "Andorra",
AO: "Angola",
AI: "Anguilla",
AQ: "Antarctica",
AG: "Antigua and Barbuda",
AR: "Argentina",
AM: "Armenia",
AW: "Aruba",
AU: "Australia",
AT: "Austria",
AZ: "Azerbaijan",
BS: "Bahamas",
BH: "Bahrain",
BD: "Bangladesh",
BB: "Barbados",
BY: "Belarus",
BE: "Belgium",
BZ: "Belize",
BJ: "Benin",
BM: "Bermuda",
BT: "Bhutan",
BO: "Bolivia",
BA: "Bosnia and Herzegovina",
BW: "Botswana",
BV: "Bouvet Island",
BR: "Brazil",
IO: "British Indian Ocean Territory",
BN: "Brunei",
BG: "Bulgaria",
BF: "Burkina Faso",
BI: "Burundi",
KH: "Cambodia",
CM: "Cameroon",
CA: "Canada",
CV: "Cape Verde",
KY: "Cayman Islands",
CF: "Central African Republic",
TD: "Chad",
CL: "Chile",
CN: "China",
CX: "Christmas Island",
CC: "Cocos (Keeling) Islands",
CO: "Colombia",
KM: "Comoros",
CG: "Congo",
CD: "Congo, the Democratic Republic of the",
CK: "Cook Islands",
CR: "Costa Rica",
CI: "Ivory Coast",
HR: "Croatia",
CU: "Cuba",
CY: "Cyprus",
CZ: "Czech Republic",
DK: "Denmark",
DJ: "Djibouti",
DM: "Dominica",
DO: "Dominican Republic",
EC: "Ecuador",
EG: "Egypt",
SV: "El Salvador",
GQ: "Equatorial Guinea",
ER: "Eritrea",
EE: "Estonia",
ET: "Ethiopia",
FK: "Falkland Islands (Malvinas)",
FO: "Faroe Islands",
FJ: "Fiji",
FI: "Finland",
FR: "France",
GF: "French Guiana",
PF: "French Polynesia",
TF: "French Southern Territories",
GA: "Gabon",
GM: "Gambia",
GE: "Georgia",
DE: "Germany",
GH: "Ghana",
GI: "Gibraltar",
GR: "Greece",
GL: "Greenland",
GD: "Grenada",
GP: "Guadeloupe",
GU: "Guam",
GT: "Guatemala",
GG: "Guernsey",
GN: "Guinea",
GW: "Guinea-Bissau",
GY: "Guyana",
HT: "Haiti",
HM: "Heard Island and McDonald Islands",
VA: "Holy See (Vatican City State)",
HN: "Honduras",
HK: "Hong Kong",
HU: "Hungary",
IS: "Iceland",
IN: "India",
ID: "Indonesia",
IR: "Iran, Islamic Republic of",
IQ: "Iraq",
IE: "Ireland",
IM: "Isle of Man",
IL: "Israel",
IT: "Italy",
JM: "Jamaica",
JP: "Japan",
JE: "Jersey",
JO: "Jordan",
KZ: "Kazakhstan",
KE: "Kenya",
KI: "Kiribati",
KP: "Korea, Democratic People's Republic of",
KR: "South Korea",
KW: "Kuwait",
KG: "Kyrgyzstan",
LA: "Lao People's Democratic Republic",
LV: "Latvia",
LB: "Lebanon",
LS: "Lesotho",
LR: "Liberia",
LY: "Libya",
LI: "Liechtenstein",
LT: "Lithuania",
LU: "Luxembourg",
MO: "Macao",
MK: "Macedonia, the former Yugoslav Republic of",
MG: "Madagascar",
MW: "Malawi",
MY: "Malaysia",
MV: "Maldives",
ML: "Mali",
MT: "Malta",
MH: "Marshall Islands",
MQ: "Martinique",
MR: "Mauritania",
MU: "Mauritius",
YT: "Mayotte",
MX: "Mexico",
FM: "Micronesia, Federated States of",
MD: "Moldova, Republic of",
MC: "Monaco",
MN: "Mongolia",
ME: "Montenegro",
MS: "Montserrat",
MA: "Morocco",
MZ: "Mozambique",
MM: "Burma",
NA: "Namibia",
NR: "Nauru",
NP: "Nepal",
NL: "Netherlands",
AN: "Netherlands Antilles",
NC: "New Caledonia",
NZ: "New Zealand",
NI: "Nicaragua",
NE: "Niger",
NG: "Nigeria",
NU: "Niue",
NF: "Norfolk Island",
MP: "Northern Mariana Islands",
NO: "Norway",
OM: "Oman",
PK: "Pakistan",
PW: "Palau",
PS: "Palestinian Territory, Occupied",
PA: "Panama",
PG: "Papua New Guinea",
PY: "Paraguay",
PE: "Peru",
PH: "Philippines",
PN: "Pitcairn",
PL: "Poland",
PT: "Portugal",
PR: "Puerto Rico",
QA: "Qatar",
RE: "Réunion",
RO: "Romania",
RU: "Russia",
RW: "Rwanda",
SH: "Saint Helena, Ascension and Tristan da Cunha",
KN: "Saint Kitts and Nevis",
LC: "Saint Lucia",
PM: "Saint Pierre and Miquelon",
VC: "St. Vincent and the Grenadines",
WS: "Samoa",
SM: "San Marino",
ST: "Sao Tome and Principe",
SA: "Saudi Arabia",
SN: "Senegal",
RS: "Serbia",
SC: "Seychelles",
SL: "Sierra Leone",
SG: "Singapore",
SK: "Slovakia",
SI: "Slovenia",
SB: "Solomon Islands",
SO: "Somalia",
ZA: "South Africa",
GS: "South Georgia and the South Sandwich Islands",
SS: "South Sudan",
ES: "Spain",
LK: "Sri Lanka",
SD: "Sudan",
SR: "Suriname",
SJ: "Svalbard and Jan Mayen",
SZ: "Swaziland",
SE: "Sweden",
CH: "Switzerland",
SY: "Syrian Arab Republic",
TW: "Taiwan",
TJ: "Tajikistan",
TZ: "Tanzania, United Republic of",
TH: "Thailand",
TL: "Timor-Leste",
TG: "Togo",
TK: "Tokelau",
TO: "Tonga",
TT: "Trinidad and Tobago",
TN: "Tunisia",
TR: "Turkey",
TM: "Turkmenistan",
TC: "Turks and Caicos Islands",
TV: "Tuvalu",
UG: "Uganda",
UA: "Ukraine",
AE: "United Arab Emirates",
GB: "United Kingdom",
US: "United States",
UM: "United States Minor Outlying Islands",
UY: "Uruguay",
UZ: "Uzbekistan",
VU: "Vanuatu",
VE: "Venezuela",
VN: "Vietnam",
VG: "Virgin Islands, British",
VI: "Virgin Islands, U.S.",
WF: "Wallis and Futuna",
EH: "Western Sahara",
YE: "Yemen",
ZM: "Zambia",
ZW: "Zimbabwe",
};
const phoneNumber = parsePhoneNumber(rawPhoneNumber);
const formattedPhoneNumber =
phoneNumber.getNumber("international") ?? phoneNumber.getNumber("national") ?? rawPhoneNumber;
return {
formattedPhoneNumber,
country: countries[phoneNumber.getRegionCode()] ?? "unknown",
};
}
};
export default function PhoneCalls() {
const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = {
hasFilledTwilioCredentials: false,
hasPhoneNumber: false,
hasOngoingSubscription: false,
};
if (!hasFilledTwilioCredentials || !hasPhoneNumber) {
return (
<>
<MissingTwilioCredentials />
<PageTitle className="filter blur-sm select-none absolute top-0" title="Calls" />
</>
);
}
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">
<Suspense fallback={<Spinner />}>
<PhoneCallsList />
</Suspense>
</section>
</div>
</>
);
}
return (
<>
<PageTitle className="pl-12" title="Calls" />
<section className="flex flex-grow flex-col">
<Suspense fallback={<Spinner />}>
{/* TODO: skeleton phone calls list */}
<PhoneCallsList />
</Suspense>
</section>
</>
);
}

197
app/routes/__app/keypad.tsx Normal file
View File

@ -0,0 +1,197 @@
import { Fragment, useRef, useState } from "react";
import { useNavigate } from "@remix-run/react";
import { atom, useAtom } from "jotai";
import { usePress } from "@react-aria/interactions";
import { Transition } from "@headlessui/react";
import { IoBackspace, IoCall } from "react-icons/io5";
import { Direction } from "@prisma/client";
import Keypad from "~/features/keypad/components/keypad";
// import usePhoneCalls from "~/features/keypad/hooks/use-phone-calls";
import useKeyPress from "~/features/keypad/hooks/use-key-press";
// import useCurrentUser from "~/features/core/hooks/use-current-user";
import KeypadErrorModal from "~/features/keypad/components/keypad-error-modal";
import InactiveSubscription from "~/features/core/components/inactive-subscription";
export default function SettingsLayout() {
const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = {
hasFilledTwilioCredentials: false,
hasPhoneNumber: false,
hasOngoingSubscription: false,
};
const navigate = useNavigate();
const [isKeypadErrorModalOpen, setIsKeypadErrorModalOpen] = useState(false);
const phoneCalls: any[] = []; //usePhoneCalls();
const [phoneNumber, setPhoneNumber] = useAtom(phoneNumberAtom);
const removeDigit = useAtom(pressBackspaceAtom)[1];
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pressDigit = useAtom(pressDigitAtom)[1];
useKeyPress((key) => {
if (!hasOngoingSubscription) {
return;
}
if (key === "Backspace") {
return removeDigit();
}
pressDigit(key);
});
const longPressDigit = useAtom(longPressDigitAtom)[1];
const onZeroPressProps = {
onPressStart() {
if (!hasOngoingSubscription) {
return;
}
pressDigit("0");
timeoutRef.current = setTimeout(() => {
longPressDigit("+");
}, 750);
},
onPressEnd() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
},
};
const onDigitPressProps = (digit: string) => ({
onPress() {
// navigator.vibrate(1); // removed in webkit
if (!hasOngoingSubscription) {
return;
}
pressDigit(digit);
},
});
const { pressProps: onBackspacePress } = usePress({
onPressStart() {
timeoutRef.current = setTimeout(() => {
removeDigit();
intervalRef.current = setInterval(removeDigit, 75);
}, 325);
},
onPressEnd() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
return;
}
removeDigit();
},
});
if (!hasOngoingSubscription) {
return (
<>
<InactiveSubscription />
<div className="filter blur-sm select-none absolute top-0 w-full h-full z-0">
<section className="relative w-96 h-full flex flex-col justify-around mx-auto py-5 text-center">
<div className="h-16 text-3xl text-gray-700">
<span>{phoneNumber}</span>
</div>
<Keypad onDigitPressProps={onDigitPressProps} onZeroPressProps={onZeroPressProps}>
<button className="cursor-pointer select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
<IoCall className="w-6 h-6 text-white" />
</button>
</Keypad>
</section>
</div>
</>
);
}
return (
<>
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black">
<div className="h-16 text-3xl text-gray-700">
<span>{phoneNumber}</span>
</div>
<Keypad onDigitPressProps={onDigitPressProps} onZeroPressProps={onZeroPressProps}>
<button
onClick={async () => {
if (!hasFilledTwilioCredentials || !hasPhoneNumber) {
setIsKeypadErrorModalOpen(true);
return;
}
if (!hasOngoingSubscription) {
return;
}
if (phoneNumber === "") {
const lastCall = phoneCalls?.[0];
if (lastCall) {
const lastCallRecipient =
lastCall.direction === Direction.Inbound ? lastCall.from : lastCall.to;
setPhoneNumber(lastCallRecipient);
}
return;
}
await navigate(`/outgoing-call/${encodeURI(phoneNumber)}`);
setPhoneNumber("");
}}
className="cursor-pointer select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full"
>
<IoCall className="w-6 h-6 text-white" />
</button>
<Transition
as={Fragment}
show={phoneNumber.length > 0}
enter="transition duration-300 ease-in-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-100 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<div {...onBackspacePress} className="cursor-pointer select-none m-auto">
<IoBackspace className="w-6 h-6" />
</div>
</Transition>
</Keypad>
</div>
<KeypadErrorModal closeModal={() => setIsKeypadErrorModalOpen(false)} isOpen={isKeypadErrorModalOpen} />
</>
);
}
const phoneNumberAtom = atom("");
const pressDigitAtom = atom(null, (get, set, digit: string) => {
if (get(phoneNumberAtom).length > 17) {
return;
}
if ("0123456789+#*".indexOf(digit) === -1) {
return;
}
set(phoneNumberAtom, (prevState) => prevState + digit);
});
const longPressDigitAtom = atom(null, (get, set, replaceWith: string) => {
if (get(phoneNumberAtom).length > 17) {
return;
}
set(phoneNumberAtom, (prevState) => prevState.slice(0, -1) + replaceWith);
});
const pressBackspaceAtom = atom(null, (get, set) => {
if (get(phoneNumberAtom).length === 0) {
return;
}
set(phoneNumberAtom, (prevState) => prevState.slice(0, -1));
});

View File

@ -0,0 +1,99 @@
import { Suspense } from "react";
import { json, type LoaderFunction, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useNavigate, useParams } from "@remix-run/react";
import { IoCall, IoChevronBack, IoInformationCircle } from "react-icons/io5";
import { type Message, Prisma } from "@prisma/client";
import Conversation from "~/features/messages/components/conversation";
import { getSeoMeta } from "~/utils/seo";
import db from "~/utils/db.server";
import { parsePhoneNumber } from "awesome-phonenumber";
import { requireLoggedIn } from "~/utils/auth.server";
export const meta: MetaFunction = ({ params }) => {
const recipient = decodeURIComponent(params.recipient ?? "");
return {
...getSeoMeta({
title: `Messages with ${recipient}`,
}),
};
};
type ConversationType = {
recipient: string;
formattedPhoneNumber: string;
messages: Message[];
};
export type ConversationLoaderData = {
conversation: ConversationType;
};
export const loader: LoaderFunction = async ({ request, params }) => {
const { organizations } = await requireLoggedIn(request);
const recipient = decodeURIComponent(params.recipient ?? "");
const conversation = await getConversation(recipient);
return json<ConversationLoaderData>({ conversation });
async function getConversation(recipient: string): Promise<ConversationType> {
/*if (!hasFilledTwilioCredentials) {
return;
}*/
const organizationId = organizations[0].id;
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: { phoneNumbers: true },
});
if (!organization || !organization.phoneNumbers[0]) {
throw new Error("Not found");
}
const phoneNumber = organization.phoneNumbers[0]; // TODO: use the active number, not the first one
const phoneNumberId = phoneNumber.id;
if (organization.phoneNumbers[0].isFetchingMessages) {
throw new Error("Not found");
}
const formattedPhoneNumber = parsePhoneNumber(recipient).getNumber("international");
const messages = await db.message.findMany({
where: {
phoneNumberId,
OR: [{ from: recipient }, { to: recipient }],
},
orderBy: { sentAt: Prisma.SortOrder.desc },
});
return {
recipient,
formattedPhoneNumber,
messages,
};
}
};
export default function ConversationPage() {
const navigate = useNavigate();
const params = useParams<{ recipient: string }>();
const recipient = decodeURIComponent(params.recipient ?? "");
const { conversation } = useLoaderData<ConversationLoaderData>();
return (
<>
<header className="absolute top-0 w-screen h-12 backdrop-filter backdrop-blur-sm bg-white bg-opacity-75 border-b grid grid-cols-3 items-center">
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={() => navigate(-1)}>
<IoChevronBack className="h-8 w-8" />
</span>
<strong className="col-span-1">{conversation?.formattedPhoneNumber ?? recipient}</strong>
<span className="col-span-1 flex justify-end space-x-4 pr-2">
<IoCall className="h-8 w-8" />
<IoInformationCircle className="h-8 w-8" />
</span>
</header>
<Suspense fallback={<div className="pt-12">Loading messages with {recipient}</div>}>
<Conversation />
</Suspense>
</>
);
}

View File

@ -0,0 +1,156 @@
import { type LoaderFunction, json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { type Message, Prisma, Direction } from "@prisma/client";
import { parsePhoneNumber } from "awesome-phonenumber";
import PageTitle from "~/features/core/components/page-title";
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
import ConversationsList from "~/features/messages/components/conversations-list";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
export type MessagesLoaderData = {
user: {
hasFilledTwilioCredentials: boolean;
hasPhoneNumber: boolean;
};
conversations: Record<string, Conversation> | undefined;
};
type Conversation = {
recipient: string;
formattedPhoneNumber: string;
lastMessage: Message;
};
export const loader: LoaderFunction = async ({ request }) => {
const { id, organizations } = await requireLoggedIn(request);
/*const user = await db.user.findFirst({
where: { id },
select: {
id: true,
fullName: true,
email: true,
role: true,
memberships: {
include: {
organization: {
include: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
},
},
},
},
});
const organization = user!.memberships[0]!.organization;
const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken);*/
const hasFilledTwilioCredentials = false;
const phoneNumber = await db.phoneNumber.findFirst({
// TODO: use the active number, not the first one
where: { organizationId: organizations[0].id },
select: {
id: true,
organizationId: true,
number: true,
},
});
const conversations = await getConversations();
return json<MessagesLoaderData>({
user: {
hasFilledTwilioCredentials,
hasPhoneNumber: Boolean(phoneNumber),
},
conversations,
});
async function getConversations() {
if (!hasFilledTwilioCredentials) {
return;
}
const organizationId = organizations[0].id;
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: { phoneNumbers: true },
});
if (!organization || !organization.phoneNumbers[0]) {
throw new Error("Not found");
}
const phoneNumberId = organization.phoneNumbers[0].id; // TODO: use the active number, not the first one
if (organization.phoneNumbers[0].isFetchingMessages) {
return;
}
const messages = await db.message.findMany({
where: { phoneNumberId },
orderBy: { sentAt: Prisma.SortOrder.desc },
});
let conversations: Record<string, Conversation> = {};
for (const message of messages) {
let recipient: string;
if (message.direction === Direction.Outbound) {
recipient = message.to;
} else {
recipient = message.from;
}
const formattedPhoneNumber = parsePhoneNumber(recipient).getNumber("international");
if (!conversations[recipient]) {
conversations[recipient] = {
recipient,
formattedPhoneNumber,
lastMessage: message,
};
}
if (conversations[recipient].lastMessage.sentAt > message.sentAt) {
conversations[recipient].lastMessage = message;
}
/*conversations[recipient]!.messages.push({
...message,
content: decrypt(message.content, organization.encryptionKey),
});*/
}
return conversations;
}
};
export default function MessagesPage() {
const { user } = useLoaderData<MessagesLoaderData>();
if (!user.hasFilledTwilioCredentials || !user.hasPhoneNumber) {
return (
<>
<MissingTwilioCredentials />
<PageTitle className="filter blur-sm select-none absolute top-0" title="Messages" />
</>
);
}
return (
<>
<PageTitle title="Messages" />
<section className="flex flex-grow flex-col">
{/* TODO: skeleton conversations list */}
<ConversationsList />
</section>
</>
);
}

View File

@ -0,0 +1,93 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, NavLink, Outlet, useNavigate } from "@remix-run/react";
import clsx from "clsx";
import {
IoChevronBack,
IoLogOutOutline,
IoNotificationsOutline,
IoCardOutline,
IoCallOutline,
IoPersonCircleOutline,
} from "react-icons/io5";
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 },
];
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "Settings" }),
});
export default function SettingsLayout() {
const navigate = useNavigate();
return (
<section>
<header className="bg-gray-100 px-2 sm:px-6 lg:px-8">
<header className="flex">
<span className="flex items-center cursor-pointer" onClick={() => navigate(-1)}>
<IoChevronBack className="h-8 w-8 mr-2" /> Back
</span>
</header>
</header>
<main className="flex flex-col flex-grow mx-auto w-full max-w-7xl pb-10 lg:py-12 lg:px-8">
<div className="flex flex-col flex-grow lg:grid lg:grid-cols-12 lg:gap-x-5">
<aside className="py-6 px-2 sm:px-6 lg:py-0 lg:px-0 lg:col-span-3">
<nav className="space-y-1 h-full flex flex-col">
{subNavigation.map((item) => (
<NavLink
key={item.name}
to={item.to}
prefetch="intent"
className={({ isActive }) =>
clsx(
isActive
? "bg-gray-50 text-primary-600 hover:bg-white"
: "text-gray-900 hover:text-gray-900 hover:bg-gray-50",
"group rounded-md px-3 py-2 flex items-center text-sm font-medium",
)
}
>
{({ isActive }) => (
<>
<item.icon
className={clsx(
isActive
? "text-primary-500"
: "text-gray-400 group-hover:text-gray-500",
"flex-shrink-0 -ml-1 mr-3 h-6 w-6",
)}
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</>
)}
</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>
<div className="flex-grow space-y-6 px-2 sm:px-6 lg:col-span-9">
<Outlet />
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,15 @@
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 default function Account() {
return (
<div className="flex flex-col space-y-6">
<ProfileInformations />
<UpdatePassword />
<DangerZone />
</div>
);
}

View File

@ -0,0 +1,68 @@
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,
};
}
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",
};
export default Billing;

View File

@ -0,0 +1,4 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
export const loader: LoaderFunction = () => redirect("/settings/account");

View File

@ -0,0 +1,7 @@
function Notifications() {
return (
<div>Coming soon</div>
);
}
export default Notifications;

View File

@ -0,0 +1,13 @@
import TwilioApiForm from "~/features/settings/components/phone/twilio-api-form";
import PhoneNumberForm from "~/features/settings/components/phone/phone-number-form";
function PhoneSettings() {
return (
<div className="flex flex-col space-y-6">
<TwilioApiForm />
<PhoneNumberForm />
</div>
);
}
export default PhoneSettings;