Compare commits

...

16 Commits

Author SHA1 Message Date
Sean Brydon d9d2aff953 Correct br 2023-10-31 11:02:21 +00:00
Sean Brydon 2a118e1a6b Stop navigator throwing errors on inital render 2023-10-31 10:25:17 +00:00
Sean Brydon 8464e74c43 Ensure header navigation works 2023-10-31 10:09:11 +00:00
Sean Brydon f11f587d1b add event title as a tooltip 2023-10-26 13:02:12 +01:00
Sean Brydon df9936858a Merge remote-tracking branch 'origin/main' into feat/troubleshooter-v2 2023-10-26 12:08:47 +01:00
Sean Brydon 98e12b162a Changes sizes on smaller screens 2023-10-26 12:07:36 +01:00
Sean Brydon f080838855 Add date override to calendar 2023-10-26 10:55:07 +01:00
Sean Brydon 2904230009 Store more event info than just slug 2023-10-26 10:17:57 +01:00
Sean Brydon 61eb439fa0 useschedule 2023-10-26 09:08:07 +01:00
Sean Brydon 1b87cf80b9 Add busy events to calendar 2023-10-25 14:19:15 +01:00
Sean Brydon a9bf0d625f Load schedule from event slug 2023-10-25 14:18:55 +01:00
Sean Brydon 6b6e9dba2b Calendar toggle 2023-10-25 14:18:29 +01:00
Sean Brydon daab6a17d0 adds get schedule by event-type-slug 2023-10-25 10:52:25 +01:00
Sean Brydon 8e97169de4 event-select - sidebar + store work 2023-10-24 10:02:13 +01:00
Sean Brydon 6f01802f22 use booker approach of grid 2023-10-23 17:33:30 +01:00
Sean Brydon f93d2d83e5 Inital UI + layout setup 2023-10-19 14:20:45 +01:00
23 changed files with 1184 additions and 155 deletions

View File

@ -1,139 +1,12 @@
import dayjs from "@calcom/dayjs"; import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
import Shell from "@calcom/features/shell/Shell"; import { getLayout } from "@calcom/features/troubleshooter/layout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import PageWrapper from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"]; function TroubleshooterPage() {
return <Troubleshooter />;
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
} }
const AvailabilityView = ({ user }: { user: User }) => { TroubleshooterPage.getLayout = getLayout;
const { t } = useLocale(); TroubleshooterPage.PageWrapper = PageWrapper;
const { date, setQuery: setSelectedDate } = useRouterQuery("date"); export default TroubleshooterPage;
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: user.username || "",
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!user.username,
}
);
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return (
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none bg-inherit p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
/>
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="bg-subtle overflow-hidden rounded-md"
data-testid="troubleshooter-busy-time">
<div className="text-emphasis px-4 py-5 sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
));
return (
<div className="bg-subtle overflow-hidden rounded-md">
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
);
})()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const { data, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
);
}
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}

View File

@ -0,0 +1,139 @@
import dayjs from "@calcom/dayjs";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: user.username || "",
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!user.username,
}
);
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return (
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none bg-inherit p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
/>
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="bg-subtle overflow-hidden rounded-md"
data-testid="troubleshooter-busy-time">
<div className="text-emphasis px-4 py-5 sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
));
return (
<div className="bg-subtle overflow-hidden rounded-md">
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
);
})()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const { data, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
);
}
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}

View File

@ -2094,6 +2094,11 @@
"overlay_my_calendar":"Overlay my calendar", "overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"troubleshooting":"Troubleshooting",
"calendars_were_checking_for_conflicts":"Calendars were checking for conflicts",
"availabilty_schedules":"Availability schedules",
"manage calendars":"Manage calendars",
"manage_availability_schedules":"Manage availability schedules",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page", "lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.", "description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"

View File

@ -1,6 +1,8 @@
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { Tooltip } from "@calcom/ui";
import type { CalendarEvent } from "../../types/events"; import type { CalendarEvent } from "../../types/events";
@ -13,7 +15,7 @@ type EventProps = {
}; };
const eventClasses = cva( const eventClasses = cva(
"group flex h-full w-full flex-col overflow-y-auto rounded-[4px] px-[6px] py-1 text-xs font-semibold leading-5 ", "group flex h-full w-full flex-col overflow-y-auto rounded-[6px] px-[6px] py-1 text-xs font-semibold leading-5 ",
{ {
variants: { variants: {
status: { status: {
@ -62,23 +64,28 @@ export function Event({
const Component = onEventClick ? "button" : "div"; const Component = onEventClick ? "button" : "div";
return ( return (
<Component <Tooltip content={event.title}>
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event. <Component
className={eventClasses({ onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
status: options?.status, className={classNames(
disabled, eventClasses({
selected, status: options?.status,
borderColor, disabled,
})} selected,
style={styles}> borderColor,
<div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4"> }),
{event.title} options?.className
</div> )}
{eventDuration > 30 && ( style={styles}>
<p className="text-subtle text-left text-[10px] leading-none"> <div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} {event.title}
</p> </div>
)} {eventDuration > 30 && (
</Component> <p className="text-subtle text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</Component>
</Tooltip>
); );
} }

View File

@ -2,8 +2,12 @@ import type dayjs from "@calcom/dayjs";
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => { export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
const isRTL = () => { const isRTL = () => {
const userLocale = navigator.language; let userLanguage = "en"; // Default to 'en' if navigator is not defined
const userLanguage = new Intl.Locale(userLocale).language;
if (typeof window !== "undefined" && typeof navigator !== "undefined") {
const userLocale = navigator.language;
userLanguage = new Intl.Locale(userLocale).language;
}
return ["ar", "he", "fa", "ur"].includes(userLanguage); return ["ar", "he", "fa", "ur"].includes(userLanguage);
}; };

View File

@ -10,5 +10,6 @@ export interface CalendarEvent {
status?: BookingStatus; status?: BookingStatus;
allDay?: boolean; allDay?: boolean;
borderColor?: string; borderColor?: string;
className?: string;
}; };
} }

View File

@ -0,0 +1,68 @@
import StickyBox from "react-sticky-box";
import type { TroubleshooterProps } from "troubleshooter/types";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { LargeCalendar } from "./components/LargeCalendar";
import { TroubleshooterHeader } from "./components/TroubleshooterHeader";
import { TroubleshooterSidebar } from "./components/TroubleshooterSidebar";
import { useInitalizeTroubleshooterStore } from "./store";
const extraDaysConfig = {
desktop: 7,
tablet: 4,
};
const TroubleshooterComponent = ({ month }: TroubleshooterProps) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const StickyOnDesktop = isMobile ? "div" : StickyBox;
const extraDays = isTablet ? extraDaysConfig.tablet : extraDaysConfig.desktop;
useInitalizeTroubleshooterStore({
month: month,
});
return (
<>
<div
className={classNames(
"text-default flex min-h-full w-full flex-col items-center overflow-clip [--troublehooster-meta-width:350px] lg:[--troubleshooter-meta-width:430px]"
)}>
<div
style={{
width: "100vw",
minHeight: "100vh",
height: "auto",
gridTemplateAreas: `
"meta header header"
"meta main main"
`,
gridTemplateColumns: "var(--troubleshooter-meta-width) 1fr",
gridTemplateRows: "70px auto",
}}
className={classNames(
"bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row"
)}>
<div className={classNames("bg-default dark:bg-muted sticky top-0 z-10 [grid-area:header]")}>
<TroubleshooterHeader extraDays={extraDays} isMobile={isMobile} />
</div>
<StickyOnDesktop key="meta" className={classNames("relative z-10 flex [grid-area:meta]")}>
<div className="max-w-screen flex w-full flex-col [grid-area:meta] md:w-[var(--troubleshooter-meta-width)]">
<TroubleshooterSidebar />
</div>
</StickyOnDesktop>
<div className="border-subtle sticky top-0 ml-[-1px] h-full [grid-area:main] md:border-l">
<LargeCalendar extraDays={extraDays} />
</div>
</div>
</div>
</>
);
};
export const Troubleshooter = ({ month }: TroubleshooterProps) => {
return <TroubleshooterComponent month={month} />;
};

View File

@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
function AvailabiltyItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title="Office Hours"
subtitle="Mon-Fri; 9:00 AM - 5:00 PM"
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<p className="text-subtle text-sm font-medium leading-none">{t("date_overrides")}</p>
<Switch label="google@calendar.com" />
</div>
</TroubleshooterListItemContainer>
);
}
export function AvailabiltySchedulesContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("availabilty_schedules")}</p>
<AvailabiltyItem />
<Button color="secondary" className="justify-center gap-2">
{t("manage_availabilty_schedules")}
</Button>
</div>
);
}

View File

@ -0,0 +1,148 @@
import { useRef, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
import { classNames } from "@calcom/lib";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { AvailableTimesHeader } from "../../components/AvailableTimesHeader";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
type AvailableTimeSlotsProps = {
extraDays?: number;
limitHeight?: boolean;
prefetchNextMonth: boolean;
monthCount: number | undefined;
seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null;
};
/**
* Renders available time slots for a given date.
* It will extract the date from the booker store.
* Next to that you can also pass in the `extraDays` prop, this
* will also fetch the next `extraDays` days and show multiple days
* in columns next to each other.
*/
export const AvailableTimeSlots = ({
extraDays,
limitHeight,
seatsPerTimeSlot,
showAvailableSeatsCount,
prefetchNextMonth,
monthCount,
}: AvailableTimeSlotsProps) => {
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
const isMobile = useMediaQuery("(max-width: 768px)");
const selectedDate = useBookerStore((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData);
const isEmbed = useIsEmbed();
const event = useEvent();
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStore((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const containerRef = useRef<HTMLDivElement | null>(null);
const onTimeSelect = (
time: string,
attendees: number,
seatsPerTimeSlot?: number | null,
bookingUid?: string
) => {
setSelectedTimeslot(time);
if (seatsPerTimeSlot) {
setSeatedEventData({
seatsPerTimeSlot,
attendees,
bookingUid,
showAvailableSeatsCount,
});
if (seatsPerTimeSlot && seatsPerTimeSlot - attendees > 1) {
return;
}
}
if (!event.data) return;
};
const schedule = useScheduleForEvent({
prefetchNextMonth,
monthCount,
});
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
// Creates an array of dates to fetch slots for.
// If `extraDays` is passed in, we will extend the array with the next `extraDays` days.
const dates = !extraDays
? [date]
: nonEmptyScheduleDaysFromSelectedDate.length > 0
? nonEmptyScheduleDaysFromSelectedDate.slice(0, extraDays)
: [];
const slotsPerDay = useSlotsForAvailableDates(dates, schedule?.data?.slots);
useEffect(() => {
if (isEmbed) return;
if (containerRef.current && !schedule.isLoading && isMobile) {
containerRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [containerRef, schedule.isLoading, isEmbed, isMobile]);
return (
<>
<div className="flex">
{schedule.isLoading ? (
<div className="mb-3 h-8" />
) : (
slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<AvailableTimesHeader
key={slots.date}
date={dayjs(slots.date)}
showTimeFormatToggle={!isColumnView}
availableMonth={
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
? dayjs(slots.date).format("MMM")
: undefined
}
/>
))
)}
</div>
<div
ref={containerRef}
className={classNames(
limitHeight && "scroll-bar flex-grow overflow-auto md:h-[400px]",
!limitHeight && "flex h-full w-full flex-row gap-4"
)}>
{schedule.isLoading
? // Shows exact amount of days as skeleton.
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)
: slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<AvailableTimes
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
key={slots.date}
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}
slots={slots.slots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
/>
))}
</div>
</>
);
};

View File

@ -0,0 +1,117 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"];
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return <Switch key={calendar.name} checked={calendar.active} label={calendar.name} disabled />;
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title="Please install a calendar"
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
Not found
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<Button color="secondary" className="justify-center gap-2">
{t("install_calendar")}
</Button>
</div>
</TroubleshooterListItemContainer>
);
}
export function CalendarToggleContainer() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery();
const hasConnectedCalendars = data && data?.connectedCalendars.length > 0;
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("calendars_were_checking_for_conflicts")}</p>
{hasConnectedCalendars && !isLoading ? (
<>
{data.connectedCalendars.map((calendar, idx) => {
const foundPrimary = calendar.calendars?.find((item) => item.primary);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot={SELECTION_COLORS[idx] || "#000000"}
subtitle={foundPrimary?.name ?? "Nameless Calendar"}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
<Button color="secondary" className="justify-center gap-2">
{t("manage_calendars")}
</Button>
</>
) : (
<EmptyCalendarToggleItem />
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
function ConnectedAppsItem() {
return (
<TroubleshooterListItemHeader
title="Google Cal"
subtitle="google@calendar.com"
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}
/>
);
}
export function ConnectedAppsContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("other_apps")}</p>
<div className="[&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:last-child]:border-b">
<ConnectedAppsItem />
<ConnectedAppsItem />
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Label } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
export function EventScheduleItem() {
const { t } = useLocale();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
return (
<div>
<Label>Availability Schedule</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? "Loading"}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge color="orange" size="sm" className="hidden hover:cursor-pointer group-hover:inline-flex">
{t("edit")}
</Badge>
</Link>
)
}
/>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { useMemo } from "react";
import { trpc } from "@calcom/trpc";
import { SelectField } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
export function EventTypeSelect() {
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent);
// const selectedEventQueryParam = getQueryParam("eventType");
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.slug,
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
return (
<SelectField
label="Event Type"
options={options}
value={options.find((option) => option.value === selectedEventType?.slug) || options[0]}
onChange={(option) => {
if (!option) return;
setSelectedEventType({
slug: option.value,
id: option.id,
duration: option.duration,
});
}}
/>
);
}

View File

@ -0,0 +1,116 @@
import { useSession } from "next-auth/react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { useTimePreferences } from "../../bookings/lib/timePreferences";
import { useSchedule } from "../../schedules/lib/use-schedule";
import { useTroubleshooterStore } from "../store";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
const { timezone } = useTimePreferences();
const selectedDate = useTroubleshooterStore((state) => state.selectedDate);
const event = useTroubleshooterStore((state) => state.event);
const { data: session } = useSession();
const startDate = selectedDate ? dayjs(selectedDate) : dayjs();
const { data: busyEvents, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: session?.user?.username || "",
dateFrom: startDate.startOf("day").utc().format(),
dateTo: startDate
.endOf("day")
.add(extraDays - 1, "day")
.utc()
.format(),
withSource: true,
},
{
enabled: !!session?.user?.username,
}
);
const { data: schedule } = useSchedule({
username: session?.user.username || "",
eventSlug: event?.slug,
eventId: event?.id,
timezone,
month: startDate.format("YYYY-MM"),
});
const endDate = dayjs(startDate)
.add(extraDays - 1, "day")
.toDate();
const availableSlots = useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule) return availableTimeslots;
if (!schedule?.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
start: dayjs(slot.time).toDate(),
end: dayjs(slot.time)
.add(event?.duration ?? 30, "minutes")
.toDate(),
}));
}
return availableTimeslots;
}, [schedule, event]);
const events = useMemo(() => {
if (!busyEvents?.busy) return [];
const calendarEvents = busyEvents?.busy.map((event, idx) => {
return {
id: idx,
title: event.title ?? "Busy",
start: new Date(event.start),
end: new Date(event.end),
options: {
borderColor: "#F97417",
status: BookingStatus.ACCEPTED,
},
};
});
if (busyEvents.dateOverrides) {
for (const date in busyEvents.dateOverrides) {
const dateOverride = busyEvents.dateOverrides[date];
calendarEvents.push({
id: calendarEvents.length,
title: "Date Override",
start: new Date(dateOverride.start),
end: new Date(dateOverride.end),
options: {
borderColor: "black",
status: BookingStatus.ACCEPTED,
},
});
}
}
return calendarEvents;
}, [busyEvents]);
return (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
<Calendar
startHour={0}
endHour={23}
events={events}
availableTimeslots={availableSlots}
startDate={startDate.toDate()}
endDate={endDate}
gridCellsPerHour={60 / 15}
hoverEventDuration={30}
hideHeader
/>
</div>
);
};

View File

@ -0,0 +1,80 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
export function TroubleshooterHeader({ extraDays, isMobile }: { extraDays: number; isMobile: boolean }) {
const { t, i18n } = useLocale();
const selectedDateString = useTroubleshooterStore((state) => state.selectedDate);
const setSelectedDate = useTroubleshooterStore((state) => state.setSelectedDate);
const addToSelectedDate = useTroubleshooterStore((state) => state.addToSelectedDate);
const selectedDate = selectedDateString ? dayjs(selectedDateString) : dayjs();
const today = dayjs();
const selectedDateMin3DaysDifference = useMemo(() => {
const diff = today.diff(selectedDate, "days");
return diff > 3 || diff < -3;
}, [today, selectedDate]);
if (isMobile) return null;
const endDate = selectedDate.add(extraDays - 1, "days");
const isSameMonth = () => {
return selectedDate.format("MMM") === endDate.format("MMM");
};
const isSameYear = () => {
return selectedDate.format("YYYY") === endDate.format("YYYY");
};
const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" });
const FormattedSelectedDateRange = () => {
return (
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
<span className="text-subtle">
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
</span>
</h3>
);
};
return (
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
<div className="flex items-center gap-5 rtl:flex-grow">
<FormattedSelectedDateRange />
<ButtonGroup>
<Button
className="group rtl:ml-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon={ChevronLeft}
aria-label="Previous Day"
onClick={() => addToSelectedDate(-extraDays)}
/>
<Button
className="group rtl:mr-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon={ChevronRight}
aria-label="Next Day"
onClick={() => addToSelectedDate(extraDays)}
/>
{selectedDateMin3DaysDifference && (
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
{t("today")}
</Button>
)}
</ButtonGroup>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import type { PropsWithChildren } from "react";
import classNames from "@calcom/lib/classNames";
interface TroubleshooterListItemContainerProps {
title: string;
subtitle?: string;
suffixSlot?: React.ReactNode;
prefixSlot?: React.ReactNode;
className?: string;
}
export function TroubleshooterListItemHeader({
prefixSlot,
title,
subtitle,
suffixSlot,
className,
}: TroubleshooterListItemContainerProps) {
return (
<div className={classNames("border-subtle flex max-w-full gap-3 border border-b-0 px-4 py-2", className)}>
{prefixSlot}
<div className="flex h-full max-w-full flex-1 flex-col flex-nowrap truncate text-sm leading-4">
<p className="font-medium">{title}</p>
{subtitle && <p className="font-normal">{subtitle}</p>}
</div>
{suffixSlot}
</div>
);
}
export function TroubleshooterListItemContainer({
children,
...rest
}: PropsWithChildren<TroubleshooterListItemContainerProps>) {
return (
<div className="[&>*:first-child]:rounded-t-md ">
<TroubleshooterListItemHeader {...rest} />
<div className="border-subtle flex flex-col space-y-3 rounded-b-md border p-4">{children}</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Skeleton } from "@calcom/ui";
import { CalendarToggleContainer } from "./CalendarToggleContainer";
import { EventScheduleItem } from "./EventScheduleItem";
import { EventTypeSelect } from "./EventTypeSelect";
const BackButtonInSidebar = ({ name }: { name: string }) => {
return (
<Link
href="/"
className="hover:bg-subtle group-hover:text-default text-emphasis group flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2">
<ArrowLeft className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0" />
<Skeleton
title={name}
as="p"
className="max-w-36 min-h-4 truncate text-xl font-semibold"
loadingClassName="ms-3">
{name}
</Skeleton>
</Link>
);
};
export const TroubleshooterSidebar = () => {
const { i18n, t } = useLocale();
return (
<div className="relative z-10 flex w-full flex-col gap-6 py-6 pr-6">
<BackButtonInSidebar name={t("troubleshooter")} />
<EventTypeSelect />
<EventScheduleItem />
<CalendarToggleContainer />
</div>
);
};

View File

@ -0,0 +1,23 @@
import type { ComponentProps } from "react";
import React, { Suspense } from "react";
import Shell from "@calcom/features/shell/Shell";
import { ErrorBoundary } from "@calcom/ui";
import { Loader } from "@calcom/ui/components/icon";
export default function TroubleshooterLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell withoutSeo={true} flexChildrenContainer hideHeadingOnMobile {...rest} SidebarContainer={<></>}>
<div className="flex flex-1 [&>*]:flex-1">
<ErrorBoundary>
<Suspense fallback={<Loader />}>{children}</Suspense>
</ErrorBoundary>
</div>
</Shell>
);
}
export const getLayout = (page: React.ReactElement) => <TroubleshooterLayout>{page}</TroubleshooterLayout>;

View File

@ -0,0 +1,101 @@
import { useEffect } from "react";
import { create } from "zustand";
import dayjs from "@calcom/dayjs";
import { updateQueryParam, getQueryParam, removeQueryParam } from "../bookings/Booker/utils/query-param";
/**
* Arguments passed into store initializer, containing
* the event data.
*/
type StoreInitializeType = {
month: string | null;
};
type EventType = {
id: number;
slug: string;
duration: number;
};
export type TroubleshooterStore = {
event: EventType | null;
setEvent: (eventSlug: EventType) => void;
month: string | null;
setMonth: (month: string | null) => void;
selectedDate: string | null;
setSelectedDate: (date: string | null) => void;
addToSelectedDate: (days: number) => void;
initialize: (data: StoreInitializeType) => void;
};
/**
* The booker store contains the data of the component's
* current state. This data can be reused within child components
* by importing this hook.
*
* See comments in interface above for more information on it's specific values.
*/
export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) => ({
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
}
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
updateQueryParam("date", selectedDate ?? "");
// Setting month make sure small calendar in fullscreen layouts also updates.
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
},
addToSelectedDate: (days: number) => {
const selectedDate = get().selectedDate;
const currentSelection = selectedDate ? dayjs(get().selectedDate) : dayjs();
const newSelection = currentSelection.add(days, "day");
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
set({ selectedDate: newSelectionFormatted });
updateQueryParam("date", newSelectionFormatted);
},
event: null,
setEvent: (event: EventType) => {
set({ event });
updateQueryParam("eventType", event.slug ?? "");
},
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
set({ month });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
initialize: ({ month }: StoreInitializeType) => {
if (month) {
set({ month });
updateQueryParam("month", month);
}
//removeQueryParam("layout");
},
}));
export const useInitalizeTroubleshooterStore = ({ month }: StoreInitializeType) => {
const initializeStore = useTroubleshooterStore((state) => state.initialize);
useEffect(() => {
initializeStore({
month,
});
}, [initializeStore, month]);
};

View File

@ -0,0 +1,13 @@
export interface TroubleshooterProps {
/**
* If month is NOT set as a prop on the component, we expect a query parameter
* called `month` to be present on the url. If that is missing, the component will
* default to the current month.
* @note In case you're using a client side router, please pass the value in as a prop,
* since the component will leverage window.location, which might not have the query param yet.
* @format YYYY-MM.
* @optional
*/
month: string | null;
selectedDate?: Date;
}

View File

@ -4,6 +4,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema"; import { ZDeleteInputSchema } from "./delete.schema";
import { ZScheduleDuplicateSchema } from "./duplicate.schema"; import { ZScheduleDuplicateSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema"; import { ZGetInputSchema } from "./get.schema";
import { ZGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema";
import { ZGetByUserIdInputSchema } from "./getScheduleByUserId.schema"; import { ZGetByUserIdInputSchema } from "./getScheduleByUserId.schema";
import { ZUpdateInputSchema } from "./update.schema"; import { ZUpdateInputSchema } from "./update.schema";
@ -14,6 +15,7 @@ type ScheduleRouterHandlerCache = {
update?: typeof import("./update.handler").updateHandler; update?: typeof import("./update.handler").updateHandler;
duplicate?: typeof import("./duplicate.handler").duplicateHandler; duplicate?: typeof import("./duplicate.handler").duplicateHandler;
getScheduleByUserId?: typeof import("./getScheduleByUserId.handler").getScheduleByUserIdHandler; getScheduleByUserId?: typeof import("./getScheduleByUserId.handler").getScheduleByUserIdHandler;
getScheduleByEventSlug?: typeof import("./getScheduleByEventTypeSlug.handler").getScheduleByEventSlugHandler;
}; };
const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {}; const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {};
@ -118,4 +120,21 @@ export const scheduleRouter = router({
input, input,
}); });
}), }),
getScheduleByEventSlug: authedProcedure.input(ZGetByEventSlugInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) {
UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug = await import(
"./getScheduleByEventTypeSlug.handler"
).then((mod) => mod.getScheduleByEventSlugHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug({
ctx,
input,
});
}),
}); });

View File

@ -0,0 +1,69 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../../trpc";
import { getHandler } from "./get.handler";
import type { TGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetByEventSlugInputSchema;
};
const EMPTY_SCHEDULE = [[], [], [], [], [], [], []];
export const getScheduleByEventSlugHandler = async ({ ctx, input }: GetOptions) => {
const foundScheduleForSlug = await ctx.prisma.eventType.findFirst({
where: {
slug: input.eventSlug,
userId: ctx.user.id,
},
select: {
scheduleId: true,
},
});
try {
// This looks kinda weird that we throw straight in the catch - its so that we can return a default schedule if the user has not completed onboarding @shiraz will loveme for this
if (!foundScheduleForSlug?.scheduleId) {
const foundUserDefaultId = await ctx.prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
defaultScheduleId: true,
},
});
if (foundUserDefaultId?.defaultScheduleId) {
return await getHandler({
ctx,
input: {
scheduleId: foundUserDefaultId?.defaultScheduleId,
},
});
}
throw new Error("NOT_FOUND");
}
return await getHandler({
ctx,
input: {
scheduleId: foundScheduleForSlug?.scheduleId,
},
});
} catch (e) {
console.log(e);
return {
id: -1,
name: "No schedules found",
availability: EMPTY_SCHEDULE,
dateOverrides: [],
timeZone: ctx.user.timeZone || "Europe/London",
workingHours: [],
isDefault: true,
};
}
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetByEventSlugInputSchema = z.object({
eventSlug: z.string(),
});
export type TGetByEventSlugInputSchema = z.infer<typeof ZGetByEventSlugInputSchema>;