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 BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
||||||
const { userProfile } = useUser();
|
const { customer } = useUser();
|
||||||
const { subscribe, changePlan } = useSubscription();
|
const { subscribe, changePlan } = useSubscription();
|
||||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
|
||||||
false,
|
false,
|
||||||
@ -78,8 +78,8 @@ const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = userProfile!.email!;
|
const email = customer!.email!;
|
||||||
const userId = userProfile!.id;
|
const userId = customer!.id;
|
||||||
const selectedPlanId = selectedPlan.id;
|
const selectedPlanId = selectedPlan.id;
|
||||||
|
|
||||||
const isMovingToPaidPlan =
|
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";
|
import useUser from "../../hooks/use-user";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { userProfile } = useUser();
|
const { customer } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@ -35,7 +35,7 @@ export default function Header() {
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={userProfile?.email ?? "FSS"}
|
name={customer?.email ?? "FSS"}
|
||||||
/>
|
/>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|
||||||
|
@ -30,9 +30,9 @@ const ProfileInformations: FunctionComponent = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("name", user.userProfile?.user_metadata.name ?? "");
|
setValue("name", user.customer?.name ?? "");
|
||||||
setValue("email", user.userProfile?.email ?? "");
|
setValue("email", user.customer?.email ?? "");
|
||||||
}, [setValue, user.userProfile]);
|
}, [setValue, user.customer]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async ({ name, email }) => {
|
const onSubmit = handleSubmit(async ({ name, email }) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
|
@ -32,8 +32,6 @@ export async function createCustomer({ id, email, name }: CreateCustomerParams):
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
console.log("data", data);
|
|
||||||
|
|
||||||
return data![0];
|
return data![0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export default function useAuth() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
|
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
console.log("event", event);
|
||||||
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
|
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
|
||||||
await axios.post("/api/auth/session", { event, session });
|
await axios.post("/api/auth/session", { event, session });
|
||||||
|
|
||||||
|
@ -1,34 +1,39 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
import type { Message } from "../database/message";
|
import type { Message } from "../database/message";
|
||||||
import useUser from "./use-user";
|
import useUser from "./use-user";
|
||||||
|
import { conversationsAtom, customerAtom, customerPhoneNumberAtom } from "../state";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
type UseConversationParams = {
|
export default function useConversation(recipient: string) {
|
||||||
initialData?: Message[];
|
const customer = useAtom(customerAtom)[0];
|
||||||
recipient: string;
|
const customerPhoneNumber = useAtom(customerPhoneNumberAtom)[0];
|
||||||
}
|
|
||||||
|
|
||||||
export default function useConversation({
|
|
||||||
initialData,
|
|
||||||
recipient,
|
|
||||||
}: UseConversationParams) {
|
|
||||||
const user = useUser();
|
|
||||||
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
|
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
|
||||||
const fetcher = async () => {
|
const fetcher = async () => {
|
||||||
const { data } = await axios.get<Message[]>(getConversationUrl);
|
const { data } = await axios.get<Message[]>(getConversationUrl);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const getConversationQuery = useQuery<Message[]>(
|
const [conversations] = useAtom(conversationsAtom);
|
||||||
|
const getConversationQuery = useQuery<Message[] | null>(
|
||||||
getConversationUrl,
|
getConversationUrl,
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
initialData,
|
initialData: null,
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
refetchOnWindowFocus: 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(
|
const sendMessage = useMutation(
|
||||||
(sms: Pick<Message, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
|
(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,
|
...previousMessages,
|
||||||
{
|
{
|
||||||
id: "", // TODO: somehow generate an id
|
id: "", // TODO: somehow generate an id
|
||||||
from: "", // TODO: get user's phone number
|
from: customerPhoneNumber!.phoneNumber,
|
||||||
customerId: user.userProfile!.id,
|
customerId: customer!.id,
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
status: "queued",
|
status: "queued",
|
||||||
@ -54,12 +59,6 @@ export default function useConversation({
|
|||||||
|
|
||||||
return { previousMessages };
|
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 { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { User, UserAttributes } from "@supabase/supabase-js";
|
import type { User, UserAttributes } from "@supabase/supabase-js";
|
||||||
|
|
||||||
import { SessionContext } from "../session-context";
|
|
||||||
import appLogger from "../../lib/logger";
|
import appLogger from "../../lib/logger";
|
||||||
import supabase from "../supabase/client";
|
import supabase from "../supabase/client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { customerAtom } from "../state";
|
||||||
|
import { Customer } from "../database/customer";
|
||||||
|
|
||||||
const logger = appLogger.child({ module: "useUser" });
|
const logger = appLogger.child({ module: "useUser" });
|
||||||
|
|
||||||
@ -16,28 +17,27 @@ type UseUser = {
|
|||||||
| {
|
| {
|
||||||
isLoading: true;
|
isLoading: true;
|
||||||
error: null;
|
error: null;
|
||||||
userProfile: null;
|
customer: null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
isLoading: false;
|
isLoading: false;
|
||||||
error: Error;
|
error: Error;
|
||||||
userProfile: User | null;
|
customer: Customer | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
isLoading: false;
|
isLoading: false;
|
||||||
error: null;
|
error: null;
|
||||||
userProfile: User;
|
customer: Customer;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function useUser(): UseUser {
|
export default function useUser(): UseUser {
|
||||||
const session = useContext(SessionContext);
|
const [customer] = useAtom(customerAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: session.state.user === null,
|
isLoading: customer === null,
|
||||||
userProfile: session.state.user,
|
customer,
|
||||||
error: session.state.error,
|
|
||||||
async deleteUser() {
|
async deleteUser() {
|
||||||
await axios.post("/api/user/delete-user", null, {
|
await axios.post("/api/user/delete-user", null, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "react-query";
|
|||||||
import { Hydrate } from "react-query/hydration";
|
import { Hydrate } from "react-query/hydration";
|
||||||
|
|
||||||
import { pageTitle } from "./_document";
|
import { pageTitle } from "./_document";
|
||||||
import { SessionProvider } from "../session-context";
|
|
||||||
|
|
||||||
import "../fonts.css";
|
import "../fonts.css";
|
||||||
import "../tailwind.css";
|
import "../tailwind.css";
|
||||||
@ -21,16 +20,14 @@ const NextApp = (props: AppProps) => {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<Hydrate state={pageProps.dehydratedState}>
|
<Hydrate state={pageProps.dehydratedState}>
|
||||||
<SessionProvider user={pageProps.user}>
|
<Head>
|
||||||
<Head>
|
<meta
|
||||||
<meta
|
name="viewport"
|
||||||
name="viewport"
|
content="width=device-width, initial-scale=1"
|
||||||
content="width=device-width, initial-scale=1"
|
/>
|
||||||
/>
|
<title>{pageTitle}</title>
|
||||||
<title>{pageTitle}</title>
|
</Head>
|
||||||
</Head>
|
<Component {...pageProps} />
|
||||||
<Component {...pageProps} />
|
|
||||||
</SessionProvider>
|
|
||||||
</Hydrate>
|
</Hydrate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import Joi from "joi";
|
import Joi from "joi";
|
||||||
|
|
||||||
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
|
import { withApiAuthRequired } from "../../../../lib/session-helpers";
|
||||||
import { findConversation } from "../../../../database/message";
|
import { findConversation } from "../../../database/message";
|
||||||
import type { ApiError } from "../../_types";
|
import type { ApiError } from "../_types";
|
||||||
import appLogger from "../../../../../lib/logger";
|
import appLogger from "../../../../lib/logger";
|
||||||
|
|
||||||
const logger = appLogger.child({ route: "/api/conversation" });
|
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 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 pageTitle = "Calls";
|
||||||
|
|
||||||
const Calls: NextPage<Props> = ({ phoneCalls }) => {
|
const Calls: NextPage<Props> = () => {
|
||||||
const { userProfile } = useUser();
|
const phoneCalls = useAtom(phoneCallsAtom)[0] ?? [];
|
||||||
|
|
||||||
console.log("userProfile", userProfile);
|
|
||||||
|
|
||||||
if (!userProfile) {
|
|
||||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title={pageTitle}>
|
<ConnectedLayout>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
<Layout title={pageTitle}>
|
||||||
<p>Calls page</p>
|
<div className="flex flex-col space-y-6 p-6">
|
||||||
<ul className="divide-y">
|
<p>Calls page</p>
|
||||||
{phoneCalls.map((phoneCall) => {
|
<ul className="divide-y">
|
||||||
const recipient = phoneCall.direction === "outbound" ? phoneCall.to : phoneCall.from;
|
{phoneCalls.map((phoneCall) => {
|
||||||
return (
|
const recipient = phoneCall.direction === "outbound" ? phoneCall.to : phoneCall.from;
|
||||||
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
|
return (
|
||||||
<div>{recipient}</div>
|
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
|
||||||
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
<div>{recipient}</div>
|
||||||
</li>
|
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
||||||
)
|
</li>
|
||||||
})}
|
)
|
||||||
</ul>
|
})}
|
||||||
</div>
|
</ul>
|
||||||
</Layout>
|
</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;
|
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 { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||||
import Layout from "../components/layout";
|
import Layout from "../components/layout";
|
||||||
import useUser from "../hooks/use-user";
|
import useUser from "../hooks/use-user";
|
||||||
|
import ConnectedLayout from "../components/connected-layout";
|
||||||
|
|
||||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
type Props = {};
|
||||||
|
|
||||||
const pageTitle = "Keypad";
|
const pageTitle = "Keypad";
|
||||||
|
|
||||||
const Keypad: NextPage<Props> = () => {
|
const Keypad: NextPage<Props> = () => {
|
||||||
const { userProfile } = useUser();
|
|
||||||
const phoneNumber = useAtom(phoneNumberAtom)[0];
|
const phoneNumber = useAtom(phoneNumberAtom)[0];
|
||||||
const pressBackspace = useAtom(pressBackspaceAtom)[1];
|
const pressBackspace = useAtom(pressBackspaceAtom)[1];
|
||||||
|
|
||||||
if (!userProfile) {
|
|
||||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title={pageTitle}>
|
<ConnectedLayout>
|
||||||
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
|
<Layout title={pageTitle}>
|
||||||
<div className="h-16 text-3xl text-gray-700">
|
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
|
||||||
<span>{phoneNumber}</span>
|
<div className="h-16 text-3xl text-gray-700">
|
||||||
</div>
|
<span>{phoneNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<Row>
|
<Row>
|
||||||
<Digit digit="1" />
|
<Digit digit="1" />
|
||||||
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
|
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
|
||||||
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
|
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
|
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
|
||||||
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
|
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
|
||||||
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
|
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
|
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
|
||||||
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
|
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
|
||||||
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
|
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Digit digit="*" />
|
<Digit digit="*" />
|
||||||
<ZeroDigit />
|
<ZeroDigit />
|
||||||
<Digit digit="#" />
|
<Digit digit="#" />
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<div
|
<div
|
||||||
className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
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" />
|
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="select-none my-auto" onClick={pressBackspace}>
|
<div className="select-none my-auto" onClick={pressBackspace}>
|
||||||
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</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;
|
export default Keypad;
|
||||||
|
@ -10,31 +10,33 @@ import {
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
|
||||||
import type { Message } from "../../database/message";
|
import type { Message } from "../../database/message";
|
||||||
import { findConversation } from "../../database/message";
|
|
||||||
import supabase from "../../supabase/client";
|
import supabase from "../../supabase/client";
|
||||||
import useUser from "../../hooks/use-user";
|
import useUser from "../../hooks/use-user";
|
||||||
import useConversation from "../../hooks/use-conversation";
|
import useConversation from "../../hooks/use-conversation";
|
||||||
import Layout from "../../components/layout";
|
import Layout from "../../components/layout";
|
||||||
|
import ConnectedLayout from "../../components/connected-layout";
|
||||||
|
|
||||||
type Props = {
|
type Props = {}
|
||||||
recipient: string;
|
|
||||||
conversation: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Messages: NextPage<Props> = (props) => {
|
const Messages: NextPage<Props> = (props) => {
|
||||||
const { userProfile } = useUser();
|
const { customer } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const recipient = router.query.recipient as string;
|
const recipient = router.query.recipient as string;
|
||||||
const { conversation, error, refetch, sendMessage } = useConversation({
|
useEffect(() => {
|
||||||
initialData: props.conversation,
|
if (!router.isReady) {
|
||||||
recipient,
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (!recipient || Array.isArray(recipient)) {
|
||||||
|
router.push("/messages");
|
||||||
|
}
|
||||||
|
}, [recipient, router]);
|
||||||
|
const { conversation, error, refetch, sendMessage } = useConversation(recipient);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -58,12 +60,12 @@ const Messages: NextPage<Props> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userProfile) {
|
if (!customer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = supabase
|
const subscription = supabase
|
||||||
.from<Message>(`sms:customerId=eq.${userProfile.id}`)
|
.from<Message>(`sms:customerId=eq.${customer.id}`)
|
||||||
.on("INSERT", (payload) => {
|
.on("INSERT", (payload) => {
|
||||||
const message = payload.new;
|
const message = payload.new;
|
||||||
if ([message.from, message.to].includes(recipient)) {
|
if ([message.from, message.to].includes(recipient)) {
|
||||||
@ -73,117 +75,99 @@ const Messages: NextPage<Props> = (props) => {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return () => void subscription.unsubscribe();
|
return () => void subscription.unsubscribe();
|
||||||
}, [userProfile, recipient, refetch]);
|
}, [customer, recipient, refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
formRef.current.scrollIntoView();
|
formRef.current.scrollIntoView();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [conversation]);
|
||||||
|
|
||||||
if (!userProfile) {
|
|
||||||
return (
|
|
||||||
<Layout title={pageTitle}>
|
|
||||||
Loading...
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("error", error);
|
console.error("error", error);
|
||||||
return (
|
return (
|
||||||
<Layout title={pageTitle}>
|
<ConnectedLayout>
|
||||||
Oops, something unexpected happened. Please try reloading the page.
|
<Layout title={pageTitle}>
|
||||||
</Layout>
|
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 (
|
return (
|
||||||
<Layout title={pageTitle}>
|
<ConnectedLayout>
|
||||||
<header className="grid grid-cols-3 items-center">
|
<Layout title={pageTitle}>
|
||||||
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
|
<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">
|
||||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
|
||||||
</span>
|
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
||||||
<strong className="col-span-1">
|
</span>
|
||||||
{recipient}
|
<strong className="col-span-1">
|
||||||
</strong>
|
{recipient}
|
||||||
<span className="col-span-1 text-right space-x-4 pr-2">
|
</strong>
|
||||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
<span className="col-span-1 text-right space-x-4 pr-2">
|
||||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
||||||
</span>
|
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
||||||
</header>
|
</span>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
</header>
|
||||||
<ul>
|
<div className="flex flex-col space-y-6 p-6 pt-12">
|
||||||
{conversation!.map((message, index) => {
|
<ul>
|
||||||
const isOutbound = message.direction === "outbound";
|
{conversation.map((message, index) => {
|
||||||
const nextMessage = conversation![index + 1];
|
const isOutbound = message.direction === "outbound";
|
||||||
const previousMessage = conversation![index - 1];
|
const nextMessage = conversation![index + 1];
|
||||||
const isSameNext = message.from === nextMessage?.from;
|
const previousMessage = conversation![index - 1];
|
||||||
const isSamePrevious = message.from === previousMessage?.from;
|
const isSameNext = message.from === nextMessage?.from;
|
||||||
const differenceInMinutes = previousMessage ? (new Date(message.sentAt).getTime() - new Date(previousMessage.sentAt).getTime()) / 1000 / 60 : 0;
|
const isSamePrevious = message.from === previousMessage?.from;
|
||||||
const isTooLate = differenceInMinutes > 15;
|
const differenceInMinutes = previousMessage ? (new Date(message.sentAt).getTime() - new Date(previousMessage.sentAt).getTime()) / 1000 / 60 : 0;
|
||||||
return (
|
const isTooLate = differenceInMinutes > 15;
|
||||||
<li key={message.id}>
|
console.log("message.from === previousMessage?.from", message.from, previousMessage?.from);
|
||||||
{
|
return (
|
||||||
(!isSamePrevious || isTooLate) && (
|
<li key={message.id}>
|
||||||
<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>
|
(!isSamePrevious || isTooLate) && (
|
||||||
<span>{new Date(message.sentAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
<div className="flex py-2 space-x-1 text-xs justify-center">
|
||||||
</div>
|
<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
|
<div
|
||||||
className={clsx(
|
|
||||||
isSameNext ? "pb-1" : "pb-2",
|
|
||||||
isOutbound ? "text-right" : "text-left",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-block text-left w-[fit-content] p-2 rounded-lg text-white",
|
isSameNext ? "pb-1" : "pb-2",
|
||||||
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
|
isOutbound ? "text-right" : "text-left",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.content}
|
<span
|
||||||
</span>
|
className={clsx(
|
||||||
</div>
|
"inline-block text-left w-[fit-content] p-2 rounded-lg text-white",
|
||||||
</li>
|
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
|
||||||
);
|
)}
|
||||||
})}
|
>
|
||||||
</ul>
|
{message.content}
|
||||||
</div>
|
</span>
|
||||||
<form ref={formRef} onSubmit={onSubmit}>
|
</div>
|
||||||
<textarea{...register("content")} />
|
</li>
|
||||||
<button type="submit">Send</button>
|
);
|
||||||
</form>
|
})}
|
||||||
</Layout>
|
</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;
|
export default Messages;
|
||||||
|
@ -8,85 +8,44 @@ import { findCustomer } from "../../database/customer";
|
|||||||
import { decrypt } from "../../database/_encryption";
|
import { decrypt } from "../../database/_encryption";
|
||||||
import useUser from "../../hooks/use-user";
|
import useUser from "../../hooks/use-user";
|
||||||
import Layout from "../../components/layout";
|
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 pageTitle = "Messages";
|
||||||
|
|
||||||
const Messages: NextPage<Props> = ({ conversations }) => {
|
const Messages: NextPage<Props> = () => {
|
||||||
const { userProfile } = useUser();
|
const [conversations] = useAtom(conversationsAtom);
|
||||||
|
|
||||||
if (!userProfile) {
|
|
||||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title={pageTitle}>
|
<ConnectedLayout>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
<Layout title={pageTitle}>
|
||||||
<p>Messages page</p>
|
<div className="flex flex-col space-y-6 p-6">
|
||||||
<ul className="divide-y">
|
<p>Messages page</p>
|
||||||
{Object.entries(conversations).map(([recipient, message]) => {
|
<ul className="divide-y">
|
||||||
return (
|
{Object.entries(conversations).map(([recipient, messages]) => {
|
||||||
<li key={recipient} className="py-2">
|
const lastMessage = messages[messages.length - 1];
|
||||||
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
|
return (
|
||||||
<a className="flex flex-col">
|
<li key={recipient} className="py-2">
|
||||||
<div className="flex flex-row justify-between">
|
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
|
||||||
<strong>{recipient}</strong>
|
<a className="flex flex-col">
|
||||||
<div>{new Date(message.sentAt).toLocaleString("fr-FR")}</div>
|
<div className="flex flex-row justify-between">
|
||||||
</div>
|
<strong>{recipient}</strong>
|
||||||
<div>{message.content}</div>
|
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
<div>{lastMessage.content}</div>
|
||||||
</li>
|
</a>
|
||||||
)
|
</Link>
|
||||||
})}
|
</li>
|
||||||
</ul>
|
);
|
||||||
</div>
|
})}
|
||||||
</Layout>
|
</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;
|
export default Messages;
|
||||||
|
@ -9,14 +9,11 @@ import Divider from "../../components/divider";
|
|||||||
import UpdatePassword from "../../components/settings/update-password";
|
import UpdatePassword from "../../components/settings/update-password";
|
||||||
import DangerZone from "../../components/settings/danger-zone";
|
import DangerZone from "../../components/settings/danger-zone";
|
||||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||||
|
import ConnectedLayout from "../../components/connected-layout";
|
||||||
|
|
||||||
const Account: NextPage = () => {
|
const Account: NextPage = () => {
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
|
||||||
if (user.isLoading) {
|
|
||||||
return <SettingsLayout>Loading...</SettingsLayout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.error !== null) {
|
if (user.error !== null) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
@ -32,23 +29,25 @@ const Account: NextPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<ConnectedLayout>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
<SettingsLayout>
|
||||||
<ProfileInformations />
|
<div className="flex flex-col space-y-6 p-6">
|
||||||
|
<ProfileInformations />
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Divider />
|
<Divider />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpdatePassword />
|
||||||
|
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DangerZone />
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
<UpdatePassword />
|
</ConnectedLayout>
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DangerZone />
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ import type { Subscription } from "../../database/subscriptions";
|
|||||||
import { findUserSubscription } from "../../database/subscriptions";
|
import { findUserSubscription } from "../../database/subscriptions";
|
||||||
|
|
||||||
import appLogger from "../../../lib/logger";
|
import appLogger from "../../../lib/logger";
|
||||||
|
import ConnectedLayout from "../../components/connected-layout";
|
||||||
|
|
||||||
const logger = appLogger.child({ page: "/account/settings/billing" });
|
const logger = appLogger.child({ page: "/account/settings/billing" });
|
||||||
|
|
||||||
@ -31,51 +32,53 @@ const Billing: NextPage<Props> = ({ subscription }) => {
|
|||||||
const { cancelSubscription, updatePaymentMethod } = useSubscription();
|
const { cancelSubscription, updatePaymentMethod } = useSubscription();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<ConnectedLayout>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
<SettingsLayout>
|
||||||
{subscription ? (
|
<div className="flex flex-col space-y-6 p-6">
|
||||||
<>
|
{subscription ? (
|
||||||
<SettingsSection title="Payment method">
|
<>
|
||||||
<PaddleLink
|
<SettingsSection title="Payment method">
|
||||||
onClick={() =>
|
<PaddleLink
|
||||||
updatePaymentMethod({
|
onClick={() =>
|
||||||
updateUrl: subscription.updateUrl,
|
updatePaymentMethod({
|
||||||
})
|
updateUrl: subscription.updateUrl,
|
||||||
}
|
})
|
||||||
text="Update payment method on Paddle"
|
}
|
||||||
/>
|
text="Update payment method on Paddle"
|
||||||
</SettingsSection>
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Divider />
|
<Divider />
|
||||||
</div>
|
</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">
|
<SettingsSection title="Plan">
|
||||||
<BillingPlans activePlanId={subscription?.planId} />
|
<BillingPlans />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
)}
|
||||||
<div className="hidden lg:block">
|
</div>
|
||||||
<Divider />
|
</SettingsLayout>
|
||||||
</div>
|
</ConnectedLayout>
|
||||||
|
|
||||||
<SettingsSection title="Cancel subscription">
|
|
||||||
<PaddleLink
|
|
||||||
onClick={() =>
|
|
||||||
cancelSubscription({
|
|
||||||
cancelUrl: subscription.cancelUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
text="Cancel subscription on Paddle"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<SettingsSection title="Plan">
|
|
||||||
<BillingPlans />
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -6,8 +6,9 @@ import Layout from "../../components/layout";
|
|||||||
|
|
||||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||||
import appLogger from "../../../lib/logger";
|
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" });
|
const logger = appLogger.child({ page: "/account/settings" });
|
||||||
|
|
||||||
@ -28,36 +29,27 @@ const navigation = [
|
|||||||
|
|
||||||
const Settings: NextPage<Props> = (props) => {
|
const Settings: NextPage<Props> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Layout title="Settings">
|
<ConnectedLayout>
|
||||||
<div className="flex flex-col space-y-6 p-6">
|
<Layout title="Settings">
|
||||||
<aside className="py-6 lg:col-span-3">
|
<div className="flex flex-col space-y-6 p-6">
|
||||||
<nav className="space-y-1">
|
<aside className="py-6 lg:col-span-3">
|
||||||
{navigation.map((item) => (
|
<nav className="space-y-1">
|
||||||
<a
|
{navigation.map((item) => (
|
||||||
key={item.name}
|
<a
|
||||||
href={item.href}
|
key={item.name}
|
||||||
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"
|
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>
|
<item.icon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
|
||||||
</a>
|
<span className="truncate">{item.name}</span>
|
||||||
))}
|
</a>
|
||||||
</nav>
|
))}
|
||||||
</aside>
|
</nav>
|
||||||
</div>
|
</aside>
|
||||||
</Layout>
|
</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;
|
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