remixed v0
This commit is contained in:
344
app/routes/__app/calls.tsx
Normal file
344
app/routes/__app/calls.tsx
Normal 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
197
app/routes/__app/keypad.tsx
Normal 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));
|
||||
});
|
99
app/routes/__app/messages.$recipient.tsx
Normal file
99
app/routes/__app/messages.$recipient.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
156
app/routes/__app/messages.tsx
Normal file
156
app/routes/__app/messages.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
93
app/routes/__app/settings.tsx
Normal file
93
app/routes/__app/settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
15
app/routes/__app/settings/account.tsx
Normal file
15
app/routes/__app/settings/account.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
app/routes/__app/settings/billing.tsx
Normal file
68
app/routes/__app/settings/billing.tsx
Normal 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;
|
4
app/routes/__app/settings/index.tsx
Normal file
4
app/routes/__app/settings/index.tsx
Normal 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");
|
7
app/routes/__app/settings/notifications.tsx
Normal file
7
app/routes/__app/settings/notifications.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
function Notifications() {
|
||||
return (
|
||||
<div>Coming soon</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
13
app/routes/__app/settings/phone.tsx
Normal file
13
app/routes/__app/settings/phone.tsx
Normal 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;
|
Reference in New Issue
Block a user