Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 |
@@ -27,8 +27,24 @@ jobs:
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
# Define authorized users and their emails using organization secret
|
||||
# Expected format: JSON object with username -> email mapping
|
||||
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||
|
||||
# Extract email for the triggering user
|
||||
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||
|
||||
# Fail if user is not authorized or not in mapping
|
||||
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||
echo "Only authorized team members can trigger releases."
|
||||
echo "Contact your administrator to be added to the authorized users list."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config --local user.email "$USER_EMAIL"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||
steps:
|
||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||
id: check-if-pr-has-label
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.9",
|
||||
"next": "^15.3.0",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Team } from "@calcom/prisma/client";
|
||||
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export interface UserContext {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
organizationId?: number;
|
||||
teamId?: number;
|
||||
organizationId?: string;
|
||||
teamId?: string;
|
||||
userOrgId?: string;
|
||||
}
|
||||
|
||||
export function extractUserContext(request: Request): UserContext {
|
||||
@@ -16,18 +17,21 @@ export function extractUserContext(request: Request): UserContext {
|
||||
|
||||
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||
if (user) {
|
||||
context.userId = user.id;
|
||||
context.userId = String(user.id);
|
||||
context.userEmail = user.email;
|
||||
if (user.profiles?.[0]?.organizationId) {
|
||||
context.userOrgId = String(user.profiles?.[0].organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
const organizationId = (request as any).organizationId as number | undefined;
|
||||
if (organizationId) {
|
||||
context.organizationId = organizationId;
|
||||
context.organizationId = String(organizationId);
|
||||
}
|
||||
|
||||
const team = (request as any).team as Team | undefined;
|
||||
if (team) {
|
||||
context.teamId = team.id;
|
||||
context.teamId = String(team.id);
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
@@ -9,6 +9,7 @@ import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
@@ -118,6 +119,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
@@ -465,7 +467,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
const isCalVideoLocation =
|
||||
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "";
|
||||
|
||||
const videoOptionsActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
@@ -475,6 +480,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
color: showCheckRecordingButton ? "secondary" : "primary",
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
{
|
||||
id: "meeting_session_details",
|
||||
label: t("get_meeting_session_details"),
|
||||
onClick: () => {
|
||||
setMeetingSessionDetailsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
@@ -516,7 +529,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
@@ -524,6 +537,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
@@ -714,8 +735,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
) : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
{isCalVideoLocation && (
|
||||
<TableActions
|
||||
actions={[{ id: "video_options", label: t("video_options"), actions: videoOptionsActions }]}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.12",
|
||||
"version": "5.4.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -48,6 +48,21 @@
|
||||
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"video_options": "Video Options",
|
||||
"get_meeting_session_details": "Get Meeting Session Details",
|
||||
"meeting_session_details": "Meeting Session Details",
|
||||
"meeting_session": "Meeting Session",
|
||||
"session_id": "Session ID",
|
||||
"max_participants": "Max Participants",
|
||||
"unlimited": "Unlimited",
|
||||
"ongoing": "Ongoing",
|
||||
"ended": "Ended",
|
||||
"participants": "Participants",
|
||||
"anonymous_user": "Anonymous User",
|
||||
"session_duration": "Session Duration",
|
||||
"minutes": "minutes",
|
||||
"no_meeting_sessions_found": "No meeting sessions found",
|
||||
"not_available": "Not available",
|
||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||
"disable_transcription_for_guests": "Disable transcription for guests",
|
||||
"disable_transcription_for_organizer": "Disable transcription for organizer",
|
||||
|
||||
@@ -2,15 +2,34 @@ import { oauth2_v2 } from "@googleapis/oauth2";
|
||||
import type { OAuth2Client } from "googleapis-common";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
|
||||
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
|
||||
try {
|
||||
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
|
||||
const userDetails = await oauth2.userinfo.get();
|
||||
if (userDetails.data?.picture) {
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
|
||||
const avatarUrl = userDetails.data?.picture;
|
||||
if (!avatarUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle base64 data
|
||||
if (
|
||||
avatarUrl.startsWith("data:image/png;base64,") ||
|
||||
avatarUrl.startsWith("data:image/jpeg;base64,") ||
|
||||
avatarUrl.startsWith("data:image/jpg;base64,")
|
||||
) {
|
||||
const resizedAvatarUrl = await uploadAvatar({
|
||||
avatar: await resizeBase64Image(avatarUrl),
|
||||
userId,
|
||||
});
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl });
|
||||
} catch (error) {
|
||||
logger.error("Error updating avatarUrl from google calendar connect", error);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,28 @@ import {
|
||||
ZGetMeetingTokenResponseSchema,
|
||||
} from "./types";
|
||||
|
||||
const meetingParticipantSchema = z.object({
|
||||
user_id: z.string().nullable(),
|
||||
participant_id: z.string(),
|
||||
user_name: z.string().nullable(),
|
||||
join_time: z.number(),
|
||||
duration: z.number(),
|
||||
});
|
||||
|
||||
const meetingSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
room: z.string(),
|
||||
start_time: z.number(),
|
||||
duration: z.number(),
|
||||
ongoing: z.boolean(),
|
||||
max_participants: z.number(),
|
||||
participants: z.array(meetingParticipantSchema),
|
||||
});
|
||||
|
||||
const getMeetingInformationResponseSchema = z.object({
|
||||
data: z.array(meetingSessionSchema),
|
||||
});
|
||||
|
||||
export interface DailyEventResult {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -501,6 +523,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
throw new Error(`Something went wrong! Unable to checkIfRoomNameMatchesInRecording. ${err}`);
|
||||
}
|
||||
},
|
||||
getMeetingInformation: async (roomName: string) => {
|
||||
try {
|
||||
const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then(
|
||||
getMeetingInformationResponseSchema.parse
|
||||
);
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error("err", err);
|
||||
throw new Error("Something went wrong! Unable to get meeting information");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
SCOPE_USERINFO_PROFILE,
|
||||
WEBAPP_URL,
|
||||
WEBAPP_URL_FOR_OAUTH,
|
||||
} from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
@@ -14,6 +19,7 @@ import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
import { updateProfilePhotoGoogle } from "../../_utils/oauth/updateProfilePhotoGoogle";
|
||||
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
|
||||
|
||||
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -95,10 +101,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||
// TODO: Use the avatarUrl when setting the profile picture
|
||||
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
// }
|
||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
}
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RetryableError } from "@calcom/lib/crmManager/errors";
|
||||
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
@@ -1244,6 +1245,16 @@ export default class SalesforceCRMService implements CRM {
|
||||
return;
|
||||
}
|
||||
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid);
|
||||
} else if (fieldValue === "{assignmentReason}") {
|
||||
if (!bookingUid) {
|
||||
log.error(`BookingUid not passed. Cannot get assignment reason without it`);
|
||||
return;
|
||||
}
|
||||
valueToWrite = await this.getAssignmentReason(bookingUid);
|
||||
if (!valueToWrite) {
|
||||
log.error(`No assignment reason found for bookingUid ${bookingUid}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Get the value from the booking response
|
||||
if (!calEventResponses) {
|
||||
@@ -1336,6 +1347,11 @@ export default class SalesforceCRMService implements CRM {
|
||||
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
|
||||
}
|
||||
|
||||
private async getAssignmentReason(bookingId: string) {
|
||||
const assignmentReason = await AssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
|
||||
return assignmentReason?.reasonString ?? "";
|
||||
}
|
||||
|
||||
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
|
||||
const regexValueToReplace = /\{(.*?)\}/g;
|
||||
return fieldValue.replace(regexValueToReplace, (match, captured) => {
|
||||
|
||||
+52
-13
@@ -166,8 +166,8 @@ For example, if your page is `https.example.com/contact`, you can enable logging
|
||||
#### Routing Prerendering System
|
||||
The prerendering system optimizes the initial load:
|
||||
```typescript
|
||||
Cal.prerender({
|
||||
calLink: "organization/event-type",
|
||||
Cal("prerender", {
|
||||
calLink,
|
||||
type: "modal"
|
||||
});
|
||||
```
|
||||
@@ -178,24 +178,63 @@ Key aspects:
|
||||
- Loads the booking page but doesn't send the slots availability request
|
||||
- Tries to reuse whenever it makes sense and do a fresh load otherwise
|
||||
|
||||
Iframe Reuse and Reload Conditions. There could be three situations:
|
||||
Modal's Iframe Reuse and Reload Conditions. There could be four situations:
|
||||
|
||||
1. Reuse
|
||||
2. Reuse the iframe but refetch the slots
|
||||
3. Do a fresh load in iframe
|
||||
1. Show modal as is. No connect and no requests happen - Corresponding action is "noAction"
|
||||
2. Connect but don't fetch the slots- Corresponding action is "connect-no-slots-fetch"
|
||||
3. Connect and fetch the slots - Corresponding action is "connect"
|
||||
4. Do a fresh load in iframe. Corresponding action is "fullReload"
|
||||
|
||||
- **Reuse**:
|
||||
- Modal opens when
|
||||
- Modal is not in a failed state
|
||||
- config, params are same as the last time
|
||||
- No threshold violations
|
||||
- **Show modal as is**:
|
||||
- Modal is not in a failed state
|
||||
- config, params are same as the last time
|
||||
- No time threshold violations
|
||||
|
||||
- **Reuse the iframe but refetch the slots**:
|
||||
- **Connect but don't fetch the slots**:
|
||||
- Only embed `config` changes (handled via "connect" flow)
|
||||
- Query query params changes (handled via "connect" flow)
|
||||
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
|
||||
|
||||
- **Fresh Reload Conditions**:
|
||||
- **Connect and fetch the slots**:
|
||||
- Slots are stale
|
||||
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
|
||||
|
||||
- **Do a fresh load in iframe**:
|
||||
- Different path being loaded(i.e. /pro vs /free)
|
||||
- Modal is in a failed state
|
||||
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
|
||||
|
||||
|
||||
#### Prerendering headless router
|
||||
|
||||
**Without namespace:**
|
||||
Prerender when there are high chances of user clicking the CTA.
|
||||
```js
|
||||
Cal('prerender', {
|
||||
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
|
||||
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
|
||||
type: "modal",
|
||||
// Shows skeleton loader for a Team Event's booking slots page
|
||||
pageType: "team.event.booking.slots"
|
||||
});
|
||||
```
|
||||
Using the prerendered iframe with a CTA:
|
||||
```js
|
||||
<button data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
|
||||
```
|
||||
|
||||
**With namespace:**
|
||||
Prerender when there are high changes of user clicking the CTA.
|
||||
```js
|
||||
Cal.ns.myNamespace('prerender', {
|
||||
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
|
||||
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
|
||||
type: "modal"
|
||||
// Shows skeleton loader for a Team Event's booking slots page
|
||||
pageType: "team.event.booking.slots"
|
||||
});
|
||||
```
|
||||
Using the prerendered iframe with a CTA:
|
||||
```js
|
||||
<button data-cal-namespace="myNamespace" data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
|
||||
```
|
||||
@@ -4,6 +4,8 @@ export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute
|
||||
// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs
|
||||
// Also full reload means that response would be resubmitted.
|
||||
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||
// Ensures that prerender instruction would never re-prerender within one minute as long as the link is the same.
|
||||
export const EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
|
||||
throw new Error(
|
||||
|
||||
@@ -5,22 +5,13 @@ import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest";
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import { submitResponseAndGetRoutingResult } from "./utils";
|
||||
|
||||
vi.mock("./tailwindCss", () => ({
|
||||
default: "mockedTailwindCss",
|
||||
}));
|
||||
|
||||
vi.mock("./utils", async () => {
|
||||
const actual = (await vi.importActual("./utils")) as any;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
submitResponseAndGetRoutingResult: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type ExpectedModalBoxAttrs = {
|
||||
theme: string;
|
||||
layout: string;
|
||||
@@ -335,6 +326,7 @@ describe("Cal", () => {
|
||||
__prerender: true,
|
||||
};
|
||||
calInstance.api.modal(modalArg);
|
||||
expect(calInstance.isPrerendering).toBe(true);
|
||||
|
||||
const modalBox = expectCalModalBoxToBeInDocument({
|
||||
state: "prerendering",
|
||||
@@ -399,11 +391,82 @@ describe("Cal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Accidental repeat prerender prevention", async () => {
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
};
|
||||
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
const embedRenderStartTime = calInstance.embedRenderStartTime;
|
||||
|
||||
const { status: status2 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status2).toBe("prerender-prevented");
|
||||
// embedRenderStartTime should not be updated prerender did not happen
|
||||
expect(calInstance.embedRenderStartTime).toBe(embedRenderStartTime);
|
||||
});
|
||||
|
||||
it("should allow repeat prerender after threshold time has passed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
};
|
||||
|
||||
// First prerender
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
|
||||
// Mock time passage beyond threshold
|
||||
vi.advanceTimersByTime(EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS + 1000);
|
||||
|
||||
// Second prerender after threshold - should be ALLOWED
|
||||
const { status: status2 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
|
||||
expect(status2).toBe("created");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Prerender with non-headless router link and then CTA click with headless router is not allowed", async () => {
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "john-doe/meeting",
|
||||
};
|
||||
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
|
||||
await expect(
|
||||
calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
})
|
||||
).rejects.toThrow("`prerender` instruction should have been fired with headless router path");
|
||||
});
|
||||
|
||||
describe("Modal State Transitions", () => {
|
||||
it(`should handle prerender -> open(with prefill) -> reopen scenario`, () => {
|
||||
it(`should handle prerender -> open(with prefill) -> reopen(with re-submission) scenario`, () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=FORM_ID&routingField1=value1&routingField2=value2",
|
||||
};
|
||||
|
||||
const { modalBoxUid, expectedConfig } = (function prerender(): {
|
||||
@@ -412,6 +475,7 @@ describe("Cal", () => {
|
||||
} {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
expect(calInstance.isPrerendering).toBe(true);
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
@@ -425,11 +489,14 @@ describe("Cal", () => {
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
form: "FORM_ID",
|
||||
"cal.queueFormResponse": "true",
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
@@ -439,138 +506,14 @@ describe("Cal", () => {
|
||||
return { modalBoxUid, expectedConfig };
|
||||
})();
|
||||
|
||||
const { modalArgWithPrefilledConfig } = (function prefill() {
|
||||
log("Opening the modal with prefill config");
|
||||
const modalArgWithPrefilledConfig = {
|
||||
...modalArg,
|
||||
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
|
||||
};
|
||||
// Second modal call with additional config value for prefilling
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
const expectedConfigAfterPrefilling = {
|
||||
...expectedConfig,
|
||||
name: modalArgWithPrefilledConfig.config.name,
|
||||
email: modalArgWithPrefilledConfig.config.email,
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to go to loading state - which starts the loader animation
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because internally we remove prerender=true in iframe, as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// This would update the iframe document URL through embed-iframe.
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(calInstance.doInIframe).mockClear();
|
||||
return { modalArgWithPrefilledConfig };
|
||||
})();
|
||||
|
||||
(function reopen() {
|
||||
log("Reopening the same modal without any changes");
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
// Connect won't happen again
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to just go to "reopened" state
|
||||
state: "reopened",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because we are just reopening the same modal
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
|
||||
it(`should handle prerender -> open(with prefill) -> error -> loading scenario`, () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
};
|
||||
|
||||
const { modalBoxUid, modalBox, expectedConfig } = (function prerender() {
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
const { modalBox } = expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
const modalBoxUid = modalBox.getAttribute("uid");
|
||||
return { modalBoxUid, modalBox, expectedConfig };
|
||||
})();
|
||||
|
||||
const { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling } = (function prefill() {
|
||||
log("Opening the modal with prefill config");
|
||||
const modalArgWithPrefilledConfig = {
|
||||
...modalArg,
|
||||
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
|
||||
};
|
||||
|
||||
// Second modal call with additional config value for prefilling
|
||||
calInstance.api.modal({ ...buildModalArg(modalArgWithPrefilledConfig) });
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
const expectedConfigAfterPrefilling = {
|
||||
...expectedConfig,
|
||||
@@ -588,24 +531,33 @@ describe("Cal", () => {
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because internally we remove prerender=true in iframe - through connect flow
|
||||
// It remains because internally we remove prerender=true in iframe, as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
form: "FORM_ID",
|
||||
"cal.queueFormResponse": "true",
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Right now we allow re-submission of the same form data, for which we need to connect again
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
params: {
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
form: "FORM_ID",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -613,412 +565,52 @@ describe("Cal", () => {
|
||||
return { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling };
|
||||
})();
|
||||
|
||||
log("Simulating an error state");
|
||||
modalBox.setAttribute("state", "failed");
|
||||
|
||||
(function reopen() {
|
||||
log("Reopening the same modal but it is in error state, so full page load would be initiated");
|
||||
log("Reopening the same modal without any changes");
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
expect(calInstance.isPrerendering).toBe(false);
|
||||
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to just go to "reopened" state
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfigAfterPrefilling,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> reuse booking link ", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}/${modalArg.calLink}`,
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the routing form headless");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
// State must immediately change to loading, so that loader is shown
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...config,
|
||||
// It remains because internally we remove prerender=true in iframe as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
// Connect won't happen again
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...configWithPrefilledValues,
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
embedType: "modal",
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
form: "FORM_ID",
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> different booking link redirect -> fresh page load", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
// expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
// expectedModalBoxAttrs: {
|
||||
// // Expect the modal to just go to "reopened" state
|
||||
// state: "reopened",
|
||||
// theme: modalArg.config.theme,
|
||||
// layout: modalArg.config.layout,
|
||||
// pageType: null,
|
||||
// uid: modalBoxUid,
|
||||
// },
|
||||
// expectedIframeUrlObject: {
|
||||
// pathname: `/${modalArg.calLink}`,
|
||||
// searchParams: new URLSearchParams({
|
||||
// ...expectedConfig,
|
||||
// // It remains because we are just reopening the same modal
|
||||
// prerender: "true",
|
||||
// "cal.skipSlotsFetch": "true",
|
||||
// }),
|
||||
// origin: null,
|
||||
// },
|
||||
// });
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
const headlessRouterRedirectUrlWithPathOnly = `/something_else_than_prerendered_link`;
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}${headlessRouterRedirectUrlWithPathOnly}?routingFormResponseId=xyz`,
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
const configWithPrefilledValuesAndHeadlessRouterRedirectParams = {
|
||||
...configWithPrefilledValues,
|
||||
routingFormResponseId: "xyz",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: headlessRouterRedirectUrlWithPathOnly,
|
||||
searchParams: new URLSearchParams({
|
||||
...configWithPrefilledValuesAndHeadlessRouterRedirectParams,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> Message ", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
status: "success",
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
message: "Fallback Route Message",
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "has-message",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Message");
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> Error Message", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
error: "Fallback Route Error",
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "failed",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
expect(calModalBox.getAttribute("data-error-code")).toBe("routerError");
|
||||
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Error");
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,21 +6,22 @@ import { addAppCssVars } from "./addAppCssVars";
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
|
||||
import css from "./embed.css";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||
import tailwindCss from "./tailwindCss";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
|
||||
import { getMaxHeightForModal } from "./ui-utils";
|
||||
import {
|
||||
fromEntriesWithDuplicateKeys,
|
||||
isRouterPath,
|
||||
submitResponseAndGetRoutingResult,
|
||||
generateDataAttributes,
|
||||
getConfigProp,
|
||||
isSameBookingLink,
|
||||
buildConfigWithPrerenderRelatedFields,
|
||||
} from "./utils";
|
||||
|
||||
// Exporting for consumption by @calcom/embed-core user
|
||||
@@ -51,12 +52,8 @@ type CalConfig = {
|
||||
uiDebug?: boolean;
|
||||
};
|
||||
|
||||
type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
reuseFully?: boolean;
|
||||
// Default value is true right now but it would change soon
|
||||
backgroundSlotsFetch?: boolean;
|
||||
type ModalBoxElement = Element & {
|
||||
uid?: string;
|
||||
};
|
||||
|
||||
type ModalStateData = {
|
||||
@@ -221,7 +218,7 @@ export class Cal {
|
||||
|
||||
__config: CalConfig;
|
||||
|
||||
modalBox?: Element;
|
||||
modalBox?: ModalBoxElement;
|
||||
|
||||
inlineEl?: Element;
|
||||
|
||||
@@ -688,89 +685,128 @@ export class Cal {
|
||||
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
|
||||
}
|
||||
|
||||
async submitThroughHeadlessRouterInModal({
|
||||
modal,
|
||||
calLinkUrlObject,
|
||||
stateData,
|
||||
}: {
|
||||
modal: { uid: string; element: Element; calOrigin: string | null };
|
||||
calLinkUrlObject: URL;
|
||||
stateData: ModalStateData;
|
||||
}) {
|
||||
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
|
||||
const { embedConfig } = stateData;
|
||||
// We record the updated params on dataset.calLink which allows us to determine if something actually changed since the last time we loaded
|
||||
// This is used by getNextActionForModal
|
||||
recordUpdatedParamsForIframe(params: Record<string, string | string[]>) {
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
|
||||
const calConfig = this.getCalConfig();
|
||||
const calOrigin = _calOrigin ?? calConfig.calOrigin;
|
||||
|
||||
const headlessRouterPageObject = calLinkUrlObject;
|
||||
const result = await submitResponseAndGetRoutingResult({
|
||||
headlessRouterPageUrl: headlessRouterPageObject.toString(),
|
||||
});
|
||||
log("Headless router result", result);
|
||||
if ("redirect" in result) {
|
||||
const routerRedirectUrl = new URL(result.redirect);
|
||||
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
|
||||
|
||||
const newEmbedConfig = (this.embedConfig = {
|
||||
...embedConfig,
|
||||
...paramsFromRedirect,
|
||||
});
|
||||
const actionToTake = this.getNextActionForModal({
|
||||
modal: { uid: modalBoxUid },
|
||||
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
|
||||
stateData: {
|
||||
...stateData,
|
||||
embedConfig: newEmbedConfig,
|
||||
},
|
||||
});
|
||||
if (actionToTake === "fullReload") {
|
||||
if (lastLoadedPathInIframe) {
|
||||
console.error("Preloaded iframe couldn't be used", {
|
||||
preloadedPath: lastLoadedPathInIframe,
|
||||
newPath: routerRedirectUrl.pathname,
|
||||
});
|
||||
}
|
||||
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
|
||||
this.loadInIframe({
|
||||
calLink: pathWithoutStartingSlash,
|
||||
calOrigin,
|
||||
config: newEmbedConfig,
|
||||
iframe: this.iframe as HTMLIFrameElement,
|
||||
});
|
||||
} else {
|
||||
log("Reusing the same iframe for headless router", {
|
||||
routerRedirectUrl: routerRedirectUrl.toString(),
|
||||
lastLoadedPathInIframe,
|
||||
});
|
||||
|
||||
// Connection Initiated
|
||||
this.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: newEmbedConfig,
|
||||
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if ("message" in result) {
|
||||
log("Setting message in modal", {
|
||||
message: result.message,
|
||||
});
|
||||
// TODO: We might need to sanitize the error message and error code before setting it
|
||||
modalEl.setAttribute("data-message", result.message);
|
||||
modalEl.setAttribute("state", "has-message");
|
||||
} else if ("error" in result) {
|
||||
log("Setting error in modal", {
|
||||
error: result.error,
|
||||
});
|
||||
// We need to show this message in the modal
|
||||
modalEl.setAttribute("data-error-code", "routerError");
|
||||
// TODO: We might need to sanitize the error message and error code before setting it
|
||||
modalEl.setAttribute("data-message", result.error);
|
||||
modalEl.setAttribute("state", "failed");
|
||||
if (!lastLoadedUrlInIframeObject || !this.iframe) {
|
||||
return;
|
||||
}
|
||||
// Create a clone of the last loaded url object in iframe
|
||||
const urlObject = new URL(lastLoadedUrlInIframeObject.toString());
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
urlObject.searchParams.set(key, value as string);
|
||||
}
|
||||
this.iframe.dataset.calLink = `${urlObject.pathname.replace(/^\//, "")}${urlObject.search}`;
|
||||
}
|
||||
|
||||
canPrerenderLink({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
}: {
|
||||
calLink: string;
|
||||
calOrigin: string;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
}) {
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
// Trying to prerender a link that is already prerendered.
|
||||
// Prevent unnecessary repeat prerenders
|
||||
if (lastLoadedUrlInIframeObject?.toString() === new URL(calLink, calOrigin).toString()) {
|
||||
const isThresholdCrossed = hasCrossedThreshold();
|
||||
if (isThresholdCrossed) {
|
||||
log("Threshold crossed, allowing repeat prerender");
|
||||
return true;
|
||||
}
|
||||
log("Threshold not crossed, preventing repeat prerender");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
function hasCrossedThreshold() {
|
||||
if (!previousEmbedRenderStartTime) {
|
||||
return false;
|
||||
}
|
||||
const timeDiff = Date.now() - previousEmbedRenderStartTime;
|
||||
if (timeDiff >= EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
prepareForPrerender({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
prerenderOptions,
|
||||
isHeadlessRouterPath,
|
||||
}: {
|
||||
calLink: string;
|
||||
calOrigin: string;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
prerenderOptions: ModalPrerenderOptions;
|
||||
isHeadlessRouterPath: boolean;
|
||||
}) {
|
||||
if (!this.canPrerenderLink({ calLink, calOrigin, previousEmbedRenderStartTime })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.modalBox.remove();
|
||||
}
|
||||
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
const enrichedPrerenderOptions = { ...prerenderOptions, backgroundSlotsFetch };
|
||||
|
||||
// Persist prerender options here so that during CTA click we can use these.
|
||||
this.api.prerenderOptions = enrichedPrerenderOptions;
|
||||
this.isPrerendering = true;
|
||||
|
||||
return { enrichedPrerenderOptions };
|
||||
}
|
||||
|
||||
setModalRenderStartVariables({
|
||||
embedConfig,
|
||||
embedRenderStartTime,
|
||||
}: {
|
||||
embedConfig: PrefillAndIframeAttrsConfig;
|
||||
embedRenderStartTime: number;
|
||||
}) {
|
||||
this.embedConfig = embedConfig;
|
||||
this.embedRenderStartTime = embedRenderStartTime;
|
||||
}
|
||||
|
||||
getPreviousModalRenderStartVariables() {
|
||||
return {
|
||||
embedConfig: this.embedConfig,
|
||||
embedRenderStartTime: this.embedRenderStartTime,
|
||||
};
|
||||
}
|
||||
|
||||
connect({
|
||||
config,
|
||||
params,
|
||||
}: {
|
||||
config: PrefillAndIframeAttrsConfig;
|
||||
params: Record<string, string | string[]>;
|
||||
}) {
|
||||
// Update the iframe query params and record the change as well on the iframe for getNextActionForModal to use
|
||||
this.recordUpdatedParamsForIframe(params);
|
||||
this.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,56 +1015,24 @@ class CalApi {
|
||||
__prerender?: boolean;
|
||||
prerenderOptions?: ModalPrerenderOptions;
|
||||
}) {
|
||||
const isPrerendering = !!__prerender;
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
const containerEl = document.body;
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
// Clone so that it doesn't mutate the original config
|
||||
config = { ...config };
|
||||
|
||||
// calOrigin could have been passed as empty string by the user
|
||||
calOrigin = calOrigin || calConfig.calOrigin;
|
||||
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
const { embedConfig: previousEmbedConfig, embedRenderStartTime: previousEmbedRenderStartTime } =
|
||||
this.cal.getPreviousModalRenderStartVariables();
|
||||
|
||||
if (__prerender && this.cal.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.cal.modalBox.remove();
|
||||
}
|
||||
|
||||
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
|
||||
// `this.prerenderedModalUid` is set for a modal created through "prerender"
|
||||
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
|
||||
// Means whether there is already an attempt to use the prerendered modal
|
||||
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
|
||||
|
||||
const containerEl = document.body;
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
if (__prerender) {
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
|
||||
this.prerenderOptions = {
|
||||
...prerenderOptions,
|
||||
backgroundSlotsFetch,
|
||||
};
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
|
||||
// If we are prerendering a headless router path, we don't want to record the response immediately.
|
||||
if (isHeadlessRouterPath) {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
let enrichedConfig;
|
||||
const configWithGuestKeyAndColorScheme = withColorScheme(
|
||||
Cal.ensureGuestKey({
|
||||
...config,
|
||||
@@ -1037,41 +1041,99 @@ class CalApi {
|
||||
containerEl
|
||||
);
|
||||
|
||||
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
|
||||
const previousEmbedConfig = this.cal.embedConfig;
|
||||
let enrichedPrerenderOptions;
|
||||
if (isPrerendering) {
|
||||
const preparationResult = this.cal.prepareForPrerender({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
prerenderOptions,
|
||||
isHeadlessRouterPath,
|
||||
});
|
||||
|
||||
if (!preparationResult) {
|
||||
log(`Prevented unnecessary repeat prerender for ${calLink}`);
|
||||
return {
|
||||
status: "prerender-prevented",
|
||||
};
|
||||
}
|
||||
|
||||
enrichedPrerenderOptions = preparationResult.enrichedPrerenderOptions;
|
||||
enrichedConfig = buildConfigWithPrerenderRelatedFields({
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
isHeadlessRouterPath,
|
||||
backgroundSlotsFetch: !!this.prerenderOptions?.backgroundSlotsFetch,
|
||||
});
|
||||
} else {
|
||||
this.cal.isPrerendering = false;
|
||||
enrichedConfig = configWithGuestKeyAndColorScheme;
|
||||
}
|
||||
|
||||
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
|
||||
// `this.prerenderedModalUid` is set for a modal created through "prerender"
|
||||
const reusableModalUid = this.modalUid || this.prerenderedModalUid;
|
||||
const uid = reusableModalUid || String(Date.now());
|
||||
// Means whether there is already an attempt to use the prerendered modal
|
||||
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
|
||||
const embedRenderStartTime = Date.now();
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
|
||||
|
||||
const stateData = {
|
||||
embedConfig: enrichedConfig,
|
||||
previousEmbedConfig,
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
// If prerenderOptions are available, use them. Otherwise use the prerenderOptions from the previous call to modal for prerender
|
||||
prerenderOptions: enrichedPrerenderOptions ?? this.prerenderOptions ?? null,
|
||||
};
|
||||
|
||||
this.cal.setModalRenderStartVariables({
|
||||
embedConfig: enrichedConfig,
|
||||
embedRenderStartTime,
|
||||
});
|
||||
|
||||
return this.__modal({
|
||||
calLink,
|
||||
enrichedConfig,
|
||||
calOrigin,
|
||||
isPrerendering,
|
||||
stateData,
|
||||
uid,
|
||||
containerEl,
|
||||
});
|
||||
}
|
||||
|
||||
async __modal({
|
||||
uid,
|
||||
calLink,
|
||||
enrichedConfig,
|
||||
calOrigin,
|
||||
isPrerendering,
|
||||
stateData,
|
||||
containerEl,
|
||||
}: {
|
||||
uid: string;
|
||||
calLink: string;
|
||||
enrichedConfig: PrefillAndIframeAttrsConfig;
|
||||
calOrigin: string;
|
||||
isPrerendering: boolean;
|
||||
stateData: ModalStateData;
|
||||
containerEl: HTMLElement;
|
||||
}) {
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
|
||||
// isConnectionPossible
|
||||
if (!!existingModalEl && !!this.cal.iframe) {
|
||||
log(`Trying to reuse modal ${uid}`);
|
||||
const stateData = {
|
||||
embedConfig: configWithGuestKeyAndColorScheme,
|
||||
previousEmbedConfig,
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
prerenderOptions: this.prerenderOptions ?? null,
|
||||
};
|
||||
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
|
||||
|
||||
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
|
||||
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
|
||||
await this.cal.submitThroughHeadlessRouterInModal({
|
||||
modal: { uid, element: existingModalEl, calOrigin },
|
||||
calLinkUrlObject,
|
||||
stateData,
|
||||
});
|
||||
throw new Error(
|
||||
"`prerender` instruction should have been fired with headless router path as calLink(i.e. router?form={FORM_ID}&PARAMS=VALUES)"
|
||||
);
|
||||
} else {
|
||||
const actionToTake = this.cal.getNextActionForModal({
|
||||
modal: { uid },
|
||||
@@ -1080,7 +1142,7 @@ class CalApi {
|
||||
});
|
||||
|
||||
if (actionToTake === "noAction") {
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
// A prerender instruction can never show the modal
|
||||
return;
|
||||
}
|
||||
@@ -1100,22 +1162,20 @@ class CalApi {
|
||||
calLink,
|
||||
calOrigin,
|
||||
iframe: this.cal.iframe,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
config: enrichedConfig,
|
||||
});
|
||||
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...configWithGuestKeyAndColorScheme,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
|
||||
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
|
||||
this.cal.connect({
|
||||
config: {
|
||||
...enrichedConfig,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: paramsToAdd,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1127,23 +1187,19 @@ class CalApi {
|
||||
|
||||
log(`Creating new modal ${uid}`);
|
||||
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
this.prerenderedModalUid = uid;
|
||||
} else {
|
||||
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
|
||||
// this.modalUid = uid;
|
||||
}
|
||||
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
|
||||
let iframe = null;
|
||||
|
||||
if (!iframe) {
|
||||
iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
config: enrichedConfig,
|
||||
calOrigin,
|
||||
});
|
||||
}
|
||||
@@ -1152,9 +1208,9 @@ class CalApi {
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const template = document.createElement("template");
|
||||
const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType");
|
||||
const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme");
|
||||
const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout");
|
||||
const pageType = getConfigProp(enrichedConfig, "cal.embed.pageType");
|
||||
const theme = getConfigProp(enrichedConfig, "theme");
|
||||
const layout = getConfigProp(enrichedConfig, "layout");
|
||||
|
||||
template.innerHTML = `<cal-modal-box
|
||||
${generateDataAttributes({
|
||||
@@ -1169,11 +1225,14 @@ class CalApi {
|
||||
// Set state through setAttribute so that onAttributeChangedCallback is triggered
|
||||
this.cal.modalBox.setAttribute("state", "loading");
|
||||
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||
}
|
||||
this.handleClose();
|
||||
containerEl.appendChild(template.content);
|
||||
return {
|
||||
status: "created",
|
||||
};
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
|
||||
@@ -102,3 +102,9 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
|
||||
|
||||
export type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
export type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
|
||||
export type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
backgroundSlotsFetch?: boolean;
|
||||
};
|
||||
|
||||
@@ -196,3 +196,32 @@ export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig)
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export function buildConfigWithPrerenderRelatedFields({
|
||||
config,
|
||||
isHeadlessRouterPath,
|
||||
backgroundSlotsFetch,
|
||||
}: {
|
||||
config: PrefillAndIframeAttrsConfig;
|
||||
isHeadlessRouterPath: boolean;
|
||||
backgroundSlotsFetch: boolean;
|
||||
}) {
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
|
||||
// If we are prerendering a headless router path, we don't want to record the response immediately.
|
||||
if (isHeadlessRouterPath) {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
prerender: "true",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import type { Awaitable } from "next-auth";
|
||||
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { identityProviderNameMap } from "./identityProviderNameMap";
|
||||
|
||||
type CalComAdapter = {
|
||||
createUser: (data: Prisma.UserCreateInput) => Awaitable<User>;
|
||||
getUser: (id: string | number) => Awaitable<User | null>;
|
||||
getUserByEmail: (email: User["email"]) => Awaitable<User | null>;
|
||||
getUserByAccount: (provider_providerAccountId: {
|
||||
providerAccountId: Account["providerAccountId"];
|
||||
provider: User["identityProvider"];
|
||||
}) => Awaitable<User | null>;
|
||||
updateUser: (data: Prisma.UserUncheckedCreateInput) => Awaitable<User>;
|
||||
deleteUser: (id: User["id"]) => Awaitable<User>;
|
||||
createVerificationToken: (data: VerificationToken) => Awaitable<Omit<VerificationToken, "id">>;
|
||||
useVerificationToken: (
|
||||
identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput
|
||||
) => Awaitable<Omit<VerificationToken, "id"> | null>;
|
||||
linkAccount: (data: Prisma.AccountCreateInput) => Awaitable<Account>;
|
||||
unlinkAccount: (
|
||||
provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput
|
||||
) => Awaitable<Account>;
|
||||
};
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function CalComAdapter(prismaClient: PrismaClient) {
|
||||
export default function CalComAdapter(prismaClient: PrismaClient): CalComAdapter {
|
||||
return {
|
||||
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
|
||||
getUser: (id: string | number) =>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
type Session = RouterOutputs["viewer"]["calVideo"]["getMeetingInformation"]["data"][number];
|
||||
type Participant = Session["participants"][number];
|
||||
|
||||
interface IMeetingSessionDetailsDialog {
|
||||
booking?: BookingItem;
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
timeFormat: number | null;
|
||||
}
|
||||
|
||||
const MeetingSessionDetailsSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[...Array(2)].map((_, index) => (
|
||||
<div key={index} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<SkeletonText className="h-5 w-24" />
|
||||
<SkeletonText className="mt-1 h-4 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="mt-1 h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SkeletonText className="mb-2 h-5 w-20" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</div>
|
||||
<SkeletonText className="mt-1 h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MeetingSessionDetailsList = ({ roomName }: { roomName: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: meetingInfo, isLoading } = trpc.viewer.calVideo.getMeetingInformation.useQuery(
|
||||
{ roomName },
|
||||
{
|
||||
suspense: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <MeetingSessionDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!meetingInfo || !meetingInfo.data || meetingInfo.data.length === 0) {
|
||||
return <p className="font-semibold">{t("no_meeting_sessions_found")}</p>;
|
||||
}
|
||||
|
||||
const formatDuration = (totalSeconds: number) => {
|
||||
if (!totalSeconds) {
|
||||
return t("ongoing");
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} ${t("minutes")}`);
|
||||
}
|
||||
if (seconds > 0) {
|
||||
parts.push(`${seconds} ${t("seconds")}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{meetingInfo.data.map((session: Session, index: number) => (
|
||||
<div key={session.id} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("meeting_session")} {index + 1}
|
||||
</h3>
|
||||
<p className="text-subtle text-sm">
|
||||
{t("session_id")}: {session.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{t("start_time")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.start_time
|
||||
? dayjs(session.start_time * 1000).format("MMM D, YYYY h:mm A")
|
||||
: t("not_available")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("duration")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.duration ? formatDuration(session.duration) : t("ongoing")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("max_participants")}:</span>
|
||||
<p className="text-subtle">{session.max_participants || t("unlimited")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("status")}:</span>
|
||||
<p className="text-subtle">{session.ongoing ? t("ongoing") : t("ended")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.participants && session.participants.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 text-sm font-medium">{t("participants")}:</h4>
|
||||
<div className="space-y-2">
|
||||
{session.participants.map((participant: Participant) => (
|
||||
<div key={participant.participant_id} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">{participant.user_name || t("anonymous_user")}</span>
|
||||
<span className="text-subtle">
|
||||
{participant.join_time ? dayjs(participant.join_time * 1000).format("h:mm A") : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("user_id")}: {participant.user_id || t("not_available")}
|
||||
</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("participant_id")}: {participant.participant_id}
|
||||
</p>
|
||||
{participant.duration && (
|
||||
<p className="text-subtle text-xs">
|
||||
{t("session_duration")}: {formatDuration(participant.duration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MeetingSessionDetailsDialog = (props: IMeetingSessionDetailsDialog) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props;
|
||||
|
||||
const roomName =
|
||||
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||
undefined;
|
||||
|
||||
const subtitle = `${booking?.title} - ${dayjs(booking?.startTime).format("ddd")} ${dayjs(
|
||||
booking?.startTime
|
||||
).format("D")}, ${dayjs(booking?.startTime).format("MMM")}`;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("meeting_session_details")} subtitle={subtitle} />
|
||||
{roomName ? (
|
||||
<Suspense fallback={<MeetingSessionDetailsSkeleton />}>
|
||||
<MeetingSessionDetailsList roomName={roomName} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<p className="font-semibold">{t("no_meeting_sessions_found")}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose className="border" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -37,6 +37,9 @@ export async function handler(req: NextRequest) {
|
||||
scheduledDate: {
|
||||
lte: dayjs().add(2, "hour").toISOString(),
|
||||
},
|
||||
retryCount: {
|
||||
lt: 3, // Don't continue retrying if it's already failed 3 times
|
||||
},
|
||||
},
|
||||
select: {
|
||||
...select,
|
||||
|
||||
@@ -30,16 +30,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-01T23:59:59.999Z",
|
||||
formattedDate: "May 1",
|
||||
formattedDateFull: "May 1",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-02T00:00:00.000Z",
|
||||
endDate: "2025-05-02T23:59:59.999Z",
|
||||
formattedDate: "2",
|
||||
formattedDateFull: "May 2",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-03T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "3",
|
||||
formattedDateFull: "May 3",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,26 +67,31 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 3",
|
||||
formattedDateFull: "May 1 - May 3",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-04T00:00:00.000Z",
|
||||
endDate: "2025-05-10T23:59:59.999Z",
|
||||
formattedDate: "May 4 - 10",
|
||||
formattedDateFull: "May 4 - May 10",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-11T00:00:00.000Z",
|
||||
endDate: "2025-05-17T23:59:59.999Z",
|
||||
formattedDate: "May 11 - 17",
|
||||
formattedDateFull: "May 11 - May 17",
|
||||
});
|
||||
expect(ranges[3]).toEqual({
|
||||
startDate: "2025-05-18T00:00:00.000Z",
|
||||
endDate: "2025-05-24T23:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[4]).toEqual({
|
||||
startDate: "2025-05-25T00:00:00.000Z",
|
||||
endDate: "2025-05-25T23:59:59.999Z",
|
||||
formattedDate: "May 25 - 25",
|
||||
formattedDateFull: "May 25 - May 25",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,16 +116,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-15T00:00:00.000Z",
|
||||
endDate: "2025-05-31T23:59:59.999Z",
|
||||
formattedDate: "May",
|
||||
formattedDateFull: "May",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-06-01T00:00:00.000Z",
|
||||
endDate: "2025-06-30T23:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-07-01T00:00:00.000Z",
|
||||
endDate: "2025-07-15T23:59:59.999Z",
|
||||
formattedDate: "Jul",
|
||||
formattedDateFull: "Jul",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,16 +153,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-06-15T00:00:00.000Z",
|
||||
endDate: "2025-12-31T23:59:59.999Z",
|
||||
formattedDate: "2025",
|
||||
formattedDateFull: "2025",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2026-01-01T00:00:00.000Z",
|
||||
endDate: "2026-12-31T23:59:59.999Z",
|
||||
formattedDate: "2026",
|
||||
formattedDateFull: "2026",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2027-01-01T00:00:00.000Z",
|
||||
endDate: "2027-03-15T23:59:59.999Z",
|
||||
formattedDate: "2027",
|
||||
formattedDateFull: "2027",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +190,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-01T23:59:59.999Z",
|
||||
formattedDate: "May 1",
|
||||
formattedDateFull: "May 1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -205,6 +220,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-03-29T23:00:00.000Z", // March 30th 00:00 Paris time
|
||||
endDate: "2025-03-30T21:59:59.999Z", // March 30th 23:59:59 Paris time
|
||||
formattedDate: "Mar 30",
|
||||
formattedDateFull: "Mar 30",
|
||||
});
|
||||
expect(new Date(ranges[0].endDate).getTime() - new Date(ranges[0].startDate).getTime()).toBeLessThan(
|
||||
23 * 60 * 60 * 1000
|
||||
@@ -213,6 +229,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-03-30T22:00:00.000Z", // March 31st 00:00 Paris time
|
||||
endDate: "2025-03-31T21:59:59.999Z", // March 31st 23:59:59 Paris time
|
||||
formattedDate: "31",
|
||||
formattedDateFull: "Mar 31",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,16 +254,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-15T22:00:00.000Z",
|
||||
endDate: "2025-05-17T21:59:59.999Z",
|
||||
formattedDate: "May 16 - 17",
|
||||
formattedDateFull: "May 16 - May 17",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-17T22:00:00.000Z",
|
||||
endDate: "2025-05-24T21:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-24T22:00:00.000Z",
|
||||
endDate: "2025-05-29T21:59:59.999Z",
|
||||
formattedDate: "May 25 - 29",
|
||||
formattedDateFull: "May 25 - May 29",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,11 +291,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-31T22:00:00.000Z",
|
||||
endDate: "2025-06-30T21:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-06-30T22:00:00.000Z",
|
||||
endDate: "2025-07-31T21:59:59.999Z",
|
||||
formattedDate: "Jul",
|
||||
formattedDateFull: "Jul",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -304,11 +326,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-14T15:00:00.000Z",
|
||||
endDate: "2025-05-15T14:59:59.999Z",
|
||||
formattedDate: "May 15",
|
||||
formattedDateFull: "May 15",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-15T15:00:00.000Z",
|
||||
endDate: "2025-05-16T14:59:59.999Z",
|
||||
formattedDate: "16",
|
||||
formattedDateFull: "May 16",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,16 +357,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-11T15:00:00.000Z",
|
||||
endDate: "2025-05-17T14:59:59.999Z",
|
||||
formattedDate: "May 12 - 17",
|
||||
formattedDateFull: "May 12 - May 17",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-17T15:00:00.000Z",
|
||||
endDate: "2025-05-24T14:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-24T15:00:00.000Z",
|
||||
endDate: "2025-05-25T14:59:59.999Z",
|
||||
formattedDate: "May 25 - 25",
|
||||
formattedDateFull: "May 25 - May 25",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,11 +394,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-04-30T15:00:00.000Z",
|
||||
endDate: "2025-05-31T14:59:59.999Z",
|
||||
formattedDate: "May",
|
||||
formattedDateFull: "May",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-31T15:00:00.000Z",
|
||||
endDate: "2025-06-30T14:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -421,16 +450,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-04T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 4",
|
||||
formattedDateFull: "May 1 - May 4",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-05T00:00:00.000Z",
|
||||
endDate: "2025-05-11T23:59:59.999Z",
|
||||
formattedDate: "May 5 - 11",
|
||||
formattedDateFull: "May 5 - May 11",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-12T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 12 - 14",
|
||||
formattedDateFull: "May 12 - May 14",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,16 +487,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 3",
|
||||
formattedDateFull: "May 1 - May 3",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-04T00:00:00.000Z",
|
||||
endDate: "2025-05-10T23:59:59.999Z",
|
||||
formattedDate: "May 4 - 10",
|
||||
formattedDateFull: "May 4 - May 10",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-11T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 11 - 14",
|
||||
formattedDateFull: "May 11 - May 14",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,16 +524,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-02T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 2",
|
||||
formattedDateFull: "May 1 - May 2",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-03T00:00:00.000Z",
|
||||
endDate: "2025-05-09T23:59:59.999Z",
|
||||
formattedDate: "May 3 - 9",
|
||||
formattedDateFull: "May 3 - May 9",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-10T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 10 - 14",
|
||||
formattedDateFull: "May 10 - May 14",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -846,4 +884,204 @@ describe("EventsInsights", () => {
|
||||
expect(result).toBe("day");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateRanges - Formatting Comparison", () => {
|
||||
const timeZone = "UTC";
|
||||
|
||||
describe("Daily view formatting differences", () => {
|
||||
it("should show smart vs full formatting for consecutive days", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-01-19T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(5);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 15", "Jan 16", "Jan 17", "Jan 18", "Jan 19"]);
|
||||
});
|
||||
|
||||
it("should show formatting differences across month boundaries", () => {
|
||||
const startDate = "2024-01-30T00:00:00.000Z";
|
||||
const endDate = "2024-02-02T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(4);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weekly view formatting differences", () => {
|
||||
it("should show smart vs full formatting for weekly ranges", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z"; // Monday
|
||||
const endDate = "2024-01-28T23:59:59.999Z"; // Sunday
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "week",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(2);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15 - 21", "Jan 22 - 28"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 15 - Jan 21", "Jan 22 - Jan 28"]);
|
||||
});
|
||||
|
||||
it("should show formatting differences for cross-month weekly ranges", () => {
|
||||
const startDate = "2024-01-29T00:00:00.000Z"; // Monday
|
||||
const endDate = "2024-02-11T23:59:59.999Z"; // Sunday
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "week",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(2);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 29 - Feb 4", "Feb 5 - 11"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 29 - Feb 4", "Feb 5 - Feb 11"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Monthly view formatting differences", () => {
|
||||
it("should show consistent formatting for monthly ranges", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-03-15T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "month",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan", "Feb", "Mar"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan", "Feb", "Mar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Real-world usage examples", () => {
|
||||
it("should demonstrate practical differences for chart display", () => {
|
||||
// Simulate a common analytics scenario: 7 days of data
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-01-21T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19", "20", "21"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual([
|
||||
"Jan 15",
|
||||
"Jan 16",
|
||||
"Jan 17",
|
||||
"Jan 18",
|
||||
"Jan 19",
|
||||
"Jan 20",
|
||||
"Jan 21",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should show benefits of full formatting for data export", () => {
|
||||
// Simulate data that might be exported or used in external systems
|
||||
const startDate = "2024-01-30T00:00:00.000Z";
|
||||
const endDate = "2024-02-05T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2", "3", "4", "5"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2", "Feb 3", "Feb 4", "Feb 5"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface DateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
formattedDate: string;
|
||||
formattedDateFull: string;
|
||||
}
|
||||
|
||||
export interface GetDateRangesParams {
|
||||
@@ -330,6 +331,13 @@ class EventsInsights {
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
formattedDateFull: this.formatPeriodFull({
|
||||
start: currentStartDate,
|
||||
end: currentEndDate,
|
||||
timeView,
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -344,6 +352,13 @@ class EventsInsights {
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
formattedDateFull: this.formatPeriodFull({
|
||||
start: currentStartDate,
|
||||
end: currentEndDate,
|
||||
timeView,
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
});
|
||||
|
||||
currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone);
|
||||
@@ -401,6 +416,46 @@ class EventsInsights {
|
||||
}
|
||||
}
|
||||
|
||||
static formatPeriodFull({
|
||||
start,
|
||||
end,
|
||||
timeView,
|
||||
wholeStart,
|
||||
wholeEnd,
|
||||
}: {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
timeView: TimeViewType;
|
||||
wholeStart: dayjs.Dayjs;
|
||||
wholeEnd: dayjs.Dayjs;
|
||||
}): string {
|
||||
const omitYear = wholeStart.year() === wholeEnd.year();
|
||||
|
||||
switch (timeView) {
|
||||
case "day":
|
||||
return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY");
|
||||
case "week":
|
||||
const startFormat = "MMM D";
|
||||
const endFormat = "MMM D";
|
||||
|
||||
if (start.format("YYYY") !== end.format("YYYY")) {
|
||||
return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`;
|
||||
}
|
||||
|
||||
if (omitYear) {
|
||||
return `${start.format(startFormat)} - ${end.format(endFormat)}`;
|
||||
} else {
|
||||
return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`;
|
||||
}
|
||||
case "month":
|
||||
return omitYear ? start.format("MMM") : start.format("MMM YYYY");
|
||||
case "year":
|
||||
return start.format("YYYY");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static getCsvData = async (
|
||||
props: RawDataInput & {
|
||||
organizationId: number | null;
|
||||
|
||||
+97
@@ -42,4 +42,101 @@ describe("filterRedundantDateRanges", () => {
|
||||
|
||||
expect(filterRedundantDateRanges(dateRanges)).toEqual(dateRanges);
|
||||
});
|
||||
|
||||
it("should handle three identical ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T12:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle nested containment scenario", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle ranges with same start time", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle ranges with same end time", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should preserve invalid ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T11:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle touching ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle overlapping but non-containing ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle complex overlapping pattern that exposed cubic-dev-ai bug", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
|
||||
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[2].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
});
|
||||
});
|
||||
|
||||
+23
-10
@@ -1,7 +1,9 @@
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree";
|
||||
|
||||
/**
|
||||
* Filters out date ranges that are completely covered by other date ranges.
|
||||
* Uses an interval tree for O(n log n) worst-case complexity.
|
||||
* Unlike mergeOverlappingDateRanges, this doesn't merge overlapping ranges,
|
||||
* it only removes ranges that are completely contained within others.
|
||||
*/
|
||||
@@ -9,27 +11,38 @@ export function filterRedundantDateRanges(dateRanges: DateRange[]): DateRange[]
|
||||
if (dateRanges.length <= 1) return dateRanges;
|
||||
|
||||
const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
|
||||
const intervalNodes = createIntervalNodes(
|
||||
sortedRanges,
|
||||
(range) => range.start.valueOf(),
|
||||
(range) => range.end.valueOf()
|
||||
);
|
||||
const intervalTree = new IntervalTree(intervalNodes);
|
||||
const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree);
|
||||
|
||||
return sortedRanges.filter((range, index) => {
|
||||
if (range.end.valueOf() < range.start.valueOf()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < sortedRanges.length; i++) {
|
||||
if (i === index) continue; // Skip comparing with itself
|
||||
const containingIntervals = searchAlgorithm.findContainingIntervals(
|
||||
range.start.valueOf(),
|
||||
range.end.valueOf(),
|
||||
index
|
||||
);
|
||||
|
||||
const otherRange = sortedRanges[i];
|
||||
|
||||
if (otherRange.end.valueOf() < otherRange.start.valueOf()) {
|
||||
continue;
|
||||
}
|
||||
for (const containingNode of containingIntervals) {
|
||||
const otherRange = containingNode.item;
|
||||
const otherIndex = containingNode.index;
|
||||
|
||||
if (
|
||||
otherRange.start.valueOf() <= range.start.valueOf() &&
|
||||
otherRange.end.valueOf() >= range.end.valueOf()
|
||||
otherRange.start.valueOf() === range.start.valueOf() &&
|
||||
otherRange.end.valueOf() === range.end.valueOf()
|
||||
) {
|
||||
return false; // This range is redundant, filter it out
|
||||
return otherIndex > index; // Keep current range only if other range has higher index
|
||||
}
|
||||
|
||||
// If we reach here, the other range actually contains this range
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Keep this range
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface IntervalNode<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
start: number;
|
||||
end: number;
|
||||
maxEnd: number;
|
||||
left?: IntervalNode<T>;
|
||||
right?: IntervalNode<T>;
|
||||
}
|
||||
|
||||
export function createIntervalNodes<T>(
|
||||
items: T[],
|
||||
getStart: (item: T) => number,
|
||||
getEnd: (item: T) => number
|
||||
): IntervalNode<T>[] {
|
||||
return items.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
start: getStart(item),
|
||||
end: getEnd(item),
|
||||
maxEnd: getEnd(item),
|
||||
}));
|
||||
}
|
||||
|
||||
export class IntervalTree<T> {
|
||||
private root?: IntervalNode<T>;
|
||||
|
||||
constructor(nodes: IntervalNode<T>[]) {
|
||||
this.root = this.buildTree([...nodes]);
|
||||
}
|
||||
|
||||
private buildTree(nodes: IntervalNode<T>[]): IntervalNode<T> | undefined {
|
||||
if (nodes.length === 0) return undefined;
|
||||
|
||||
const mid = Math.floor(nodes.length / 2);
|
||||
const node = nodes[mid];
|
||||
|
||||
const leftNodes = nodes.slice(0, mid);
|
||||
const rightNodes = nodes.slice(mid + 1);
|
||||
|
||||
node.left = this.buildTree(leftNodes);
|
||||
node.right = this.buildTree(rightNodes);
|
||||
|
||||
node.maxEnd = Math.max(node.end, node.left?.maxEnd ?? 0, node.right?.maxEnd ?? 0);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
getRoot(): IntervalNode<T> | undefined {
|
||||
return this.root;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainmentSearchAlgorithm<T> {
|
||||
private tree: IntervalTree<T>;
|
||||
|
||||
constructor(tree: IntervalTree<T>) {
|
||||
this.tree = tree;
|
||||
}
|
||||
|
||||
findContainingIntervals(targetStart: number, targetEnd: number, targetIndex: number): IntervalNode<T>[] {
|
||||
const result: IntervalNode<T>[] = [];
|
||||
this.searchContaining(this.tree.getRoot(), targetStart, targetEnd, targetIndex, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private searchContaining(
|
||||
node: IntervalNode<T> | undefined,
|
||||
targetStart: number,
|
||||
targetEnd: number,
|
||||
targetIndex: number,
|
||||
result: IntervalNode<T>[]
|
||||
): void {
|
||||
if (!node) return;
|
||||
|
||||
if (node.end < node.start) {
|
||||
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
|
||||
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.start <= targetStart && node.end >= targetEnd && node.index !== targetIndex) {
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
if (node.left && node.left.maxEnd >= targetStart) {
|
||||
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
|
||||
}
|
||||
|
||||
if (node.right && node.start <= targetEnd) {
|
||||
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class AssignmentReasonRepository {
|
||||
static async findLatestReasonFromBookingUid(bookingUid: string) {
|
||||
return await prisma.assignmentReason.findFirst({
|
||||
where: {
|
||||
booking: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
globals.min.css
|
||||
cal-atoms-dev.tgz
|
||||
globals.min.css
|
||||
@@ -10,7 +10,8 @@
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
"publish-npm": "yarn build && npm publish --access public",
|
||||
"test": "jest",
|
||||
"pack-dev": "yarn build && npm pack && mv calcom-atoms-*.tgz cal-atoms-dev.tgz"
|
||||
"dev-on": "node scripts/dev-on.js",
|
||||
"dev-off": "git checkout -- package.json vite.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = path.join(__dirname, "../package.json");
|
||||
const viteConfigPath = path.join(__dirname, "../vite.config.ts");
|
||||
|
||||
// Modify package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||||
packageJson.main = "./dist/cal-atoms.umd.cjs";
|
||||
packageJson.exports["."].require = "./dist/cal-atoms.umd.cjs";
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Modify vite.config.ts
|
||||
let viteConfig = fs.readFileSync(viteConfigPath, "utf-8");
|
||||
viteConfig = viteConfig.replace(/formats: \["es"\],/g, "");
|
||||
viteConfig = viteConfig.replace(/external: \[([^\]]+)\]/, 'external: [$1, "react-awesome-query-builder"]');
|
||||
viteConfig = viteConfig.replace(/format: "esm",\s*/g, "");
|
||||
fs.writeFileSync(viteConfigPath, viteConfig);
|
||||
@@ -36,7 +36,7 @@ export default defineConfig(({ mode }) => {
|
||||
rollupOptions: {
|
||||
external: ["react", "fs", "path", "os", "react/jsx-runtime", "react-dom", "react-dom/client"],
|
||||
output: {
|
||||
format: "esm", // Force ESM output
|
||||
format: "esm",
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
|
||||
@@ -19,7 +19,7 @@ REDIS_URL="redis://localhost:6379"
|
||||
```
|
||||
|
||||
3. Start "apps/api/v2" api using `yarn dev`.
|
||||
4. Setup "packages/platform/atoms" by running `yarn atoms-dev-on` in the example app directory - it will pack atoms into a dev .tgz and switch the dependency to point to it. When you are done with development, run `yarn atoms-dev-off` to switch back to the published version because otherwise examples app would have entry in the yarn.lock file pointing to the dev .tgz and installation would fail in prod because dev tar file exists only locally.
|
||||
4. Start "packages/platform/atoms" atoms package with `yarn dev`.
|
||||
5. Start "apps/web" cal web app using `yarn dx`.
|
||||
6. Open your browser at "http://localhost:3000/" and login with admin username `admin@example.com` and password `ADMINadmin2022!`.
|
||||
7. In the web app navigate to `http://localhost:3000/settings/organizations/new` and create a sample organization. When asked for phone verification code enter `111111`.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "📦 Packing @calcom/atoms into dev .tgz..."
|
||||
yarn workspace @calcom/atoms pack-dev
|
||||
|
||||
echo "🔧 Switching @calcom/atoms to dev .tgz..."
|
||||
|
||||
# Detect platform for sed compatibility
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's|"@calcom/atoms": ".*"|"@calcom/atoms": "file:../../atoms/cal-atoms-dev.tgz"|' package.json
|
||||
else
|
||||
sed -i 's|"@calcom/atoms": ".*"|"@calcom/atoms": "file:../../atoms/cal-atoms-dev.tgz"|' package.json
|
||||
fi
|
||||
|
||||
yarn install
|
||||
|
||||
echo "✅ Local atoms .tgz linked. Ready for dev."
|
||||
@@ -4,8 +4,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4321 next dev",
|
||||
"atoms-dev-on": "./atoms-dev-on.sh",
|
||||
"atoms-dev-off": "git checkout -- package.json ../../../../yarn.lock",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
|
||||
Binary file not shown.
@@ -489,7 +489,7 @@ export async function getBookings({
|
||||
"EventType.disableCancelling",
|
||||
"EventType.disableRescheduling",
|
||||
eb
|
||||
.cast<SchedulingType>(
|
||||
.cast<SchedulingType | null>(
|
||||
eb
|
||||
.case()
|
||||
.when("EventType.schedulingType", "=", "roundRobin")
|
||||
@@ -498,7 +498,7 @@ export async function getBookings({
|
||||
.then(SchedulingType["COLLECTIVE"])
|
||||
.when("EventType.schedulingType", "=", "managed")
|
||||
.then(SchedulingType["MANAGED"])
|
||||
.else(SchedulingType["ROUND_ROBIN"]) // Ensure ELSE provides a value within SchedulingTypeLiteral for cast safety
|
||||
.else(null)
|
||||
.end(),
|
||||
"varchar" // Or 'text' - use the actual SQL data type
|
||||
)
|
||||
|
||||
@@ -2,10 +2,12 @@ import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema";
|
||||
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||
import { ZGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
|
||||
|
||||
type CalVideoRouterHandlerCache = {
|
||||
getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler;
|
||||
getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler;
|
||||
getMeetingInformation?: typeof import("./getMeetingInformation.handler").getMeetingInformationHandler;
|
||||
};
|
||||
|
||||
export const calVideoRouter = router({
|
||||
@@ -26,4 +28,12 @@ export const calVideoRouter = router({
|
||||
|
||||
return getDownloadLinkOfCalVideoRecordingsHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
getMeetingInformation: authedProcedure
|
||||
.input(ZGetMeetingInformationInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { getMeetingInformationHandler } = await import("./getMeetingInformation.handler");
|
||||
|
||||
return getMeetingInformationHandler({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import DailyVideoApiAdapter from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
|
||||
|
||||
type GetMeetingInformationOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetMeetingInformationInputSchema;
|
||||
};
|
||||
|
||||
export const getMeetingInformationHandler = async ({ ctx: _ctx, input }: GetMeetingInformationOptions) => {
|
||||
const { roomName } = input;
|
||||
|
||||
try {
|
||||
const videoApiAdapter = DailyVideoApiAdapter();
|
||||
if (!videoApiAdapter || !videoApiAdapter.getMeetingInformation) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Meeting information feature not available",
|
||||
});
|
||||
}
|
||||
const res = await videoApiAdapter.getMeetingInformation(roomName);
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetMeetingInformationInputSchema = z.object({
|
||||
roomName: z.string(),
|
||||
});
|
||||
|
||||
export type TGetMeetingInformationInputSchema = z.infer<typeof ZGetMeetingInformationInputSchema>;
|
||||
@@ -152,7 +152,12 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
}
|
||||
|
||||
// if defined AND a base 64 string, upload and update the avatar URL
|
||||
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.avatarUrl &&
|
||||
(input.avatarUrl.startsWith("data:image/png;base64,") ||
|
||||
input.avatarUrl.startsWith("data:image/jpeg;base64,") ||
|
||||
input.avatarUrl.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
data.avatarUrl = await uploadAvatar({
|
||||
avatar: await resizeBase64Image(input.avatarUrl),
|
||||
userId: user.id,
|
||||
|
||||
@@ -119,7 +119,12 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
|
||||
timeZone: input.timeZone,
|
||||
};
|
||||
|
||||
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.avatar &&
|
||||
(input.avatar.startsWith("data:image/png;base64,") ||
|
||||
input.avatar.startsWith("data:image/jpeg;base64,") ||
|
||||
input.avatar.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
const avatar = await resizeBase64Image(input.avatar);
|
||||
data.avatarUrl = await uploadAvatar({
|
||||
avatar,
|
||||
|
||||
@@ -106,7 +106,12 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
},
|
||||
});
|
||||
// Upload logo, create doesn't allow logo removal
|
||||
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.logo &&
|
||||
(input.logo.startsWith("data:image/png;base64,") ||
|
||||
input.logo.startsWith("data:image/jpeg;base64,") ||
|
||||
input.logo.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
const logoUrl = await uploadLogo({
|
||||
logo: await resizeBase64Image(input.logo),
|
||||
teamId: createdTeam.id,
|
||||
|
||||
@@ -70,7 +70,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
rrTimestampBasis: input.rrTimestampBasis,
|
||||
};
|
||||
|
||||
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
|
||||
if (
|
||||
input.logo &&
|
||||
(input.logo.startsWith("data:image/png;base64,") ||
|
||||
input.logo.startsWith("data:image/jpeg;base64,") ||
|
||||
input.logo.startsWith("data:image/jpg;base64,"))
|
||||
) {
|
||||
data.logoUrl = await uploadLogo({ teamId: input.id, logo: input.logo });
|
||||
} else if (typeof input.logo !== "undefined" && !input.logo) {
|
||||
data.logoUrl = null;
|
||||
|
||||
Vendored
+2
@@ -43,6 +43,8 @@ export type VideoApiAdapter =
|
||||
): Promise<TGetTranscriptAccessLink["transcription"] | { message: string }>;
|
||||
|
||||
checkIfRoomNameMatchesInRecording?(roomName: string, recordingId: string): Promise<boolean>;
|
||||
|
||||
getMeetingInformation?(roomName: string): Promise<any>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { IconName } from "../icon";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
const dialogClasses = cva(
|
||||
"fadeIn bg-default scroll-bar fixed left-1/2 top-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 rounded-2xl text-left shadow-xl focus-visible:outline-none sm:align-middle",
|
||||
"fadeIn bg-default scroll-bar fixed left-1/2 top-1/2 z-50 w-[95vw] m-auto -translate-x-1/2 -translate-y-1/2 rounded-2xl text-left shadow-xl focus-visible:outline-none sm:align-middle",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -2607,7 +2607,7 @@ __metadata:
|
||||
"@sentry/nextjs": ^9.15.0
|
||||
bcryptjs: ^2.4.3
|
||||
memory-cache: ^0.2.0
|
||||
next: ^13.5.9
|
||||
next: ^15.3.0
|
||||
next-api-middleware: ^1.0.1
|
||||
next-axiom: ^0.17.0
|
||||
next-swagger-doc: ^0.3.6
|
||||
@@ -9025,13 +9025,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:13.5.11":
|
||||
version: 13.5.11
|
||||
resolution: "@next/env@npm:13.5.11"
|
||||
checksum: 1d19fb97ecdda14d2ea91b251e01b5e38046e42b161572c17abe55c1b77b329067fd3a5d120c35e22d84661f09d80bd2479ca355150cefc17c9a8120998f784a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:13.5.7":
|
||||
version: 13.5.7
|
||||
resolution: "@next/env@npm:13.5.7"
|
||||
@@ -9094,13 +9087,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-darwin-arm64@npm:13.5.9"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-darwin-arm64@npm:14.0.4"
|
||||
@@ -9129,13 +9115,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-darwin-x64@npm:13.5.9"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-darwin-x64@npm:14.0.4"
|
||||
@@ -9164,13 +9143,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:13.5.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:14.0.4"
|
||||
@@ -9199,13 +9171,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:13.5.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:14.0.4"
|
||||
@@ -9234,13 +9199,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:13.5.9"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:14.0.4"
|
||||
@@ -9269,13 +9227,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-linux-x64-musl@npm:13.5.9"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-x64-musl@npm:14.0.4"
|
||||
@@ -9304,13 +9255,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:13.5.9"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:14.0.4"
|
||||
@@ -9339,13 +9283,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-ia32-msvc@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:13.5.9"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-ia32-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:14.0.4"
|
||||
@@ -9360,13 +9297,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:13.5.9":
|
||||
version: 13.5.9
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:13.5.9"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:14.0.4"
|
||||
@@ -36212,61 +36142,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^13.5.9":
|
||||
version: 13.5.11
|
||||
resolution: "next@npm:13.5.11"
|
||||
dependencies:
|
||||
"@next/env": 13.5.11
|
||||
"@next/swc-darwin-arm64": 13.5.9
|
||||
"@next/swc-darwin-x64": 13.5.9
|
||||
"@next/swc-linux-arm64-gnu": 13.5.9
|
||||
"@next/swc-linux-arm64-musl": 13.5.9
|
||||
"@next/swc-linux-x64-gnu": 13.5.9
|
||||
"@next/swc-linux-x64-musl": 13.5.9
|
||||
"@next/swc-win32-arm64-msvc": 13.5.9
|
||||
"@next/swc-win32-ia32-msvc": 13.5.9
|
||||
"@next/swc-win32-x64-msvc": 13.5.9
|
||||
"@swc/helpers": 0.5.2
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: ^1.0.30001406
|
||||
postcss: 8.4.31
|
||||
styled-jsx: 5.1.1
|
||||
watchpack: 2.4.0
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.1.0
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
sass: ^1.3.0
|
||||
dependenciesMeta:
|
||||
"@next/swc-darwin-arm64":
|
||||
optional: true
|
||||
"@next/swc-darwin-x64":
|
||||
optional: true
|
||||
"@next/swc-linux-arm64-gnu":
|
||||
optional: true
|
||||
"@next/swc-linux-arm64-musl":
|
||||
optional: true
|
||||
"@next/swc-linux-x64-gnu":
|
||||
optional: true
|
||||
"@next/swc-linux-x64-musl":
|
||||
optional: true
|
||||
"@next/swc-win32-arm64-msvc":
|
||||
optional: true
|
||||
"@next/swc-win32-ia32-msvc":
|
||||
optional: true
|
||||
"@next/swc-win32-x64-msvc":
|
||||
optional: true
|
||||
peerDependenciesMeta:
|
||||
"@opentelemetry/api":
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: 117e091c48446da63c639f6d80bb5ba2cf25cfcfc54ca90293602af988e386852e8c004251ff978a085a36285474e92d0ddd9668aa800733754670a4b03bdd2a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^15.3.0":
|
||||
version: 15.3.3
|
||||
resolution: "next@npm:15.3.3"
|
||||
|
||||
Reference in New Issue
Block a user