local cache
This commit is contained in:
parent
0760fa8f41
commit
4aa646ab43
@ -26,7 +26,7 @@ type Form = {
|
||||
};
|
||||
|
||||
const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
||||
const { userProfile } = useUser();
|
||||
const { customer } = useUser();
|
||||
const { subscribe, changePlan } = useSubscription();
|
||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
||||
false,
|
||||
@ -78,8 +78,8 @@ const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = userProfile!.email!;
|
||||
const userId = userProfile!.id;
|
||||
const email = customer!.email!;
|
||||
const userId = customer!.id;
|
||||
const selectedPlanId = selectedPlan.id;
|
||||
|
||||
const isMovingToPaidPlan =
|
||||
|
158
src/components/connected-layout.tsx
Normal file
158
src/components/connected-layout.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { conversationsAtom, customerAtom, customerPhoneNumberAtom, messagesAtom, phoneCallsAtom } from "../state";
|
||||
import supabase from "../supabase/client";
|
||||
import type { Customer } from "../database/customer";
|
||||
import { PhoneNumber } from "../database/phone-number";
|
||||
import { Message } from "../database/message";
|
||||
import { PhoneCall } from "../database/phone-call";
|
||||
|
||||
type Props = {}
|
||||
|
||||
const ConnectedLayout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
}) => {
|
||||
useRequireOnboarding();
|
||||
const { isInitialized } = useInitializeState();
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<>Loading...</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectedLayout;
|
||||
|
||||
function useRequireOnboarding() {
|
||||
const router = useRouter();
|
||||
const [customer] = useAtom(customerAtom);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!customer) {
|
||||
// still loading
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customer.accountSid || !customer.authToken) {
|
||||
return router.push("/welcome/step-two");
|
||||
}
|
||||
|
||||
const phoneNumberResponse = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
.eq("customerId", customer.id)
|
||||
.single();
|
||||
if (phoneNumberResponse.error) {
|
||||
return router.push("/welcome/step-three");
|
||||
}
|
||||
})();
|
||||
}, [customer, router]);
|
||||
}
|
||||
|
||||
function useInitializeState() {
|
||||
useInitializeCustomer();
|
||||
useInitializeMessages();
|
||||
useInitializePhoneCalls();
|
||||
|
||||
const customer = useAtom(customerAtom)[0];
|
||||
const messages = useAtom(messagesAtom)[0];
|
||||
const phoneCalls = useAtom(phoneCallsAtom)[0];
|
||||
|
||||
return {
|
||||
isInitialized: customer !== null && messages !== null && phoneCalls !== null,
|
||||
};
|
||||
}
|
||||
|
||||
function useInitializeCustomer() {
|
||||
const router = useRouter();
|
||||
const setCustomer = useAtom(customerAtom)[1];
|
||||
const setCustomerPhoneNumber = useAtom(customerPhoneNumberAtom)[1];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const redirectTo = `/auth/sign-in?redirectTo=${router.pathname}`;
|
||||
// TODO: also redirect when no cookie
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch (error) {
|
||||
console.error("session error", error);
|
||||
return router.push(redirectTo);
|
||||
}
|
||||
const user = supabase.auth.user();
|
||||
if (!user) {
|
||||
return router.push(redirectTo);
|
||||
}
|
||||
|
||||
const customerId = user.id;
|
||||
const customerResponse = await supabase
|
||||
.from<Customer>("customer")
|
||||
.select("*")
|
||||
.eq("id", customerId)
|
||||
.single();
|
||||
if (customerResponse.error) throw customerResponse.error;
|
||||
|
||||
const customer = customerResponse.data;
|
||||
setCustomer(customer);
|
||||
|
||||
const customerPhoneNumberResponse = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
.eq("customerId", customerId)
|
||||
.single();
|
||||
if (customerPhoneNumberResponse.error) throw customerPhoneNumberResponse.error;
|
||||
setCustomerPhoneNumber(customerPhoneNumberResponse.data);
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
||||
function useInitializeMessages() {
|
||||
const customer = useAtom(customerAtom)[0];
|
||||
const setMessages = useAtom(messagesAtom)[1];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesResponse = await supabase
|
||||
.from<Message>("message")
|
||||
.select("*")
|
||||
.eq("customerId", customer.id);
|
||||
if (messagesResponse.error) throw messagesResponse.error;
|
||||
setMessages(messagesResponse.data);
|
||||
})();
|
||||
}, [customer, setMessages]);
|
||||
}
|
||||
|
||||
|
||||
function useInitializePhoneCalls() {
|
||||
const customer = useAtom(customerAtom)[0];
|
||||
const setPhoneCalls = useAtom(phoneCallsAtom)[1];
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneCallsResponse = await supabase
|
||||
.from<PhoneCall>("phone-call")
|
||||
.select("*")
|
||||
.eq("customerId", customer.id);
|
||||
if (phoneCallsResponse.error) throw phoneCallsResponse.error;
|
||||
setPhoneCalls(phoneCallsResponse.data);
|
||||
})();
|
||||
}, [customer, setPhoneCalls]);
|
||||
}
|
@ -8,7 +8,7 @@ import Avatar from "../avatar";
|
||||
import useUser from "../../hooks/use-user";
|
||||
|
||||
export default function Header() {
|
||||
const { userProfile } = useUser();
|
||||
const { customer } = useUser();
|
||||
|
||||
return (
|
||||
<header
|
||||
@ -35,7 +35,7 @@ export default function Header() {
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
name={userProfile?.email ?? "FSS"}
|
||||
name={customer?.email ?? "FSS"}
|
||||
/>
|
||||
</Menu.Button>
|
||||
|
||||
|
@ -30,9 +30,9 @@ const ProfileInformations: FunctionComponent = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setValue("name", user.userProfile?.user_metadata.name ?? "");
|
||||
setValue("email", user.userProfile?.email ?? "");
|
||||
}, [setValue, user.userProfile]);
|
||||
setValue("name", user.customer?.name ?? "");
|
||||
setValue("email", user.customer?.email ?? "");
|
||||
}, [setValue, user.customer]);
|
||||
|
||||
const onSubmit = handleSubmit(async ({ name, email }) => {
|
||||
if (isSubmitting) {
|
||||
|
@ -32,8 +32,6 @@ export async function createCustomer({ id, email, name }: CreateCustomerParams):
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log("data", data);
|
||||
|
||||
return data![0];
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ export default function useAuth() {
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
console.log("event", event);
|
||||
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
|
||||
await axios.post("/api/auth/session", { event, session });
|
||||
|
||||
|
@ -1,34 +1,39 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import axios from "axios";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import type { Message } from "../database/message";
|
||||
import useUser from "./use-user";
|
||||
import { conversationsAtom, customerAtom, customerPhoneNumberAtom } from "../state";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type UseConversationParams = {
|
||||
initialData?: Message[];
|
||||
recipient: string;
|
||||
}
|
||||
|
||||
export default function useConversation({
|
||||
initialData,
|
||||
recipient,
|
||||
}: UseConversationParams) {
|
||||
const user = useUser();
|
||||
export default function useConversation(recipient: string) {
|
||||
const customer = useAtom(customerAtom)[0];
|
||||
const customerPhoneNumber = useAtom(customerPhoneNumberAtom)[0];
|
||||
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
|
||||
const fetcher = async () => {
|
||||
const { data } = await axios.get<Message[]>(getConversationUrl);
|
||||
return data;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const getConversationQuery = useQuery<Message[]>(
|
||||
const [conversations] = useAtom(conversationsAtom);
|
||||
const getConversationQuery = useQuery<Message[] | null>(
|
||||
getConversationUrl,
|
||||
fetcher,
|
||||
{
|
||||
initialData,
|
||||
initialData: null,
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const conversation = conversations[recipient];
|
||||
if (getConversationQuery.data?.length === 0) {
|
||||
queryClient.setQueryData(getConversationUrl, conversation);
|
||||
}
|
||||
}, [queryClient, getConversationQuery.data, conversations, recipient, getConversationUrl]);
|
||||
|
||||
const sendMessage = useMutation(
|
||||
(sms: Pick<Message, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
|
||||
{
|
||||
@ -41,8 +46,8 @@ export default function useConversation({
|
||||
...previousMessages,
|
||||
{
|
||||
id: "", // TODO: somehow generate an id
|
||||
from: "", // TODO: get user's phone number
|
||||
customerId: user.userProfile!.id,
|
||||
from: customerPhoneNumber!.phoneNumber,
|
||||
customerId: customer!.id,
|
||||
sentAt: new Date().toISOString(),
|
||||
direction: "outbound",
|
||||
status: "queued",
|
||||
@ -54,12 +59,6 @@ export default function useConversation({
|
||||
|
||||
return { previousMessages };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previousMessages) {
|
||||
queryClient.setQueryData<Message[]>(getConversationUrl, context.previousMessages);
|
||||
}
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries(getConversationUrl),
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import type { User, UserAttributes } from "@supabase/supabase-js";
|
||||
|
||||
import { SessionContext } from "../session-context";
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/client";
|
||||
import { useAtom } from "jotai";
|
||||
import { customerAtom } from "../state";
|
||||
import { Customer } from "../database/customer";
|
||||
|
||||
const logger = appLogger.child({ module: "useUser" });
|
||||
|
||||
@ -16,28 +17,27 @@ type UseUser = {
|
||||
| {
|
||||
isLoading: true;
|
||||
error: null;
|
||||
userProfile: null;
|
||||
customer: null;
|
||||
}
|
||||
| {
|
||||
isLoading: false;
|
||||
error: Error;
|
||||
userProfile: User | null;
|
||||
customer: Customer | null;
|
||||
}
|
||||
| {
|
||||
isLoading: false;
|
||||
error: null;
|
||||
userProfile: User;
|
||||
customer: Customer;
|
||||
}
|
||||
);
|
||||
|
||||
export default function useUser(): UseUser {
|
||||
const session = useContext(SessionContext);
|
||||
const [customer] = useAtom(customerAtom);
|
||||
const router = useRouter();
|
||||
|
||||
return {
|
||||
isLoading: session.state.user === null,
|
||||
userProfile: session.state.user,
|
||||
error: session.state.error,
|
||||
isLoading: customer === null,
|
||||
customer,
|
||||
async deleteUser() {
|
||||
await axios.post("/api/user/delete-user", null, {
|
||||
withCredentials: true,
|
||||
|
@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { Hydrate } from "react-query/hydration";
|
||||
|
||||
import { pageTitle } from "./_document";
|
||||
import { SessionProvider } from "../session-context";
|
||||
|
||||
import "../fonts.css";
|
||||
import "../tailwind.css";
|
||||
@ -21,16 +20,14 @@ const NextApp = (props: AppProps) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<Hydrate state={pageProps.dehydratedState}>
|
||||
<SessionProvider user={pageProps.user}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<title>{pageTitle}</title>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<title>{pageTitle}</title>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</Hydrate>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
|
||||
import { findConversation } from "../../../../database/message";
|
||||
import type { ApiError } from "../../_types";
|
||||
import appLogger from "../../../../../lib/logger";
|
||||
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||
import { findConversation } from "../../../database/message";
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/conversation" });
|
||||
|
@ -1,56 +1,37 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import type { NextPage } from "next";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import { findCustomerPhoneCalls } from "../database/phone-call";
|
||||
import useUser from "../hooks/use-user";
|
||||
import Layout from "../components/layout";
|
||||
import ConnectedLayout from "../components/connected-layout";
|
||||
import { useAtom } from "jotai";
|
||||
import { phoneCallsAtom } from "../state";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
type Props = {};
|
||||
|
||||
const pageTitle = "Calls";
|
||||
|
||||
const Calls: NextPage<Props> = ({ phoneCalls }) => {
|
||||
const { userProfile } = useUser();
|
||||
|
||||
console.log("userProfile", userProfile);
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
const Calls: NextPage<Props> = () => {
|
||||
const phoneCalls = useAtom(phoneCallsAtom)[0] ?? [];
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Calls page</p>
|
||||
<ul className="divide-y">
|
||||
{phoneCalls.map((phoneCall) => {
|
||||
const recipient = phoneCall.direction === "outbound" ? phoneCall.to : phoneCall.from;
|
||||
return (
|
||||
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
|
||||
<div>{recipient}</div>
|
||||
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Calls page</p>
|
||||
<ul className="divide-y">
|
||||
{phoneCalls.map((phoneCall) => {
|
||||
const recipient = phoneCall.direction === "outbound" ? phoneCall.to : phoneCall.from;
|
||||
return (
|
||||
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
|
||||
<div>{recipient}</div>
|
||||
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async ({ res }, user) => {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"private, s-maxage=15, stale-while-revalidate=59",
|
||||
);
|
||||
|
||||
const phoneCalls = await findCustomerPhoneCalls(user.id);
|
||||
|
||||
return {
|
||||
props: { phoneCalls },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Calls;
|
||||
|
@ -7,60 +7,58 @@ import { faBackspace, faPhoneAlt as faPhone } from "@fortawesome/pro-solid-svg-i
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import Layout from "../components/layout";
|
||||
import useUser from "../hooks/use-user";
|
||||
import ConnectedLayout from "../components/connected-layout";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
type Props = {};
|
||||
|
||||
const pageTitle = "Keypad";
|
||||
|
||||
const Keypad: NextPage<Props> = () => {
|
||||
const { userProfile } = useUser();
|
||||
const phoneNumber = useAtom(phoneNumberAtom)[0];
|
||||
const pressBackspace = useAtom(pressBackspaceAtom)[1];
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
|
||||
<div className="h-16 text-3xl text-gray-700">
|
||||
<span>{phoneNumber}</span>
|
||||
</div>
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
|
||||
<div className="h-16 text-3xl text-gray-700">
|
||||
<span>{phoneNumber}</span>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<Row>
|
||||
<Digit digit="1" />
|
||||
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
|
||||
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
|
||||
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
|
||||
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
|
||||
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
|
||||
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="*" />
|
||||
<ZeroDigit />
|
||||
<Digit digit="#" />
|
||||
</Row>
|
||||
<Row>
|
||||
<div
|
||||
className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
||||
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
|
||||
</div>
|
||||
<div className="select-none my-auto" onClick={pressBackspace}>
|
||||
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
||||
</div>
|
||||
</Row>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
<section>
|
||||
<Row>
|
||||
<Digit digit="1" />
|
||||
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
|
||||
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
|
||||
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
|
||||
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
|
||||
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
|
||||
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
|
||||
</Row>
|
||||
<Row>
|
||||
<Digit digit="*" />
|
||||
<ZeroDigit />
|
||||
<Digit digit="#" />
|
||||
</Row>
|
||||
<Row>
|
||||
<div
|
||||
className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
||||
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
|
||||
</div>
|
||||
<div className="select-none my-auto" onClick={pressBackspace}>
|
||||
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
||||
</div>
|
||||
</Row>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -118,13 +116,4 @@ const pressBackspaceAtom = atom(
|
||||
},
|
||||
);
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(({ res }) => {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"private, s-maxage=15, stale-while-revalidate=59",
|
||||
);
|
||||
|
||||
return { props: {} };
|
||||
});
|
||||
|
||||
export default Keypad;
|
||||
|
@ -10,31 +10,33 @@ import {
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import type { Message } from "../../database/message";
|
||||
import { findConversation } from "../../database/message";
|
||||
import supabase from "../../supabase/client";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import useConversation from "../../hooks/use-conversation";
|
||||
import Layout from "../../components/layout";
|
||||
import ConnectedLayout from "../../components/connected-layout";
|
||||
|
||||
type Props = {
|
||||
recipient: string;
|
||||
conversation: Message[];
|
||||
}
|
||||
type Props = {}
|
||||
|
||||
type Form = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Messages: NextPage<Props> = (props) => {
|
||||
const { userProfile } = useUser();
|
||||
const { customer } = useUser();
|
||||
const router = useRouter();
|
||||
const recipient = router.query.recipient as string;
|
||||
const { conversation, error, refetch, sendMessage } = useConversation({
|
||||
initialData: props.conversation,
|
||||
recipient,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recipient || Array.isArray(recipient)) {
|
||||
router.push("/messages");
|
||||
}
|
||||
}, [recipient, router]);
|
||||
const { conversation, error, refetch, sendMessage } = useConversation(recipient);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const {
|
||||
register,
|
||||
@ -58,12 +60,12 @@ const Messages: NextPage<Props> = (props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile) {
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = supabase
|
||||
.from<Message>(`sms:customerId=eq.${userProfile.id}`)
|
||||
.from<Message>(`sms:customerId=eq.${customer.id}`)
|
||||
.on("INSERT", (payload) => {
|
||||
const message = payload.new;
|
||||
if ([message.from, message.to].includes(recipient)) {
|
||||
@ -73,117 +75,99 @@ const Messages: NextPage<Props> = (props) => {
|
||||
.subscribe();
|
||||
|
||||
return () => void subscription.unsubscribe();
|
||||
}, [userProfile, recipient, refetch]);
|
||||
}, [customer, recipient, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formRef.current) {
|
||||
formRef.current.scrollIntoView();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
Loading...
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
if (error) {
|
||||
console.error("error", error);
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
Oops, something unexpected happened. Please try reloading the page.
|
||||
</Layout>
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
Oops, something unexpected happened. Please try reloading the page.
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("conversation", conversation);
|
||||
if (!conversation) {
|
||||
return (
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
Loading...
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<header className="grid grid-cols-3 items-center">
|
||||
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
||||
</span>
|
||||
<strong className="col-span-1">
|
||||
{recipient}
|
||||
</strong>
|
||||
<span className="col-span-1 text-right space-x-4 pr-2">
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
||||
</span>
|
||||
</header>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ul>
|
||||
{conversation!.map((message, index) => {
|
||||
const isOutbound = message.direction === "outbound";
|
||||
const nextMessage = conversation![index + 1];
|
||||
const previousMessage = conversation![index - 1];
|
||||
const isSameNext = message.from === nextMessage?.from;
|
||||
const isSamePrevious = message.from === previousMessage?.from;
|
||||
const differenceInMinutes = previousMessage ? (new Date(message.sentAt).getTime() - new Date(previousMessage.sentAt).getTime()) / 1000 / 60 : 0;
|
||||
const isTooLate = differenceInMinutes > 15;
|
||||
return (
|
||||
<li key={message.id}>
|
||||
{
|
||||
(!isSamePrevious || isTooLate) && (
|
||||
<div className="flex py-2 space-x-1 text-xs justify-center">
|
||||
<strong>{new Date(message.sentAt).toLocaleDateString("fr-FR", { weekday: "long", day: "2-digit", month: "short" })}</strong>
|
||||
<span>{new Date(message.sentAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
<header className="absolute top-0 w-screen h-12 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={router.back}>
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
||||
</span>
|
||||
<strong className="col-span-1">
|
||||
{recipient}
|
||||
</strong>
|
||||
<span className="col-span-1 text-right space-x-4 pr-2">
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
||||
</span>
|
||||
</header>
|
||||
<div className="flex flex-col space-y-6 p-6 pt-12">
|
||||
<ul>
|
||||
{conversation.map((message, index) => {
|
||||
const isOutbound = message.direction === "outbound";
|
||||
const nextMessage = conversation![index + 1];
|
||||
const previousMessage = conversation![index - 1];
|
||||
const isSameNext = message.from === nextMessage?.from;
|
||||
const isSamePrevious = message.from === previousMessage?.from;
|
||||
const differenceInMinutes = previousMessage ? (new Date(message.sentAt).getTime() - new Date(previousMessage.sentAt).getTime()) / 1000 / 60 : 0;
|
||||
const isTooLate = differenceInMinutes > 15;
|
||||
console.log("message.from === previousMessage?.from", message.from, previousMessage?.from);
|
||||
return (
|
||||
<li key={message.id}>
|
||||
{
|
||||
(!isSamePrevious || isTooLate) && (
|
||||
<div className="flex py-2 space-x-1 text-xs justify-center">
|
||||
<strong>{new Date(message.sentAt).toLocaleDateString("fr-FR", { weekday: "long", day: "2-digit", month: "short" })}</strong>
|
||||
<span>{new Date(message.sentAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
isSameNext ? "pb-1" : "pb-2",
|
||||
isOutbound ? "text-right" : "text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
className={clsx(
|
||||
"inline-block text-left w-[fit-content] p-2 rounded-lg text-white",
|
||||
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
|
||||
isSameNext ? "pb-1" : "pb-2",
|
||||
isOutbound ? "text-right" : "text-left",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<form ref={formRef} onSubmit={onSubmit}>
|
||||
<textarea{...register("content")} />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</Layout>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-block text-left w-[fit-content] p-2 rounded-lg text-white",
|
||||
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<form ref={formRef} onSubmit={onSubmit}>
|
||||
<textarea{...register("content")} />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired<Props>(
|
||||
async (context, user) => {
|
||||
const recipient = context.params?.recipient;
|
||||
if (!recipient || Array.isArray(recipient)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/messages",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const conversation = await findConversation(user.id, recipient);
|
||||
|
||||
return {
|
||||
props: {
|
||||
recipient,
|
||||
conversation,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Messages;
|
||||
|
@ -8,85 +8,44 @@ import { findCustomer } from "../../database/customer";
|
||||
import { decrypt } from "../../database/_encryption";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import Layout from "../../components/layout";
|
||||
import ConnectedLayout from "../../components/connected-layout";
|
||||
import { conversationsAtom } from "../../state";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
type Props = {};
|
||||
|
||||
const pageTitle = "Messages";
|
||||
|
||||
const Messages: NextPage<Props> = ({ conversations }) => {
|
||||
const { userProfile } = useUser();
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
const Messages: NextPage<Props> = () => {
|
||||
const [conversations] = useAtom(conversationsAtom);
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Messages page</p>
|
||||
<ul className="divide-y">
|
||||
{Object.entries(conversations).map(([recipient, message]) => {
|
||||
return (
|
||||
<li key={recipient} className="py-2">
|
||||
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
|
||||
<a className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<strong>{recipient}</strong>
|
||||
<div>{new Date(message.sentAt).toLocaleString("fr-FR")}</div>
|
||||
</div>
|
||||
<div>{message.content}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
<ConnectedLayout>
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Messages page</p>
|
||||
<ul className="divide-y">
|
||||
{Object.entries(conversations).map(([recipient, messages]) => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return (
|
||||
<li key={recipient} className="py-2">
|
||||
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
|
||||
<a className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<strong>{recipient}</strong>
|
||||
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div>
|
||||
</div>
|
||||
<div>{lastMessage.content}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
type Recipient = string;
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async (context, user) => {
|
||||
context.res.setHeader(
|
||||
"Cache-Control",
|
||||
"private, s-maxage=15, stale-while-revalidate=59",
|
||||
);
|
||||
|
||||
const [customer, messages] = await Promise.all([
|
||||
findCustomer(user.id),
|
||||
findCustomerMessages(user.id),
|
||||
]);
|
||||
|
||||
let conversations: Record<Recipient, Message> = {};
|
||||
for (const message of messages) {
|
||||
let recipient: string;
|
||||
if (message.direction === "outbound") {
|
||||
recipient = message.to;
|
||||
} else {
|
||||
recipient = message.from;
|
||||
}
|
||||
|
||||
if (
|
||||
!conversations[recipient] ||
|
||||
message.sentAt > conversations[recipient].sentAt
|
||||
) {
|
||||
conversations[recipient] = {
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
|
||||
};
|
||||
}
|
||||
}
|
||||
conversations = Object.fromEntries(
|
||||
Object.entries(conversations).sort(([,a], [,b]) => b.sentAt.localeCompare(a.sentAt))
|
||||
);
|
||||
|
||||
return {
|
||||
props: { conversations },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default Messages;
|
||||
|
@ -9,14 +9,11 @@ import Divider from "../../components/divider";
|
||||
import UpdatePassword from "../../components/settings/update-password";
|
||||
import DangerZone from "../../components/settings/danger-zone";
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import ConnectedLayout from "../../components/connected-layout";
|
||||
|
||||
const Account: NextPage = () => {
|
||||
const user = useUser();
|
||||
|
||||
if (user.isLoading) {
|
||||
return <SettingsLayout>Loading...</SettingsLayout>;
|
||||
}
|
||||
|
||||
if (user.error !== null) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
@ -32,23 +29,25 @@ const Account: NextPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ProfileInformations />
|
||||
<ConnectedLayout>
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ProfileInformations />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<UpdatePassword />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
|
||||
<UpdatePassword />
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</SettingsLayout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -14,6 +14,7 @@ import type { Subscription } from "../../database/subscriptions";
|
||||
import { findUserSubscription } from "../../database/subscriptions";
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
import ConnectedLayout from "../../components/connected-layout";
|
||||
|
||||
const logger = appLogger.child({ page: "/account/settings/billing" });
|
||||
|
||||
@ -31,51 +32,53 @@ const Billing: NextPage<Props> = ({ subscription }) => {
|
||||
const { cancelSubscription, updatePaymentMethod } = useSubscription();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
{subscription ? (
|
||||
<>
|
||||
<SettingsSection title="Payment method">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
updatePaymentMethod({
|
||||
updateUrl: subscription.updateUrl,
|
||||
})
|
||||
}
|
||||
text="Update payment method on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
<ConnectedLayout>
|
||||
<SettingsLayout>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
{subscription ? (
|
||||
<>
|
||||
<SettingsSection title="Payment method">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
updatePaymentMethod({
|
||||
updateUrl: subscription.updateUrl,
|
||||
})
|
||||
}
|
||||
text="Update payment method on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Plan">
|
||||
<BillingPlans activePlanId={subscription?.planId} />
|
||||
</SettingsSection>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Cancel subscription">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
cancelSubscription({
|
||||
cancelUrl: subscription.cancelUrl,
|
||||
})
|
||||
}
|
||||
text="Cancel subscription on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
) : (
|
||||
<SettingsSection title="Plan">
|
||||
<BillingPlans activePlanId={subscription?.planId} />
|
||||
<BillingPlans />
|
||||
</SettingsSection>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Cancel subscription">
|
||||
<PaddleLink
|
||||
onClick={() =>
|
||||
cancelSubscription({
|
||||
cancelUrl: subscription.cancelUrl,
|
||||
})
|
||||
}
|
||||
text="Cancel subscription on Paddle"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
) : (
|
||||
<SettingsSection title="Plan">
|
||||
<BillingPlans />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -6,8 +6,9 @@ import Layout from "../../components/layout";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import appLogger from "../../../lib/logger";
|
||||
import ConnectedLayout from "../../components/connected-layout";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
type Props = {};
|
||||
|
||||
const logger = appLogger.child({ page: "/account/settings" });
|
||||
|
||||
@ -28,36 +29,27 @@ const navigation = [
|
||||
|
||||
const Settings: NextPage<Props> = (props) => {
|
||||
return (
|
||||
<Layout title="Settings">
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<aside className="py-6 lg:col-span-3">
|
||||
<nav className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"
|
||||
>
|
||||
<item.icon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</Layout>
|
||||
<ConnectedLayout>
|
||||
<Layout title="Settings">
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<aside className="py-6 lg:col-span-3">
|
||||
<nav className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"
|
||||
>
|
||||
<item.icon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</Layout>
|
||||
</ConnectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = withPageOnboardingRequired(
|
||||
async ({ res }) => {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"private, s-maxage=15, stale-while-revalidate=59",
|
||||
);
|
||||
|
||||
return { props: {} };
|
||||
},
|
||||
);
|
||||
|
||||
export default Settings;
|
||||
|
@ -1,105 +0,0 @@
|
||||
import type { Dispatch, ReactNode, Reducer, ReducerAction } from "react";
|
||||
import { createContext, useEffect, useReducer } from "react";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import supabase from "./supabase/client";
|
||||
|
||||
type Context = {
|
||||
state: SessionState;
|
||||
dispatch: Dispatch<ReducerAction<typeof sessionReducer>>;
|
||||
};
|
||||
|
||||
export const SessionContext = createContext<Context>(null as any);
|
||||
|
||||
type ProviderProps = {
|
||||
children: ReactNode;
|
||||
user?: User | null;
|
||||
};
|
||||
|
||||
function getInitialState(initialUser: User | null | undefined): SessionState {
|
||||
if (!initialUser) {
|
||||
return {
|
||||
state: "LOADING",
|
||||
user: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "SUCCESS",
|
||||
user: initialUser,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionProvider({ children, user }: ProviderProps) {
|
||||
const [state, dispatch] = useReducer(
|
||||
sessionReducer,
|
||||
getInitialState(user),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log("event", event);
|
||||
if (["SIGNED_IN", "USER_UPDATED"].includes(event)) {
|
||||
dispatch({
|
||||
type: "SET_SESSION",
|
||||
user: session!.user!,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (state.user === null) {
|
||||
dispatch({
|
||||
type: "SET_SESSION",
|
||||
user: supabase.auth.user()!,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ state, dispatch }}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionState =
|
||||
| {
|
||||
state: "LOADING";
|
||||
user: null;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
state: "SUCCESS";
|
||||
user: User;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
state: "ERROR";
|
||||
user: User | null;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SESSION"; user: User }
|
||||
| { type: "THROW_ERROR"; error: Error };
|
||||
|
||||
const sessionReducer: Reducer<SessionState, Action> = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "SET_SESSION":
|
||||
return {
|
||||
...state,
|
||||
state: "SUCCESS",
|
||||
user: action.user,
|
||||
error: null,
|
||||
};
|
||||
case "THROW_ERROR":
|
||||
return {
|
||||
...state,
|
||||
state: "ERROR",
|
||||
error: action.error,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
};
|
50
src/state.ts
Normal file
50
src/state.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { atom } from "jotai";
|
||||
import type { Message } from "./database/message";
|
||||
import type { Customer } from "./database/customer";
|
||||
import type { PhoneCall } from "./database/phone-call";
|
||||
import type { PhoneNumber } from "./database/phone-number";
|
||||
import { decrypt } from "./database/_encryption";
|
||||
|
||||
type Recipient = string;
|
||||
|
||||
export const customerAtom = atom<Customer | null>(null);
|
||||
export const customerPhoneNumberAtom = atom<PhoneNumber | null>(null);
|
||||
|
||||
export const messagesAtom = atom<Message[] | null>(null);
|
||||
export const conversationsAtom = atom<Record<Recipient, Message[]>>(
|
||||
(get) => {
|
||||
const messages = get(messagesAtom);
|
||||
const customer = get(customerAtom);
|
||||
if (!customer || !messages) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let conversations: Record<Recipient, Message[]> = {};
|
||||
for (const message of messages) {
|
||||
let recipient: string;
|
||||
if (message.direction === "outbound") {
|
||||
recipient = message.to;
|
||||
} else {
|
||||
recipient = message.from;
|
||||
}
|
||||
|
||||
if (!conversations[recipient]) {
|
||||
conversations[recipient] = [];
|
||||
}
|
||||
|
||||
conversations[recipient].push({
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey),
|
||||
});
|
||||
|
||||
conversations[recipient].sort((a, b) => a.sentAt.localeCompare(b.sentAt));
|
||||
}
|
||||
conversations = Object.fromEntries(
|
||||
Object.entries(conversations).sort(([,a], [,b]) => b[b.length - 1].sentAt.localeCompare(a[a.length - 1].sentAt))
|
||||
);
|
||||
|
||||
return conversations;
|
||||
},
|
||||
);
|
||||
|
||||
export const phoneCallsAtom = atom<PhoneCall[] | null>(null);
|
Loading…
Reference in New Issue
Block a user