chore: Add calendar weekly view enhancements and welcome modal feature (#24948)
## What does this PR do? - Adds a welcome modal for new Cal.com users - Implements timezone display in the weekly calendar view - Creates a hook for fetching onboarding calendar events ## Visual Demo (For contributors especially) #### Image Demo:   ## Mandatory Tasks - [x] I have self-reviewed the code - [x] I have updated the developer docs in /docs - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. **Welcome Modal:** - Create a new user account - Verify the welcome modal appears with correct content - Test the "Continue" button closes the modal - Check that the modal can be triggered via URL parameter `?welcomeToCalcomModal=true` 2. **Timezone Display:** - Go to the weekly calendar view - Verify the timezone is displayed correctly when `showTimezone` is enabled - Test with different timezones to ensure proper formatting 3. **Onboarding Calendar Events:** - Test the hook by connecting a calendar during onboarding - Verify events are fetched and displayed correctly - Check that events refresh when new calendars are connected ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={containerNavRef}
|
||||
@@ -49,11 +79,14 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
|
||||
<div className="text-subtle -mr-px hidden auto-cols-fr leading-6 sm:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"col-end-1 w-16",
|
||||
"col-end-1 flex w-16 items-center justify-center",
|
||||
showBorder &&
|
||||
(borderColor === "subtle" ? "border-l-subtle border-l" : "border-l-default border-l")
|
||||
)}>
|
||||
{showTimezone && timezone && (
|
||||
<span className="text-muted text-xs font-medium">{getTimezoneDisplay()}</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ const defaultState: CalendarComponentProps = {
|
||||
showBackgroundPattern: true,
|
||||
showBorder: true,
|
||||
borderColor: "default",
|
||||
showTimezone: false,
|
||||
};
|
||||
|
||||
export function createCalendarStore(initial?: Partial<CalendarComponentProps>): StoreApi<CalendarStoreProps> {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<WelcomeToOrganizationsModal />
|
||||
<WelcomeToCalcomModal />
|
||||
{/* Add more query-param driven modals here */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
{/* User illustration with rings */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{
|
||||
width: 320,
|
||||
height: 220,
|
||||
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
}}>
|
||||
{/* Center origin */}
|
||||
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
|
||||
{/* Rings */}
|
||||
{RINGS.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pointer-events-none absolute rounded-full border"
|
||||
style={{
|
||||
width: 2 * r,
|
||||
height: 2 * r,
|
||||
left: `calc(50% - ${r}px)`,
|
||||
top: `calc(50% - ${r}px)`,
|
||||
borderWidth: RING_STROKE,
|
||||
borderColor: "var(--cal-border-subtle)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Central user icon */}
|
||||
<div
|
||||
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||
style={{
|
||||
width: LARGE.outer,
|
||||
height: LARGE.outer,
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}>
|
||||
<Icon
|
||||
name="user"
|
||||
className="text-emphasis opacity-70"
|
||||
style={{ width: LARGE.icon, height: LARGE.icon }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-cal text-emphasis text-2xl leading-none">
|
||||
{t("welcome_to_calcom", { appName: APP_NAME })}
|
||||
</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("personal_welcome_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2">
|
||||
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button
|
||||
color="minimal"
|
||||
href="https://cal.com/docs"
|
||||
target="_blank"
|
||||
EndIcon="external-link"
|
||||
className="pointer-events-none opacity-0">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={closeModal}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user