diff --git a/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts b/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts new file mode 100644 index 0000000000..fc3a9f4804 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts @@ -0,0 +1,79 @@ +import { useSession } from "next-auth/react"; +import { useMemo, useEffect } from "react"; + +import dayjs from "@calcom/dayjs"; +import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability"; +import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; + +type UseOnboardingCalendarEventsProps = { + startDate: Date; + endDate: Date; +}; + +const emptyAvailabilityData = { + busy: [], + timeZone: "", + dateRanges: [], + oooExcludedDateRanges: [], + workingHours: [], + dateOverrides: [], + currentSeats: null, + datesOutOfOffice: {}, +} as GetUserAvailabilityResult; + +export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => { + const { data: session } = useSession(); + const utils = trpc.useUtils(); + + // Watch for calendar installations to refetch events + const { data: connectedCalendars } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, { + refetchInterval: 5000, // Poll every 5 seconds to detect new calendar installations + }); + + const { data: busyEvents } = trpc.viewer.availability.user.useQuery( + { + username: session?.user?.username || "", + dateFrom: dayjs(startDate).startOf("day").utc().format(), + dateTo: dayjs(endDate).endOf("day").utc().format(), + withSource: true, + }, + { + enabled: !!session?.user?.username, + // Don't show loading state - return empty array immediately + placeholderData: emptyAvailabilityData, + } + ); + + // Refetch availability when calendars change + useEffect(() => { + if (connectedCalendars?.connectedCalendars) { + utils.viewer.availability.user.invalidate(); + } + }, [connectedCalendars?.connectedCalendars?.length, utils.viewer.availability.user]); + + // Format events similar to Troubleshooter + // Always return an array, never undefined + const events = useMemo((): CalendarEvent[] => { + if (!busyEvents?.busy) return []; + + return busyEvents.busy.map((event, idx) => { + return { + id: idx, + title: event.title ?? `Busy`, + start: new Date(event.start), + end: new Date(event.end), + options: { + color: event.source ? undefined : undefined, + status: BookingStatus.ACCEPTED, + }, + }; + }); + }, [busyEvents]); + + return { + events, + isLoading: false, // Never show loading state + }; +}; diff --git a/packages/features/calendars/weeklyview/components/DateValues/index.tsx b/packages/features/calendars/weeklyview/components/DateValues/index.tsx index 77e967109a..e7e2c6e712 100644 --- a/packages/features/calendars/weeklyview/components/DateValues/index.tsx +++ b/packages/features/calendars/weeklyview/components/DateValues/index.tsx @@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@calcom/ui/classNames"; +import { useCalendarStore } from "../../state/store"; import type { BorderColor } from "../../types/common"; type Props = { @@ -15,9 +16,38 @@ type Props = { export function DateValues({ showBorder, borderColor, days, containerNavRef }: Props) { const { i18n } = useLocale(); + const timezone = useCalendarStore((state) => state.timezone); + const showTimezone = useCalendarStore((state) => state.showTimezone ?? false); + const formatDate = (date: dayjs.Dayjs): string => { return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate()); }; + + const getTimezoneDisplay = () => { + if (!showTimezone || !timezone) return null; + try { + const timeRaw = dayjs().tz(timezone); + const utcOffsetInMinutes = timeRaw.utcOffset(); + + // Convert offset to decimal hours + const offsetInHours = Math.abs(utcOffsetInMinutes / 60); + const sign = utcOffsetInMinutes < 0 ? "-" : "+"; + + // If offset is 0, just return "GMT" + if (utcOffsetInMinutes === 0) { + return "GMT"; + } + + // Format as decimal (e.g., 1.5 for 1:30, 1 for 1:00) + const offsetFormatted = `${sign}${offsetInHours}`; + + return `GMT ${offsetFormatted}`; + } catch { + // Fallback to showing the timezone name if formatting fails + return timezone.split("/").pop()?.replace(/_/g, " ") || timezone; + } + }; + return (
+ {showTimezone && timezone && ( + {getTimezoneDisplay()} )} - /> +
{days.map((day) => { const isToday = dayjs().isSame(day, "day"); return ( diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts index 5776328b58..57bc2b0fb3 100644 --- a/packages/features/calendars/weeklyview/state/store.ts +++ b/packages/features/calendars/weeklyview/state/store.ts @@ -25,6 +25,7 @@ const defaultState: CalendarComponentProps = { showBackgroundPattern: true, showBorder: true, borderColor: "default", + showTimezone: false, }; export function createCalendarStore(initial?: Partial): StoreApi { diff --git a/packages/features/calendars/weeklyview/types/state.ts b/packages/features/calendars/weeklyview/types/state.ts index 00ebb33fd1..edd5f33d19 100644 --- a/packages/features/calendars/weeklyview/types/state.ts +++ b/packages/features/calendars/weeklyview/types/state.ts @@ -147,6 +147,11 @@ export type CalendarState = { * @default "default" */ borderColor?: BorderColor; + /** + * Show the timezone in the empty space next to the date headers + * @default false + */ + showTimezone?: boolean; }; export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean }; diff --git a/packages/features/shell/DynamicModals.tsx b/packages/features/shell/DynamicModals.tsx index a80f9c7383..8ad9aa6e3f 100644 --- a/packages/features/shell/DynamicModals.tsx +++ b/packages/features/shell/DynamicModals.tsx @@ -2,10 +2,12 @@ import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal"; +import { WelcomeToCalcomModal } from "./components/WelcomeToCalcomModal"; + /** * Container for all query-param driven modals that should appear globally across the app. * This keeps the Shell component clean and provides a centralized place for dynamic modals. - * + * * We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing * * To add a new modal: @@ -16,6 +18,7 @@ export function DynamicModals() { return ( <> + {/* Add more query-param driven modals here */} ); diff --git a/packages/features/shell/components/WelcomeToCalcomModal.tsx b/packages/features/shell/components/WelcomeToCalcomModal.tsx new file mode 100644 index 0000000000..5e2bae6bd8 --- /dev/null +++ b/packages/features/shell/components/WelcomeToCalcomModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent } from "@calcom/ui/components/dialog"; +import { Icon } from "@calcom/ui/components/icon"; +import { Logo } from "@calcom/ui/components/logo"; + +import { useWelcomeToCalcomModal } from "../hooks/useWelcomeToCalcomModal"; + +const features = ["1_user", "unlimited_calendars", "accept_payments_via_stripe"]; + +export function WelcomeToCalcomModal() { + const { t } = useLocale(); + const { isOpen, closeModal } = useWelcomeToCalcomModal(); + + const LARGE = { outer: 48, icon: 24 }; + const RINGS = [60, 95, 130]; // Ring radii in px + const RING_STROKE = 1; + + return ( + !open && closeModal()}> + +
+
+ +
+ + {/* User illustration with rings */} +
+ {/* Center origin */} +
+ {/* Rings */} + {RINGS.map((r, i) => ( +
+ ))} + + {/* Central user icon */} +
+ +
+
+
+ +
+

+ {t("welcome_to_calcom", { appName: APP_NAME })} +

+

{t("personal_welcome_description")}

+
+ +
+ {features.map((feature) => ( +
+ + {t(feature)} +
+ ))} +
+
+ +
+ + +
+ +
+ ); +} diff --git a/packages/features/shell/hooks/useWelcomeToCalcomModal.ts b/packages/features/shell/hooks/useWelcomeToCalcomModal.ts index 1a6faaf78e..cf3b9afef0 100644 --- a/packages/features/shell/hooks/useWelcomeToCalcomModal.ts +++ b/packages/features/shell/hooks/useWelcomeToCalcomModal.ts @@ -1,6 +1,8 @@ import { parseAsBoolean, useQueryState } from "nuqs"; import { useEffect, useState } from "react"; +import { sessionStorage } from "@calcom/lib/webstorage"; + const STORAGE_KEY = "showWelcomeToCalcomModal"; export function useWelcomeToCalcomModal() { @@ -12,14 +14,12 @@ export function useWelcomeToCalcomModal() { const [isOpen, setIsOpen] = useState(false); useEffect(() => { - // Check query param first if (welcomeToCalcomModal) { setIsOpen(true); return; } - // Check sessionStorage as fallback (for cases where we redirect through personal onboarding) - if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") { + if (sessionStorage.getItem(STORAGE_KEY) === "true") { setIsOpen(true); } }, [welcomeToCalcomModal]); @@ -29,9 +29,7 @@ export function useWelcomeToCalcomModal() { // Remove the query param from URL setWelcomeToCalcomModal(null); // Also clear sessionStorage - if (typeof window !== "undefined") { - sessionStorage.removeItem(STORAGE_KEY); - } + sessionStorage.removeItem(STORAGE_KEY); }; return { @@ -40,12 +38,6 @@ export function useWelcomeToCalcomModal() { }; } -/** - * Helper function to set the flag that triggers the welcome modal. - * Use this before redirecting to ensure the modal shows after navigation. - */ export function setShowWelcomeToCalcomModalFlag() { - if (typeof window !== "undefined") { - sessionStorage.setItem(STORAGE_KEY, "true"); - } + sessionStorage.setItem(STORAGE_KEY, "true"); }