diff --git a/app/atoms/switching-workspace.ts b/app/atoms/switching-workspace.ts new file mode 100644 index 000000000..0dfb6a8de --- /dev/null +++ b/app/atoms/switching-workspace.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const switchingWorkspaceAtom = atom(true); diff --git a/app/components/booking/booking-assets-column.tsx b/app/components/booking/booking-assets-column.tsx index 4f5d927ca..ba99327e4 100644 --- a/app/components/booking/booking-assets-column.tsx +++ b/app/components/booking/booking-assets-column.tsx @@ -121,7 +121,7 @@ const ListAssetContent = ({ item }: { item: AssetWithBooking }) => {
-
+
{ mainImageExpiration: item.mainImageExpiration, alt: item.title, }} - className="h-full w-full rounded-[4px] border object-cover" + className="size-full rounded-[4px] border object-cover" />
diff --git a/app/components/booking/delete-booking.tsx b/app/components/booking/delete-booking.tsx index d3b6e3c90..a4f96edeb 100644 --- a/app/components/booking/delete-booking.tsx +++ b/app/components/booking/delete-booking.tsx @@ -36,7 +36,7 @@ export const DeleteBooking = ({
- +
diff --git a/app/components/booking/remove-asset-from-booking.tsx b/app/components/booking/remove-asset-from-booking.tsx index 82f515d21..ead9019a8 100644 --- a/app/components/booking/remove-asset-from-booking.tsx +++ b/app/components/booking/remove-asset-from-booking.tsx @@ -46,7 +46,7 @@ export const RemoveAssetFromBooking = ({ asset }: { asset: Asset }) => {
- +
diff --git a/app/components/layout/sidebar/bottom.tsx b/app/components/layout/sidebar/bottom.tsx index 2e7295f3d..b27430898 100644 --- a/app/components/layout/sidebar/bottom.tsx +++ b/app/components/layout/sidebar/bottom.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import type { User } from "@prisma/client"; import { Form } from "@remix-run/react"; +import { useAtom } from "jotai"; +import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; import { ChevronRight, QuestionsIcon } from "~/components/icons"; import { CrispButton } from "~/components/marketing/crisp"; import { Button } from "~/components/shared"; @@ -20,9 +22,15 @@ interface Props { export default function SidebarBottom({ user }: Props) { const [dropdownOpen, setDropdownOpen] = useState(false); + const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); return ( -
+
setDropdownOpen((prev) => !prev)} diff --git a/app/components/layout/sidebar/menu-items.tsx b/app/components/layout/sidebar/menu-items.tsx index 530a53221..6681a5c4c 100644 --- a/app/components/layout/sidebar/menu-items.tsx +++ b/app/components/layout/sidebar/menu-items.tsx @@ -2,6 +2,7 @@ import type { FetcherWithComponents } from "@remix-run/react"; import { NavLink, useLoaderData, useLocation } from "@remix-run/react"; import { motion } from "framer-motion"; import { useAtom } from "jotai"; +import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; import { SwitchIcon } from "~/components/icons/library"; import { ControlledActionButton } from "~/components/shared/controlled-action-button"; import { useMainMenuItems } from "~/hooks/use-main-menu-items"; @@ -18,6 +19,7 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { const location = useLocation(); /** We need to do this becasue of a special way we handle the bookings link that doesnt allow us to use NavLink currently */ const isBookingsRoute = location.pathname.includes("/bookings"); + const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); return (
@@ -29,7 +31,8 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { className={({ isActive }) => tw( "my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive ? "active bg-primary-50 text-primary-600" : "" + isActive ? "active bg-primary-50 text-primary-600" : "", + workspaceSwitching ? "pointer-events-none" : "" ) } to={"/admin-dashboard/users"} @@ -77,6 +80,7 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { "data-test-id": `${item.label.toLowerCase()}SidebarMenuItem`, onClick: toggleMobileNav, title: item.label, + disabled: workspaceSwitching, className: tw( "my-1 flex items-center gap-3 rounded border-0 bg-transparent px-3 text-left text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", canUseBookings @@ -95,7 +99,8 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { className={({ isActive }) => tw( "my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive ? "active bg-primary-50 text-primary-600" : "" + isActive ? "active bg-primary-50 text-primary-600" : "", + workspaceSwitching ? "pointer-events-none" : "" ) } to={item.to} @@ -131,7 +136,8 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { className={({ isActive }) => tw( "my-1 flex items-center gap-3 rounded px-3 py-2.5 text-[16px] font-semibold text-gray-700 transition-all duration-75 hover:bg-primary-50 hover:text-primary-600", - isActive ? "active bg-primary-50 text-primary-600" : "" + isActive ? "active bg-primary-50 text-primary-600" : "", + workspaceSwitching ? "pointer-events-none" : "" ) } to={item.to} @@ -159,7 +165,8 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { + )} + + + This email was sent to{" "} + + {booking.custodianUser!.email} + {" "} + because it is part of the Shelf workspace. + + {booking.organization.name} + + . If you think you weren’t supposed to have received this email please{" "} + + contact the owner + {" "} + of the workspace. + + + {" "} + © 2024 Shelf.nu + + + + ); +} + +/* + *The HTML content of an email will be accessed by a server file to send email, + we cannot import a TSX component in a server file so we are exporting TSX converted to HTML string using render function by react-email. + */ +export const bookingUpdatesTemplateString = ({ + booking, + heading, + assetCount, + hints, + hideViewButton = false, +}: Props) => + render( + + ); diff --git a/app/emails/invite-template.tsx b/app/emails/invite-template.tsx index 49b0a2b48..58e4e3344 100644 --- a/app/emails/invite-template.tsx +++ b/app/emails/invite-template.tsx @@ -33,9 +33,7 @@ export function InvitationEmailTemplate({ invite, token }: Props) { style={{ marginBottom: "24px" }} />
- + Howdy,
{invite.inviter.firstName} {invite.inviter.lastName} invites you to @@ -48,9 +46,7 @@ export function InvitationEmailTemplate({ invite, token }: Props) { > Accept the invite - + Once you’re done setting up your account, you'll be able to access the workspace and start exploring features like Asset Explorer, Location Tracking, Collaboration, Custom fields and more. If you @@ -61,9 +57,7 @@ export function InvitationEmailTemplate({ invite, token }: Props) { . - + Thanks,
The Shelf team
@@ -87,5 +81,9 @@ export function InvitationEmailTemplate({ invite, token }: Props) { ); } +/* + *The HTML content of an email will be accessed by a server file to send email, + we cannot import a TSX component in a server file so we are exporting TSX converted to HTML string using render function by react-email. + */ export const invitationTemplateString = ({ token, invite }: Props) => render(); diff --git a/app/emails/styles.ts b/app/emails/styles.ts index ed261f0be..f2c891f26 100644 --- a/app/emails/styles.ts +++ b/app/emails/styles.ts @@ -15,4 +15,17 @@ export const styles = { padding: "10px 18px", borderRadius: "4px", }, + h1: { + fontSize: "20px", + color: "#101828", + fontWeight: "600", + marginBottom: "16px", + }, + h2: { + fontSize: "16px", + color: "#101828", + fontWeight: "600", + marginBottom: "16px", + }, + p: { fontSize: "16px", color: "#344054" }, }; diff --git a/app/emails/types.ts b/app/emails/types.ts new file mode 100644 index 000000000..933e98f0a --- /dev/null +++ b/app/emails/types.ts @@ -0,0 +1,18 @@ +import type { Prisma } from "@prisma/client"; + +export type BookingForEmail = Prisma.BookingGetPayload<{ + include: { + custodianTeamMember: true; + custodianUser: true; + organization: { + include: { + owner: { + select: { email: true }; + }; + }; + }; + _count: { + select: { assets: true }; + }; + }; +}>; diff --git a/app/modules/booking/email-helpers.ts b/app/modules/booking/email-helpers.ts index b3594a680..81227c50a 100644 --- a/app/modules/booking/email-helpers.ts +++ b/app/modules/booking/email-helpers.ts @@ -1,4 +1,5 @@ -import type { Booking, TeamMember, User } from "@prisma/client"; +import { bookingUpdatesTemplateString } from "~/emails/bookings-updates-template"; +import type { BookingForEmail } from "~/emails/types"; import { SERVER_URL } from "~/utils"; import { getDateTimeFormatFromHints } from "~/utils/client-hints"; import { getTimeRemainingMessage } from "~/utils/date-fns"; @@ -9,7 +10,7 @@ import type { ClientHint } from "./types"; * THis is the base content of the bookings related emails. * We always provide some general info so this function standardizes that. */ -export const baseBookingEmailContent = ({ +export const baseBookingTextEmailContent = ({ bookingName, custodian, from, @@ -74,7 +75,7 @@ export const assetReservedEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -105,7 +106,7 @@ export const checkoutReminderEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -140,7 +141,7 @@ export const checkinReminderEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -155,10 +156,7 @@ export const checkinReminderEmailContent = ({ }); export const sendCheckinReminder = async ( - booking: Booking & { - custodianTeamMember: TeamMember | null; - custodianUser: User | null; - }, + booking: BookingForEmail, assetCount: number, hints: ClientHint ) => { @@ -176,6 +174,15 @@ export const sendCheckinReminder = async ( to: booking.to!, bookingId: booking.id, }), + html: bookingUpdatesTemplateString({ + booking, + heading: `Your booking is due for checkin in ${getTimeRemainingMessage( + new Date(booking.to!), + new Date() + )} minutes.`, + assetCount, + hints, + }), }); }; @@ -201,7 +208,7 @@ export const overdueBookingEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -234,7 +241,7 @@ export const completedBookingEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -267,7 +274,7 @@ export const deletedBookingEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, @@ -300,7 +307,7 @@ export const cancelledBookingEmailContent = ({ bookingId: string; hints: ClientHint; }) => - baseBookingEmailContent({ + baseBookingTextEmailContent({ hints, bookingName, custodian, diff --git a/app/modules/booking/service.server.ts b/app/modules/booking/service.server.ts index 30888cf9b..d9dac4c9d 100644 --- a/app/modules/booking/service.server.ts +++ b/app/modules/booking/service.server.ts @@ -7,6 +7,7 @@ import { AssetStatus, } from "@prisma/client"; import { db } from "~/database"; +import { bookingUpdatesTemplateString } from "~/emails/bookings-updates-template"; import { calcTimeDifference } from "~/utils/date-fns"; import { ShelfStackError } from "~/utils/error"; import { sendEmail } from "~/utils/mail.server"; @@ -21,6 +22,22 @@ import { } from "./email-helpers"; import type { ClientHint, SchedulerData } from "./types"; +/** Includes needed for booking to have all data required for emails */ +export const bookingIncludeForEmails = { + custodianTeamMember: true, + custodianUser: true, + organization: { + include: { + owner: { + select: { email: true }, + }, + }, + }, + _count: { + select: { assets: true }, + }, +}; + const cancelSheduler = async (b?: Booking | null) => { if (b?.activeSchedulerReference) { scheduler.cancel(b.activeSchedulerReference).catch((err) => { @@ -177,9 +194,7 @@ export const upsertBooking = async ( include: { ...commonInclude, assets: true, - _count: { - select: { assets: true }, - }, + ...bookingIncludeForEmails, }, }); @@ -217,32 +232,43 @@ export const upsertBooking = async ( data.status === BookingStatus.COMPLETE || data.status === BookingStatus.CANCELLED ) { + const custodian = + `${res.custodianUser?.firstName} ${res.custodianUser?.lastName}` || + (res.custodianTeamMember?.name as string); let subject = `Booking reserved (${res.name}) - shelf.nu`; let text = assetReservedEmailContent({ bookingName: res.name, assetsCount: res.assets.length, - custodian: - `${res.custodianUser?.firstName} ${res.custodianUser?.lastName}` || - (res.custodianTeamMember?.name as string), + custodian: custodian, from: res.from!, to: res.to!, hints, bookingId: res.id, }); + let html = bookingUpdatesTemplateString({ + booking: res, + heading: `Booking confirmation for ${custodian}`, + assetCount: res.assets.length, + hints, + }); if (data.status === BookingStatus.COMPLETE) { subject = `Booking completed (${res.name}) - shelf.nu`; text = completedBookingEmailContent({ bookingName: res.name, assetsCount: res._count.assets, - custodian: - `${res.custodianUser?.firstName} ${res.custodianUser?.lastName}` || - (res.custodianTeamMember?.name as string), + custodian: custodian, from: booking.from as Date, // We can safely cast here as we know the booking is overdue so it myust have a from and to date to: booking.to as Date, bookingId: res.id, hints: hints, }); + html = bookingUpdatesTemplateString({ + booking: res, + heading: `Your booking has been completed: "${res.name}".`, + assetCount: res._count.assets, + hints, + }); } if (data.status === BookingStatus.CANCELLED) { @@ -258,6 +284,12 @@ export const upsertBooking = async ( bookingId: res.id, hints: hints, }); + html = bookingUpdatesTemplateString({ + booking: res, + heading: `Your booking has been cancelled: "${res.name}".`, + assetCount: res._count.assets, + hints, + }); } promises.push( @@ -265,6 +297,7 @@ export const upsertBooking = async ( to: email, subject, text, + html, }) ); } else if (data.status === BookingStatus.ONGOING && res.to) { @@ -502,8 +535,7 @@ export const deleteBooking = async ( where: { id }, include: { ...commonInclude, - assets: true, - _count: { select: { assets: true } }, + ...bookingIncludeForEmails, }, }); @@ -521,11 +553,19 @@ export const deleteBooking = async ( bookingId: b.id, hints: hints, }); + const html = bookingUpdatesTemplateString({ + booking: b, + heading: `Your booking has been deleted: "${b.name}".`, + assetCount: b._count.assets, + hints, + hideViewButton: true, + }); await sendEmail({ to: email, subject, text, + html, }); } diff --git a/app/modules/booking/worker.server.ts b/app/modules/booking/worker.server.ts index b8df21381..4fe4d1682 100644 --- a/app/modules/booking/worker.server.ts +++ b/app/modules/booking/worker.server.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console */ import { BookingStatus } from "@prisma/client"; import { db } from "~/database"; +import { bookingUpdatesTemplateString } from "~/emails/bookings-updates-template"; +import { getTimeRemainingMessage } from "~/utils/date-fns"; import { sendEmail } from "~/utils/mail.server"; import { scheduler } from "~/utils/scheduler.server"; import { schedulerKeys } from "./constants"; @@ -9,7 +11,10 @@ import { overdueBookingEmailContent, sendCheckinReminder, } from "./email-helpers"; -import { scheduleNextBookingJob } from "./service.server"; +import { + bookingIncludeForEmails, + scheduleNextBookingJob, +} from "./service.server"; import type { SchedulerData } from "./types"; /** ===== start: listens and creates chain of jobs for a given booking ===== */ @@ -21,14 +26,7 @@ export const registerBookingWorkers = () => { async ({ data }) => { const booking = await db.booking.findFirst({ where: { id: data.id }, - include: { - custodianTeamMember: true, - custodianUser: true, - organization: true, - _count: { - select: { assets: true }, - }, - }, + include: bookingIncludeForEmails, }); if (!booking) { console.warn( @@ -52,6 +50,15 @@ export const registerBookingWorkers = () => { bookingId: booking.id, hints: data.hints, }), + html: bookingUpdatesTemplateString({ + booking, + heading: `Your booking is due for checkout in ${getTimeRemainingMessage( + new Date(booking.from), + new Date() + )}.`, + assetCount: booking._count.assets, + hints: data.hints, + }), }).catch((err) => { console.error(`failed to send checkoutReminder email`, err); }); @@ -75,14 +82,7 @@ export const registerBookingWorkers = () => { async ({ data }) => { const booking = await db.booking.findFirst({ where: { id: data.id }, - include: { - custodianTeamMember: true, - custodianUser: true, - organization: true, - _count: { - select: { assets: true }, - }, - }, + include: bookingIncludeForEmails, }); if (!booking) { console.warn( @@ -154,14 +154,7 @@ export const registerBookingWorkers = () => { async ({ data }) => { const booking = await db.booking.findFirst({ where: { id: data.id }, - include: { - custodianTeamMember: true, - custodianUser: true, - organization: true, - _count: { - select: { assets: true }, - }, - }, + include: bookingIncludeForEmails, }); if (!booking) { console.warn( @@ -191,6 +184,12 @@ export const registerBookingWorkers = () => { bookingId: booking.id, hints: data.hints, }), + html: bookingUpdatesTemplateString({ + booking, + heading: `You have passed the deadline for checking in your booking "${booking.name}".`, + assetCount: booking._count.assets, + hints: data.hints, + }), }).catch((err) => { console.error(`failed to send overdue reminder email`, err); }); diff --git a/app/root.tsx b/app/root.tsx index 26f912bcf..1a56a1da9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren } from "react"; +import { type PropsWithChildren } from "react"; import type { User } from "@prisma/client"; import type { LinksFunction, @@ -100,6 +100,7 @@ function Document({ children, title }: PropsWithChildren<{ title?: string }>) { function App() { const { maintenanceMode } = useLoaderData(); + return ( {maintenanceMode ? : } ); diff --git a/app/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 04b44b3bf..0d68abb58 100644 --- a/app/routes/_layout+/_layout.tsx +++ b/app/routes/_layout+/_layout.tsx @@ -1,11 +1,14 @@ import { Roles } from "@prisma/client"; import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; - import { json, redirect } from "@remix-run/node"; import { Outlet } from "@remix-run/react"; +import { useAtom } from "jotai"; +import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; + import { ErrorBoundryComponent } from "~/components/errors"; import Sidebar from "~/components/layout/sidebar/sidebar"; import { useCrisp } from "~/components/marketing/crisp"; +import { Spinner } from "~/components/shared/spinner"; import { Toaster } from "~/components/shared/toast"; import { db } from "~/database"; import { commitAuthSession, requireAuthSession } from "~/modules/auth"; @@ -113,19 +116,29 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { export default function App() { useCrisp(); + const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); return ( -
-
- -
-
- -
- -
+ <> +
+
+ +
+
+ {workspaceSwitching ? ( +
+ +

Switching workspaces...

+
+ ) : ( + + )} +
+ +
+
-
+ ); } diff --git a/app/routes/_layout+/bookings.$bookingId.add-assets.tsx b/app/routes/_layout+/bookings.$bookingId.add-assets.tsx index 0d200e121..9aa9b347c 100644 --- a/app/routes/_layout+/bookings.$bookingId.add-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId.add-assets.tsx @@ -232,7 +232,7 @@ const RowComponent = ({ item }: { item: AssetWithBooking }) => {
-
+
{ mainImageExpiration: item.mainImageExpiration, alt: item.title, }} - className="h-full w-full rounded-[4px] border object-cover" + className="size-full rounded-[4px] border object-cover" />
diff --git a/app/routes/_layout+/bookings.tsx b/app/routes/_layout+/bookings.tsx index aa9f13f47..5e6c31cc5 100644 --- a/app/routes/_layout+/bookings.tsx +++ b/app/routes/_layout+/bookings.tsx @@ -343,7 +343,7 @@ function UserBadge({ img, name }: { img?: string; name: string }) { {name}