feat: Prerender booking link and reuse with headless router (#20720)
* mvp done * wip * fix ts errors and other code improvements * fix ts errors * ensure mobile layout support * Make skeleton responsive on screen resize * refactor * Add test for EmbedElement * make skeleton closer to pixel perfect * Address PR feedback * Router-preloading ## What does this PR do? <!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. --> - Fixes #XXXX (GitHub issue number) - Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description) ## Visual Demo (For contributors especially) A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**. #### Video Demo (if applicable): - Show screen recordings of the issue or feature. - Demonstrate how to reproduce the issue, the behavior before and after the change. #### Image Demo (if applicable): - Add side-by-side screenshots of the original and updated change. - Highlight any significant change(s). ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? <!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests --> - Are there environment variables that should be set? - What are the minimal test data to have? - What is expected (happy path) to have (input and output)? - Any other important info that could help to test that PR ## Checklist <!-- Remove bullet points below that don't apply to you --> - I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code doesn't follow the style guidelines of this project - I haven't commented my code, particularly in hard-to-understand areas - I haven't checked if my changes generate no new warnings * wip\ * wip * fix mrge.io feedback * wip * Add README and lifecycle * Add README and lifecycle * Update routing form-seed and some other fixes * remove linkFailed fix from the branch * self-review * self-review-2 * self-review-3 * Handle soft connect\ * Update README and fix a bug with query parmas * Add one more case in routing-html playground --------- Co-authored-by: Benny Joo <sldisek783@gmail.com> Co-authored-by: amrit <iamamrit27@gmail.com>
This commit is contained in:
co-authored by
Benny Joo
amrit
parent
3ee6a7ecde
commit
6e9ec113fe
@@ -0,0 +1,43 @@
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
|
||||
|
||||
export default defaultHandler({
|
||||
OPTIONS: Promise.resolve({
|
||||
default: defaultResponder(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, cache-control, pragma");
|
||||
res.status(204).end();
|
||||
}),
|
||||
}),
|
||||
POST: Promise.resolve({
|
||||
default: defaultResponder(async (req, res) => {
|
||||
// getRoutedUrl has more detailed schema validation, we do a basic one here.
|
||||
const params = req.body;
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
const routedUrlData = await getRoutedUrl({ req, query: { ...params } });
|
||||
if (routedUrlData?.notFound) {
|
||||
return res.status(404).json({ status: "error", data: { message: "Form not found" } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.redirect?.destination) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ status: "success", data: { redirect: routedUrlData.redirect.destination } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.errorMessage) {
|
||||
return res.status(400).json({ status: "error", data: { message: routedUrlData.props.errorMessage } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.message) {
|
||||
return res.status(200).json({ status: "success", data: { message: routedUrlData.props.message } });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ status: "error", data: { message: "Neither Route nor custom message found." } });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
|
||||
|
||||
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
|
||||
export default function Router({ form, message, errorMessage }: inferSSRProps<typeof getServerSideProps>) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -17,7 +17,7 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
|
||||
<div>{message}</div>
|
||||
<div>{message || errorMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Cal.com Embed Lifecycle Events
|
||||
|
||||
This document details the lifecycle events and states of Cal.com embeds, showing the interaction flow between the parent page and the iframe.
|
||||
|
||||
## Core Lifecycle Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Parent as Parent (User Page)
|
||||
participant Embed as Embed (iframe)
|
||||
participant Store as EmbedStore(iframe)
|
||||
participant UI as UI Components(iframe)
|
||||
|
||||
Note over Parent: embed.js loads
|
||||
|
||||
alt Inline Embed
|
||||
Parent->>Parent: Create cal-element
|
||||
Parent->>Parent: Create iframe (visibility: hidden)
|
||||
Parent->>Parent: Show loader
|
||||
else Modal Embed
|
||||
Note over Parent: No action unless prerender
|
||||
end
|
||||
|
||||
alt Prerender Flow
|
||||
Note over Parent: Set prerender=true in URL
|
||||
Parent->>Store: Set prerenderState="inProgress"
|
||||
Note over Store: Limited events allowed
|
||||
Parent->>Parent: Create hidden iframe
|
||||
Parent->>Parent: load booker(no slots)
|
||||
Note over Parent: Wait for connect() call
|
||||
end
|
||||
|
||||
alt Modal CTA clicked
|
||||
Parent->>Parent: Create cal-modal-box
|
||||
Parent->>Parent: Create iframe (visibility: hidden)
|
||||
Parent->>Parent: Show loader
|
||||
end
|
||||
Note over Embed: background: transparent(stays transparent)
|
||||
Note over Embed: body tag set to visibility: hidden waiting to be shown
|
||||
Note over Embed: iframe webpage starts rendering
|
||||
|
||||
Note over Store: Initialize EmbedStore state
|
||||
Store->>Store: Set NOT_INITIALIZED state
|
||||
Store->>Store: Initialize UI config & theme
|
||||
|
||||
Note over Parent: Process URL params for prefill
|
||||
Parent->>Store: Set prefill data from URL
|
||||
Note over Store: Auto-populate form fields
|
||||
|
||||
Embed->>Parent: __iframeReady event
|
||||
Note over Parent,Embed: Embed ready to receive messages
|
||||
Note over Store: Set state to INITIALIZED
|
||||
|
||||
Store->>UI: Apply theme configuration
|
||||
Note over UI: DEPRECATED: styles prop
|
||||
Note over UI: Use cssVarsPerTheme instead
|
||||
Store->>UI: Apply cssVarsPerTheme
|
||||
|
||||
Embed->>Parent: __dimensionChanged event
|
||||
Note over Parent: Calculate and adjust iframe dimensions
|
||||
Note over Store: Update parentInformedAboutContentHeight
|
||||
|
||||
alt isBookerPage
|
||||
Note over Embed: Wait for booker ready state
|
||||
end
|
||||
|
||||
Embed->>Parent: linkReady event
|
||||
Note over Parent: Changes loading state to done
|
||||
Note over Parent: Removes loader
|
||||
Note over Parent: Sets iframe visibility to visible
|
||||
|
||||
Parent->>Embed: parentKnowsIframeReady event
|
||||
Note over Embed: Makes body visible
|
||||
Note over Store: Update UI configuration
|
||||
|
||||
alt Prerendering Active
|
||||
Note over Store: Set prerenderState to completed
|
||||
Parent->>Store: connect() with new config
|
||||
Store->>Store: Reset parentInformedAboutContentHeight
|
||||
Store->>Parent: Update iframe with new params
|
||||
Note over Store: Remove prerender params
|
||||
end
|
||||
|
||||
loop Dimension Monitoring
|
||||
Embed->>Embed: Monitor content size changes
|
||||
alt Dimensions Changed
|
||||
Embed->>Parent: __dimensionChanged event
|
||||
Parent->>Parent: Adjust iframe size to avoid scrollbar
|
||||
end
|
||||
end
|
||||
|
||||
alt Route Changes
|
||||
UI->>Store: Update UI state
|
||||
Store->>Parent: __routeChanged event
|
||||
Parent->>Parent: Handle navigation
|
||||
Note over Store: Preserve prefill data
|
||||
end
|
||||
```
|
||||
|
||||
## Detailed State Management
|
||||
|
||||
### EmbedStore States
|
||||
- `NOT_INITIALIZED`: Initial state when iframe is created
|
||||
- `INITIALIZED`: After __iframeReady event is processed
|
||||
- `prerenderState`: Can be null | "inProgress" | "completed"
|
||||
|
||||
### Visibility States
|
||||
1. Initial Creation:
|
||||
- iframe.style.visibility = "hidden"
|
||||
- body.style.visibility = "hidden"
|
||||
|
||||
2. After __iframeReady:
|
||||
- iframe becomes visible (unless prerendering)
|
||||
|
||||
3. After parentKnowsIframeReady:
|
||||
- body becomes visible
|
||||
- Background remains transparent
|
||||
|
||||
## Event Details
|
||||
|
||||
1. **Initial Load**
|
||||
- embed.js loads in parent page
|
||||
- For inline embeds: Creates elements immediately
|
||||
- For modal embeds: Waits for CTA click (unless prerendering)
|
||||
|
||||
2. **iframe Creation**
|
||||
- iframe is created with `visibility: hidden`
|
||||
- Loader is shown (default or skeleton)
|
||||
- EmbedStore initialized
|
||||
|
||||
3. **__iframeReady Event**
|
||||
- Fired by: Iframe
|
||||
- Indicates: Embed is ready to receive messages
|
||||
- Actions:
|
||||
- Sets iframeReady flag to true
|
||||
- Makes iframe visible (unless prerendering)
|
||||
- Processes queued iframe commands
|
||||
|
||||
4. **__dimensionChanged Event**
|
||||
- Fired by: Iframe
|
||||
- Purpose: Maintain proper iframe sizing
|
||||
- Triggers:
|
||||
- On initial load
|
||||
- When content size changes
|
||||
- After window load completes
|
||||
|
||||
5. **linkReady Event**
|
||||
- Fired by: Iframe
|
||||
- Indicates: iframe is fully ready for use
|
||||
- Requirements:
|
||||
- parentInformedAboutContentHeight must be true
|
||||
- For booker pages: booker must be in ready state
|
||||
- Actions:
|
||||
- Parent removes loader
|
||||
- Parent makes iframe visible
|
||||
|
||||
6. **parentKnowsIframeReady Event**
|
||||
- Fired by: Parent
|
||||
- Indicates: Parent acknowledges iframe readiness
|
||||
- Actions:
|
||||
- Makes body visible
|
||||
- For prerendering: marks prerenderState as "completed"
|
||||
|
||||
## Prerendering Flow
|
||||
|
||||
The prerendering flow follows a special path:
|
||||
|
||||
1. Initial State:
|
||||
- prerenderState: null
|
||||
|
||||
2. During Prerender:
|
||||
- prerenderState: "inProgress"
|
||||
- Limited events allowed (only __iframeReady, __dimensionChanged)
|
||||
- iframe and body remain hidden
|
||||
|
||||
3. After Connect:
|
||||
- prerenderState: "completed"
|
||||
- Full event flow enabled
|
||||
- Visibility states updated
|
||||
|
||||
## Command Queue System
|
||||
|
||||
The embed system implements a command queue to handle instructions before the iframe is ready:
|
||||
|
||||
1. Commands are queued if iframe isn't ready:
|
||||
```typescript
|
||||
if (!this.iframeReady) {
|
||||
this.iframeDoQueue.push(doInIframeArg);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
2. Queue is processed after __iframeReady event:
|
||||
- All queued commands are executed in order
|
||||
- New commands are executed immediately
|
||||
|
||||
## Error Handling
|
||||
|
||||
Page Load Errors:
|
||||
- System monitors CalComPageStatus
|
||||
- On non-200 status: fires linkFailed event
|
||||
- Includes error code and URL information
|
||||
@@ -37,3 +37,160 @@ Status:
|
||||
- [x] [Partially supported] team.event.booking.form - Shows skeleton but of the slots page
|
||||
- [ ] user.profile
|
||||
- [ ] team.profile
|
||||
|
||||
|
||||
|
||||
## How Routing Prerendering works
|
||||
- Use API to prerender a booking link for "modal"
|
||||
- When CTA is clicked by user, we check if there is a "prerendered"/"being prerendered" modal for this namespace.
|
||||
- If yes, we open up the modal showing the skeleton loader and send the POST request to /api/router endpoint
|
||||
- When we get the response from the endpoint, we pass on all the query params to the already rendered/being rendered iframe and embed-iframe updates the URL of the iframe to have the new query params through history.replaceState(i.e. without reloading the page)
|
||||
|
||||
|
||||
## Prerendering vs Preloading
|
||||
- Preloading loads the calLink in iframe with the sole purpose of preloading the static assets, so that when the embed actually opens, it uses the static assets from browser cache.
|
||||
- Prerendering means continuing over the preloaded iframe, so that the user books on the prerendered iframe only. So, it is much more complex than preloading and gives much more benefits in terms of performance.
|
||||
Note: API wise `prerender` delegates its task to `preload` API which then identifies whether to preload or prerender.
|
||||
|
||||
|
||||
## Modalbox re-opening performance optimization
|
||||
- ModalBox supports reusing the same cal-modal-box element and thus same iframe and thus providing a lightning fast experience when the same modal is opened multiple times [This feature is currently disabled in code because of stale booking page UI issues]
|
||||
|
||||
## Embed Core Architecture and Features
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
#### Initialization and Bootstrap Process
|
||||
The embed system initializes through a multi-step process:
|
||||
1. The embed script is loaded on the parent page
|
||||
2. It creates a global `Cal` object that acts as the entry point
|
||||
3. The system initializes necessary custom elements (`cal-modal-box`, `cal-floating-button`, `cal-inline`)
|
||||
4. A namespace-based action manager is created for event handling
|
||||
|
||||
#### Parent-Iframe Communication System
|
||||
Communication between the parent page and the embedded iframe uses a message-based system:
|
||||
|
||||
```typescript
|
||||
// Parent to Iframe communication example
|
||||
interface InterfaceWithParent {
|
||||
ui: (config: UiConfig) => void;
|
||||
connect: (config: PrefillAndIframeAttrsConfig) => void;
|
||||
}
|
||||
|
||||
// Event data structure
|
||||
type EventData<T> = {
|
||||
type: string;
|
||||
namespace: string;
|
||||
fullType: string;
|
||||
data: EventDataMap[T];
|
||||
};
|
||||
```
|
||||
|
||||
The system uses namespaced events to ensure multiple embeds on the same page don't interfere with each other.
|
||||
|
||||
#### Instruction Queue System
|
||||
Commands are queued before the iframe is ready:
|
||||
|
||||
```typescript
|
||||
type Instruction = SingleInstruction | SingleInstruction[];
|
||||
type InstructionQueue = Instruction[];
|
||||
|
||||
// Commands are queued if iframe isn't ready
|
||||
if (!this.iframeReady) {
|
||||
this.iframeDoQueue.push(doInIframeArg);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding Methods
|
||||
|
||||
#### Inline Embedding
|
||||
Embeds the calendar directly within the page flow:
|
||||
```typescript
|
||||
Cal.inline({
|
||||
elementOrSelector: "#my-cal-inline",
|
||||
calLink: "organization/event-type"
|
||||
});
|
||||
```
|
||||
|
||||
#### Modal Embedding
|
||||
Creates a modal dialog with the calendar:
|
||||
```typescript
|
||||
Cal.modal({
|
||||
calLink: "organization/event-type",
|
||||
config: {
|
||||
// Optional configuration
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### FloatingButton Embedding
|
||||
Adds a floating action button that opens the calendar in a modal. It uses modal embedding under the hood.
|
||||
```typescript
|
||||
Cal.floatingButton({
|
||||
calLink: "organization/event-type",
|
||||
buttonText: "Book meeting",
|
||||
buttonPosition: "bottom-right"
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration and Customization
|
||||
|
||||
#### Prefill System
|
||||
Allows pre-filling form fields:
|
||||
```typescript
|
||||
Cal.inline({
|
||||
calLink: "organization/event-type",
|
||||
config: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
notes: "Initial discussion"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Query Parameter Handling
|
||||
The system allows automatically forwarding query params to the iframe, by setting. This code must be present right after the embed snippet is added to the page.
|
||||
```js
|
||||
Cal.config = Cal.config || {};
|
||||
Cal.config.forwardQueryParams=true
|
||||
```
|
||||
|
||||
### Advanced Features
|
||||
|
||||
#### Routing Prerendering System
|
||||
The prerendering system optimizes the initial load:
|
||||
```typescript
|
||||
Cal.prerender({
|
||||
calLink: "organization/event-type",
|
||||
type: "modal"
|
||||
});
|
||||
```
|
||||
|
||||
Key aspects:
|
||||
|
||||
- Creates a hidden iframe
|
||||
- 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:
|
||||
|
||||
1. Reuse
|
||||
2. Reuse the iframe but refetch the slots
|
||||
3. Do a fresh load in iframe
|
||||
|
||||
- **Reuse**:
|
||||
- Modal opens when
|
||||
- Modal is not in a failed state
|
||||
- config, params are same as the last time
|
||||
- No threshold violations
|
||||
|
||||
- **Reuse the iframe but refetch 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**:
|
||||
- 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
|
||||
|
||||
@@ -10,7 +10,8 @@ You can also see various example usages [here](https://github.com/calcom/cal.com
|
||||
|
||||
## Development
|
||||
|
||||
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100`
|
||||
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100/embed/`
|
||||
We have another html page to specifically test prerendering when using headless router in Demo `http://localhost:3100/embed/routing-playground.html`
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
|
||||
@@ -669,6 +669,30 @@ if (only === "all" || only === "ns:skeletonDemoElementClick") {
|
||||
});
|
||||
}
|
||||
|
||||
if (only === "all" || only === "ns:routingFormPrerender") {
|
||||
Cal("init", "routingFormPrerender", {
|
||||
debug: true,
|
||||
origin,
|
||||
});
|
||||
|
||||
Cal.ns.routingFormPrerender("on", {
|
||||
action: "*",
|
||||
callback,
|
||||
});
|
||||
}
|
||||
|
||||
if (only === "all" || only === "ns:routingFormWithoutPrerender") {
|
||||
Cal("init", "routingFormWithoutPrerender", {
|
||||
debug: true,
|
||||
origin,
|
||||
});
|
||||
|
||||
Cal.ns.routingFormWithoutPrerender("on", {
|
||||
action: "*",
|
||||
callback,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep it at the bottom as it works on the API defined above for various cases
|
||||
(function ensureScrolledToCorrectIframe() {
|
||||
// Reset the hash so that we can scroll to correct iframe
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Embed Playground - Routing Form</title>
|
||||
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
|
||||
<!-- <script src="./src/embed.ts" type="module"></script> -->
|
||||
<script>
|
||||
const url = new URL(document.URL);
|
||||
window.only = url.searchParams.get("only")
|
||||
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
|
||||
window.calLink = url.searchParams.get("calLink");
|
||||
|
||||
if (!location.search.includes("nonResponsive")) {
|
||||
document.write('<meta name="viewport" content="width=device-width"/>');
|
||||
}
|
||||
|
||||
(function addParamsAutomatically() {
|
||||
const newSearchParams = new URLSearchParams(location.search);
|
||||
let paramsChanged = false;
|
||||
if (!newSearchParams.has("only")) {
|
||||
newSearchParams.set("only", "ns:routingFormPrerender");
|
||||
paramsChanged = true;
|
||||
}
|
||||
|
||||
if (!newSearchParams.has("calOrigin")) {
|
||||
newSearchParams.set("calOrigin", "http://acme.cal.local:3000");
|
||||
paramsChanged = true;
|
||||
}
|
||||
|
||||
if (!newSearchParams.has("cal.embed.logging")) {
|
||||
newSearchParams.set("cal.embed.logging", "1");
|
||||
paramsChanged = true;
|
||||
}
|
||||
|
||||
if (paramsChanged) {
|
||||
location.href = "?" + newSearchParams.toString();
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
<script>
|
||||
function embedSnippet() {
|
||||
(function (C, A, L) {
|
||||
let p = function (a, ar) {
|
||||
a.q.push(ar);
|
||||
};
|
||||
let d = C.document;
|
||||
C.Cal =
|
||||
C.Cal ||
|
||||
function () {
|
||||
let cal = C.Cal;
|
||||
let ar = arguments;
|
||||
if (!cal.loaded) {
|
||||
cal.ns = {};
|
||||
cal.q = cal.q || [];
|
||||
d.head.appendChild(d.createElement("script")).src = A;
|
||||
cal.loaded = true;
|
||||
}
|
||||
if (ar[0] === L) {
|
||||
const api = function () {
|
||||
p(api, arguments);
|
||||
};
|
||||
const namespace = ar[1];
|
||||
api.q = api.q || [];
|
||||
if (typeof namespace === "string") {
|
||||
// Make sure that even after re-execution of the snippet, the namespace is not overridden
|
||||
cal.ns[namespace] = cal.ns[namespace] || api;
|
||||
p(cal.ns[namespace], ar);
|
||||
p(cal, ['initNamespace', namespace])
|
||||
} else p(cal, ar);
|
||||
return;
|
||||
}
|
||||
p(cal, ar);
|
||||
};
|
||||
})(window, window.calOrigin + "/embed/embed.js", "init");
|
||||
}
|
||||
embedSnippet();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(90deg,
|
||||
rgba(120, 116, 186, 1) 0%,
|
||||
rgba(221, 221, 255, 1) 41%,
|
||||
rgba(148, 232, 249, 1) 100%);
|
||||
}
|
||||
|
||||
.inline-embed-container {
|
||||
/* border: 1px solid black; */
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: green;
|
||||
}
|
||||
|
||||
* {
|
||||
--cal-brand-color: gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div id="cal-booking-place-routingFormPrerender">
|
||||
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Demo</a>
|
||||
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
|
||||
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal</p>
|
||||
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
|
||||
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as threshold of 1 min since last time the modal was shown is met). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
|
||||
<form id="cal-booking-place-routingFormPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
<select name="skills" placeholder="JavaScript, Node.js">
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
</form>
|
||||
<button id="cal-booking-place-routingFormPrerender-submit" data-cal-namespace="routingFormPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
|
||||
<script>
|
||||
document.getElementById("cal-booking-place-routingFormPrerender-form").addEventListener("input", function prerender(e) {
|
||||
if (prerender.initiated) return;
|
||||
prerender.initiated = true;
|
||||
console.log("Playground: prerender initiated");
|
||||
Cal.ns.routingFormPrerender("prerender", {
|
||||
calLink: "team/insights-team/team-javascript",
|
||||
type: "modal",
|
||||
pageType: "user.event.booking.slots",
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(function updateSubmitButtonLink() {
|
||||
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
|
||||
const form = document.getElementById("cal-booking-place-routingFormPrerender-form");
|
||||
const name = form.querySelector("input[name='name']").value;
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<div id="cal-booking-place-routingFormWithoutPrerender">
|
||||
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
|
||||
<form id="cal-booking-place-routingFormWithoutPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
<select name="skills" placeholder="JavaScript, Node.js">
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
</form>
|
||||
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
|
||||
<script>
|
||||
requestAnimationFrame(function updateSubmitButtonLink() {
|
||||
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
|
||||
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
|
||||
const name = form.querySelector("input[name='name']").value;
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./playground.ts"></script>
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -165,12 +165,24 @@ export class EmbedElement extends HTMLElement {
|
||||
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
||||
}
|
||||
|
||||
public isSkeletonLoaderVisible() {
|
||||
const skeletonEl = this.getSkeletonElement();
|
||||
// Comparing with "none" which is set by toggleLoader when skeleton is hidden
|
||||
return skeletonEl.style.display !== "none";
|
||||
}
|
||||
|
||||
public resizeHandler() {
|
||||
const newLayout = getTrueLayout({ layout: this.layout ?? null });
|
||||
if (newLayout === this.layout) {
|
||||
return;
|
||||
}
|
||||
this.layout = newLayout;
|
||||
|
||||
// We can't accidentaly show skeleton if it isn't showing
|
||||
if (!this.isSkeletonLoaderVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { skeletonContent, skeletonContainerStyle, skeletonStyle } = this.getSkeletonData({
|
||||
layout: this.getLayout(),
|
||||
pageType: this.getPageType() ?? null,
|
||||
|
||||
@@ -22,7 +22,10 @@ export class Inline extends EmbedElement {
|
||||
this.toggleLoader(false);
|
||||
slotEl.style.visibility = "hidden";
|
||||
errorEl.style.display = "block";
|
||||
const errorString = getErrorString(this.dataset.errorCode);
|
||||
const errorString = getErrorString({
|
||||
errorCode: this.dataset.errorCode,
|
||||
errorMessage: this.dataset.message,
|
||||
});
|
||||
errorEl.innerText = errorString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export class ModalBox extends EmbedElement {
|
||||
return ["state"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the modal box, regardless of anything else - Kind of like forced open
|
||||
*/
|
||||
show(show: boolean) {
|
||||
this.assertHasShadowRoot();
|
||||
// We can't make it display none as that takes iframe width and height calculations to 0
|
||||
@@ -21,7 +24,14 @@ export class ModalBox extends EmbedElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal box if it makes sense to do so.
|
||||
*/
|
||||
open() {
|
||||
if (this.getAttribute("state") === "prerendering") {
|
||||
// You can't show a modal thats prerendering or prerendered
|
||||
return;
|
||||
}
|
||||
this.show(true);
|
||||
const event = new Event("open");
|
||||
this.dispatchEvent(event);
|
||||
@@ -29,30 +39,71 @@ export class ModalBox extends EmbedElement {
|
||||
|
||||
private isLoaderRunning() {
|
||||
const state = this.getAttribute("state");
|
||||
return !state || state === "loading" || state === "reopening";
|
||||
return !state || state === "loading";
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal box - It is like a forced close
|
||||
*/
|
||||
private explicitClose() {
|
||||
this.show(false);
|
||||
const event = new Event("close");
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
isShowingMessage() {
|
||||
const currentState = this.getAttribute("state");
|
||||
return currentState === "has-message" || currentState === "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal box if it makes sense to do so.
|
||||
*/
|
||||
close() {
|
||||
if (this.isLoaderRunning()) {
|
||||
// We want to avoid accidentally closing the modal through Esc, Click outside, etc.
|
||||
if (this.isLoaderRunning() || this.isShowingMessage()) {
|
||||
return;
|
||||
}
|
||||
this.explicitClose();
|
||||
}
|
||||
|
||||
hideIframe() {
|
||||
/**
|
||||
* Takes the iframe out of layout and hides it
|
||||
*/
|
||||
collapseIframe() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
iframe.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the iframe back in layout and make it visible
|
||||
*/
|
||||
uncollapseIframe() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
// Resets the display to its default value
|
||||
iframe.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the iframe invisible but stays in layout
|
||||
*/
|
||||
makeIframeInvisible() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
iframe.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
showIframe() {
|
||||
/**
|
||||
* Make the iframe visible, doesn't affect layout
|
||||
*
|
||||
* Iframe on creation is invisible
|
||||
*/
|
||||
makeIframeVisible() {
|
||||
const iframe = this.querySelector("iframe");
|
||||
if (iframe) {
|
||||
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
|
||||
@@ -60,40 +111,108 @@ export class ModalBox extends EmbedElement {
|
||||
}
|
||||
}
|
||||
|
||||
getErrorElement(): HTMLElement {
|
||||
getMessageElement(): HTMLElement {
|
||||
this.assertHasShadowRoot();
|
||||
const element = this.shadowRoot.querySelector<HTMLElement>("#error");
|
||||
const element = this.shadowRoot.querySelector<HTMLElement>("#message");
|
||||
|
||||
if (!element) {
|
||||
throw new Error("No error element");
|
||||
throw new Error("No message element");
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
getMessageContainerElement(): HTMLElement {
|
||||
this.assertHasShadowRoot();
|
||||
const element = this.shadowRoot.querySelector<HTMLElement>("#message-container");
|
||||
|
||||
if (!element) {
|
||||
throw new Error("No message container element");
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
toggleMessageElement(show: boolean) {
|
||||
const errorContainerElement = this.getMessageContainerElement();
|
||||
if (show) {
|
||||
errorContainerElement.style.display = "";
|
||||
} else {
|
||||
errorContainerElement.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
ensureIframeFullyVisible() {
|
||||
// Iframe when added is invisible, so we ensure that it is visible
|
||||
this.makeIframeVisible();
|
||||
// Iframe is collapsed(doesn't take any space) when error is to be shown, so we ensure that it is uncollapsed
|
||||
this.uncollapseIframe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the state is "loaded"
|
||||
*/
|
||||
onStateLoaded() {
|
||||
// Hide Loader
|
||||
this.toggleLoader(false);
|
||||
// Message is shown either in "failed" or "has-message" state, so we hide it here
|
||||
this.toggleMessageElement(false);
|
||||
|
||||
// Open Modal
|
||||
this.open();
|
||||
// Ensure Iframe is fully visible
|
||||
this.ensureIframeFullyVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the state is "failed" or "has-message"
|
||||
*/
|
||||
onStateFailedOrMessage() {
|
||||
this.toggleLoader(false);
|
||||
this.toggleMessageElement(true);
|
||||
this.collapseIframe();
|
||||
|
||||
const message = this.dataset.message;
|
||||
const errorMessage = this.dataset.errorCode
|
||||
? getErrorString({
|
||||
errorCode: this.dataset.errorCode,
|
||||
errorMessage: message,
|
||||
})
|
||||
: null;
|
||||
const messageToShow = errorMessage || message;
|
||||
if (messageToShow) {
|
||||
this.getMessageElement().innerText = messageToShow;
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||
if (name !== "state") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === "loading") {
|
||||
this.open();
|
||||
this.hideIframe();
|
||||
this.toggleLoader(true);
|
||||
} else if (newValue == "loaded" || newValue === "reopening") {
|
||||
this.open();
|
||||
this.showIframe();
|
||||
this.toggleLoader(false);
|
||||
|
||||
// Ensure iframe takes its available space as it could be possible that we are moving from "failed" state to "loading" state or some other case.
|
||||
this.uncollapseIframe();
|
||||
this.toggleMessageElement(false);
|
||||
|
||||
// Keep iframe invisible as it will be made visible when the state changes to "loaded"
|
||||
this.makeIframeInvisible();
|
||||
} else if (newValue == "loaded") {
|
||||
this.onStateLoaded();
|
||||
} else if (newValue == "closed") {
|
||||
this.explicitClose();
|
||||
} else if (newValue === "failed") {
|
||||
this.getLoaderElement().style.display = "none";
|
||||
this.getSkeletonElement().style.display = "none";
|
||||
this.getErrorElement().style.display = "inline-block";
|
||||
const errorString = getErrorString(this.dataset.errorCode);
|
||||
this.getErrorElement().innerText = errorString;
|
||||
} else if (newValue === "failed" || newValue === "has-message") {
|
||||
this.onStateFailedOrMessage();
|
||||
} else if (newValue === "prerendering") {
|
||||
// We do a close here because we don't want the loaders to show up when the modal is prerendering
|
||||
// As per HTML, both skeleton/loader are configured to be shown by default, so we need to hide them and infact we don't want to show up anything unexpected so we completely hide the customElement itself
|
||||
this.explicitClose();
|
||||
} else if (newValue === "reopened") {
|
||||
// Show in whatever state it is
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ function getStyle() {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
min-height: 200px;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
float:right;
|
||||
@@ -58,12 +63,17 @@ const html = ({
|
||||
layout,
|
||||
pageType,
|
||||
});
|
||||
|
||||
// Keep message-container outside modal-box as that restricts the content to be shown through its overflow:auto unnecessarily
|
||||
return `
|
||||
${getStyle()}
|
||||
<div class="my-backdrop">
|
||||
<div class="header">
|
||||
<button type="button" class="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="message-container" style="left: 50%; top: 50%; transform: translate(-50%, -50%);" class="message-container flex items-center p-24 justify-center dark:bg-muted rounded-md border-subtle border bg-default text-default absolute z-highest">
|
||||
<div id="message"></div>
|
||||
</div>
|
||||
<div class="modal-box">
|
||||
<div class="body" id="skeleton-container" style="${skeletonContainerStyle}">
|
||||
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
|
||||
@@ -74,10 +84,10 @@ ${getStyle()}
|
||||
<div id="skeleton" style="${skeletonStyle}" class="absolute z-highest">
|
||||
${skeletonContent}
|
||||
</div>
|
||||
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { embedStore, getEmbedBookerState } from "../embed-iframe";
|
||||
|
||||
// Test helper functions
|
||||
type fakeCurrentDocumentUrlParams = {
|
||||
origin?: string;
|
||||
path?: string;
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
function fakeCurrentDocumentUrl({
|
||||
origin = "https://example.com",
|
||||
path = "",
|
||||
params = {},
|
||||
}: fakeCurrentDocumentUrlParams = {}) {
|
||||
const url = new URL(path, origin);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
return mockDocumentUrl(url);
|
||||
}
|
||||
|
||||
function mockDocumentUrl(url: URL | string) {
|
||||
return vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
|
||||
}
|
||||
|
||||
function createSearchParams(params: Record<string, string>) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
searchParams.set(key, value);
|
||||
});
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
describe("embedStore.router.ensureQueryParamsInUrl", () => {
|
||||
// Mock window.history and URL
|
||||
|
||||
const originalHistory = window.history;
|
||||
const originalURL = window.URL;
|
||||
|
||||
function nextTick() {
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Mock requestAnimationFrame and cancelAnimationFrame
|
||||
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
console.log("mockRequestAnimationFrame called");
|
||||
const timeoutId = setTimeout(() => {
|
||||
callback(performance.now());
|
||||
}, 100) as unknown as number;
|
||||
return timeoutId;
|
||||
});
|
||||
|
||||
// Mock history.replaceState
|
||||
window.history.replaceState = (...args) => {
|
||||
const url = args[2];
|
||||
if (!url) {
|
||||
throw new Error("url is not provided");
|
||||
}
|
||||
vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
// Cleanup
|
||||
window.history = originalHistory;
|
||||
window.URL = originalURL;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should add missing parameters to URL", async () => {
|
||||
// Setup
|
||||
fakeCurrentDocumentUrl();
|
||||
|
||||
// Execute
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: {
|
||||
theme: "dark",
|
||||
layout: "month",
|
||||
},
|
||||
toRemoveParams: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(document.URL).toContain("theme=dark");
|
||||
expect(document.URL).toContain("layout=month");
|
||||
|
||||
// Cleanup
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
});
|
||||
|
||||
it("should ensure that no existing value of param exists as is", () => {
|
||||
fakeCurrentDocumentUrl({ params: { guest: "initial" } });
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: { guest: ["ax.com", "bx.com"] },
|
||||
toRemoveParams: [],
|
||||
});
|
||||
expect(document.URL).toContain("guest=ax.com");
|
||||
expect(document.URL).toContain("guest=bx.com");
|
||||
expect(document.URL).not.toContain("guest=initial");
|
||||
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
});
|
||||
|
||||
it("should remove specified parameters from URL", async () => {
|
||||
// Setup
|
||||
fakeCurrentDocumentUrl({ params: { remove: "true", keep: "yes" } });
|
||||
|
||||
// Execute
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: {},
|
||||
toRemoveParams: ["remove"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(document.URL).not.toContain("remove=true");
|
||||
expect(document.URL).toContain("keep=yes");
|
||||
|
||||
// Cleanup
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
});
|
||||
|
||||
it("should handle empty parameters", async () => {
|
||||
fakeCurrentDocumentUrl();
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: {},
|
||||
toRemoveParams: [],
|
||||
});
|
||||
nextTick();
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
});
|
||||
|
||||
it("should restore parameters if they are changed before cleanup, otherwise not", async () => {
|
||||
fakeCurrentDocumentUrl();
|
||||
const initialTheme = "dark";
|
||||
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: { theme: initialTheme },
|
||||
toRemoveParams: [],
|
||||
});
|
||||
|
||||
// First interval - should add the parameter
|
||||
expect(document.URL).toContain(`theme=${initialTheme}`);
|
||||
|
||||
const changedThemeByReact = "light";
|
||||
// Simulate React code changing the URL
|
||||
fakeCurrentDocumentUrl({ params: { theme: changedThemeByReact } });
|
||||
|
||||
// Next interval - should restore our parameter
|
||||
nextTick();
|
||||
expect(document.URL).toContain(`theme=${initialTheme}`);
|
||||
|
||||
// After cleanup, changes should not be restored
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
fakeCurrentDocumentUrl({ params: { theme: "light" } });
|
||||
nextTick();
|
||||
expect(document.URL).toContain("theme=light");
|
||||
expect(document.URL).not.toContain(`theme=${initialTheme}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmbedBookerState", () => {
|
||||
it("should return 'initializing' when bookerState is 'loading'", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "loading",
|
||||
slotsQuery: {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("initializing");
|
||||
});
|
||||
|
||||
it("should return 'slotsLoading' when slotsQuery.isLoading is true", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "selecting_date",
|
||||
slotsQuery: {
|
||||
isLoading: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("slotsLoading");
|
||||
});
|
||||
|
||||
it("should return 'slotsDone' when slotsQuery.isPending is true but not loading", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "selecting_date",
|
||||
slotsQuery: {
|
||||
isLoading: false,
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("slotsDone");
|
||||
});
|
||||
|
||||
it("should return 'slotsDone' when slotsQuery.isSuccess is true", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "selecting_date",
|
||||
slotsQuery: {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("slotsDone");
|
||||
});
|
||||
|
||||
it("should return 'slotsLoadingError' when slotsQuery.isError is true", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "selecting_date",
|
||||
slotsQuery: {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("slotsLoadingError");
|
||||
});
|
||||
|
||||
it("should return 'slotsPending' when no other conditions are met", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "selecting_date",
|
||||
slotsQuery: {
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("slotsPending");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateDataAttributes } from "../utils";
|
||||
import { generateDataAttributes, isSameBookingLink } from "../utils";
|
||||
|
||||
describe("generateDataAttributes", () => {
|
||||
it("should handle PascalCase property names correctly", () => {
|
||||
@@ -59,3 +59,41 @@ describe("generateDataAttributes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameBookingLink", () => {
|
||||
it("should return true for same booking link", () => {
|
||||
expect(
|
||||
isSameBookingLink({
|
||||
bookingLinkPath1: "/team/event-booking-url",
|
||||
bookingLinkPath2: "/team/event-booking-url",
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameBookingLink({
|
||||
bookingLinkPath1: "/team/team1/event-booking-url",
|
||||
bookingLinkPath2: "/team/team1/event-booking-url",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for different booking links", () => {
|
||||
expect(
|
||||
isSameBookingLink({
|
||||
bookingLinkPath1: "/team/event-booking-url",
|
||||
bookingLinkPath2: "/team/event-booking-url-2",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for same booking links with /team prefix in them", () => {
|
||||
expect(isSameBookingLink({ bookingLinkPath1: "/team/sales/demo", bookingLinkPath2: "/sales/demo" })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isSameBookingLink({
|
||||
bookingLinkPath1: "/team1/event-booking-url",
|
||||
bookingLinkPath2: "/team/team1/event-booking-url",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// We can't keep it too high because that might cause stale content to be shown
|
||||
// TODO: We should introduce a slotRefresh mechanism
|
||||
export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 10 * 1000; // 1 minute
|
||||
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
|
||||
throw new Error(
|
||||
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
import type { Message } from "./embed";
|
||||
@@ -11,9 +10,25 @@ import type {
|
||||
EmbedNonStylesConfig,
|
||||
BookerLayouts,
|
||||
EmbedStyles,
|
||||
KnownConfig,
|
||||
EmbedBookerState,
|
||||
SlotsQuery,
|
||||
PrefillAndIframeAttrsConfig,
|
||||
} from "./types";
|
||||
import { useCompatSearchParams } from "./useCompatSearchParams";
|
||||
import { isParamValuePresentInUrlSearchParams } from "./utils";
|
||||
|
||||
// We don't import it from Booker/types because the types from this module are published to npm and we can't import packages that aren't published
|
||||
type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
|
||||
|
||||
// Prerendering is a hidden process and we shouldn't really track any events from it unless absolutely necessary
|
||||
const eventsAllowedInPrerendering = [
|
||||
// so that Postmessage communication starts
|
||||
"__iframeReady",
|
||||
// so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed
|
||||
"__dimensionChanged",
|
||||
|
||||
// For other events, we should consider introducing prerender specific events and not reuse existing events
|
||||
];
|
||||
|
||||
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
@@ -22,16 +37,6 @@ const enum EMBED_IFRAME_STATE {
|
||||
INITIALIZED,
|
||||
}
|
||||
|
||||
/**
|
||||
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
|
||||
*/
|
||||
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
|
||||
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
|
||||
iframeAttrs?: Record<string, string> & {
|
||||
id?: string;
|
||||
};
|
||||
} & KnownConfig;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CalEmbed: {
|
||||
@@ -40,32 +45,89 @@ declare global {
|
||||
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
|
||||
};
|
||||
// Marks that Booker has moved to some non-"loading" state
|
||||
_embedBookerState?: "initializing" | "done";
|
||||
_embedBookerState?: EmbedBookerState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
||||
*/
|
||||
const embedStore = {
|
||||
export const embedStore = {
|
||||
connectVersion: 0 as number,
|
||||
/**
|
||||
* Tracks whether the prerender has been completed or not.
|
||||
* NOTE: prerenderState would be "completed" even after the iframe was switched from isPrerendering=true to not Prerendering(which happens after connect)
|
||||
*/
|
||||
prerenderState: null as null | "inProgress" | "completed",
|
||||
|
||||
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
|
||||
router: {
|
||||
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
|
||||
this.nextRouter = nextRouter;
|
||||
/**
|
||||
* When we do the history push, it is possible that
|
||||
* - React might revert that change depending on in what state React is in while initializing
|
||||
* - So, we use a declarative approach to ensure that our requirement is continuously met
|
||||
*/
|
||||
ensureQueryParamsInUrl({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
toBeThereParams: Record<string, string | string[]>;
|
||||
toRemoveParams: string[];
|
||||
}) {
|
||||
let stopUpdating = false;
|
||||
function updateIfNeeded() {
|
||||
if (stopUpdating) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
const currentUrl = new URL(document.URL);
|
||||
let hasChanged = false;
|
||||
|
||||
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
|
||||
this.queue.forEach((url) => {
|
||||
nextRouter.push(url);
|
||||
this.queue.splice(0, 1);
|
||||
});
|
||||
},
|
||||
nextRouter: null as null | ReturnType<typeof useRouter>,
|
||||
queue: [] as string[],
|
||||
goto(url: string) {
|
||||
if (this.nextRouter) {
|
||||
this.nextRouter.push(url.toString());
|
||||
} else {
|
||||
this.queue.push(url);
|
||||
// Ensuring toBeThereSearchParams
|
||||
for (const [key, newValue] of Object.entries(toBeThereParams)) {
|
||||
// It checks that the value must be present and if an array no other item should be there except those in newValue
|
||||
hasChanged = !isParamValuePresentInUrlSearchParams({
|
||||
param: key,
|
||||
value: newValue,
|
||||
container: currentUrl.searchParams,
|
||||
});
|
||||
if (hasChanged) {
|
||||
setParamInUrl({ key, value: newValue, url: currentUrl });
|
||||
}
|
||||
}
|
||||
|
||||
removeParamsFromUrl({ keys: toRemoveParams, url: currentUrl });
|
||||
|
||||
hasChanged = hasChanged || toRemoveParams.length > 0;
|
||||
if (hasChanged) {
|
||||
// Avoid unnecessary history push
|
||||
window.history.replaceState({}, "", currentUrl.toString());
|
||||
}
|
||||
requestAnimationFrame(updateIfNeeded);
|
||||
return {
|
||||
hasChanged,
|
||||
};
|
||||
}
|
||||
const { hasChanged } = updateIfNeeded();
|
||||
return {
|
||||
stopEnsuringQueryParamsInUrl: () => {
|
||||
stopUpdating = true;
|
||||
},
|
||||
hasChanged,
|
||||
};
|
||||
|
||||
function removeParamsFromUrl({ keys, url }: { keys: string[]; url: URL }) {
|
||||
for (const key of keys) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function setParamInUrl({ key, value, url }: { key: string; value: string | string[]; url: URL }) {
|
||||
// Reset and then set the new value, to ensure nothing else remains in value
|
||||
url.searchParams.delete(key);
|
||||
const newValueArray = Array.isArray(value) ? value : [value];
|
||||
newValueArray.forEach((val) => {
|
||||
url.searchParams.append(key, val);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -125,7 +187,7 @@ function log(...args: unknown[]) {
|
||||
args.unshift("CAL:");
|
||||
logQueue.push(args);
|
||||
if (searchParams.get("debug")) {
|
||||
console.log(...args);
|
||||
console.log("Child:", ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,8 +261,6 @@ const useUrlChange = (callback: (newUrl: string) => void) => {
|
||||
const pathname = currentFullUrl?.pathname ?? "";
|
||||
const searchParams = currentFullUrl?.searchParams ?? null;
|
||||
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
|
||||
const router = useRouter();
|
||||
embedStore.router.setNextRouter(router);
|
||||
useEffect(() => {
|
||||
const newUrl = `${pathname}?${searchParams}`;
|
||||
if (lastKnownUrl.current !== newUrl) {
|
||||
@@ -309,8 +369,25 @@ function getEmbedType() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
|
||||
*/
|
||||
function isLinkReady() {
|
||||
if (!embedStore.parentInformedAboutContentHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBookerPage()) {
|
||||
// Let's wait for Booker to be ready before showing the embed
|
||||
// It means that booker has loaded all its data and is ready to show
|
||||
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
|
||||
return isBookerReady();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBookerReady() {
|
||||
return window._embedBookerState === "done";
|
||||
return window._embedBookerState === "slotsDone";
|
||||
}
|
||||
|
||||
function isBookerPage() {
|
||||
@@ -340,16 +417,33 @@ export const useEmbedType = () => {
|
||||
return state;
|
||||
};
|
||||
|
||||
function unhideBody() {
|
||||
function makeBodyVisible() {
|
||||
if (document.body.style.visibility !== "visible") {
|
||||
document.body.style.visibility = "visible";
|
||||
}
|
||||
// Ensure that it stays visible and not reverted by React
|
||||
runAsap(() => {
|
||||
unhideBody();
|
||||
makeBodyVisible();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On an embed page, there are two changes done
|
||||
* - Body is made invisible
|
||||
* - Background is set to transparent
|
||||
*
|
||||
* This function reverses both of them
|
||||
*/
|
||||
function showPageAsNonEmbed() {
|
||||
makeBodyVisible();
|
||||
resetTransparentBackground();
|
||||
function resetTransparentBackground() {
|
||||
if (document.body.style.background === "transparent") {
|
||||
document.body.style.background = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
|
||||
const methods = {
|
||||
ui: function style(uiConfig: UiConfig) {
|
||||
@@ -401,43 +495,62 @@ const methods = {
|
||||
log("Method: `parentKnowsIframeReady` called");
|
||||
|
||||
runAsap(function tryInformingLinkReady() {
|
||||
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
|
||||
if (!embedStore.parentInformedAboutContentHeight) {
|
||||
runAsap(tryInformingLinkReady);
|
||||
return;
|
||||
}
|
||||
|
||||
// Let's wait for Booker to be ready before showing the embed
|
||||
// It means that booker has loaded all its data and is ready to show
|
||||
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
|
||||
if (isBookerPage() && !isBookerReady()) {
|
||||
if (!isLinkReady()) {
|
||||
runAsap(tryInformingLinkReady);
|
||||
return;
|
||||
}
|
||||
|
||||
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
|
||||
unhideBody();
|
||||
if (!isPrerendering()) {
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
// Embed background must still remain transparent
|
||||
makeBodyVisible();
|
||||
if (isPrerendering()) {
|
||||
log("prerenderState is 'completed'");
|
||||
embedStore.prerenderState = "completed";
|
||||
}
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
});
|
||||
},
|
||||
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
|
||||
const currentUrl = new URL(document.URL);
|
||||
const searchParams = currentUrl.searchParams;
|
||||
searchParams.delete("preload");
|
||||
for (const [key, value] of Object.entries(queryObject)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Array) {
|
||||
value.forEach((val) => searchParams.append(key, val));
|
||||
} else {
|
||||
searchParams.set(key, value as string);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Connects new config to prerendered page
|
||||
*/
|
||||
connect: function connect({
|
||||
config,
|
||||
params,
|
||||
}: {
|
||||
config: PrefillAndIframeAttrsConfig;
|
||||
// This is basically searchParams simplified as Record<string, string | string[]>
|
||||
// So a=1&a=2&b=3 would be {a: ["1", "2"], b: "3"}
|
||||
// We can't accept URLSearchParams as it isn't cloneable and thus postMessage doesn't support it
|
||||
params: Record<string, string | string[]>;
|
||||
}) {
|
||||
log("Method: connect, requested with params", { config, params });
|
||||
const { iframeAttrs: _1, ...queryParamsFromConfig } = config;
|
||||
const connectVersion = (embedStore.connectVersion = embedStore.connectVersion + 1);
|
||||
// We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config
|
||||
embedStore.parentInformedAboutContentHeight = false;
|
||||
|
||||
connectPreloadedEmbed({ url: currentUrl });
|
||||
// Config is just a typed and more declarative way to pass the query params from the parent(except iframeAttrs which is meant to be consumed by parent and not supposed to passed to child)
|
||||
// So, query params can come directly by providing them to calLink or through config
|
||||
const toBeThereParams = {
|
||||
...params,
|
||||
// Query params from config takes precedence over query params in url
|
||||
...(queryParamsFromConfig as Record<string, string | string[]>),
|
||||
"cal.embed.connectVersion": connectVersion.toString(),
|
||||
};
|
||||
|
||||
(function tryToConnect() {
|
||||
if (embedStore.prerenderState !== "completed") {
|
||||
runAsap(tryToConnect);
|
||||
return;
|
||||
}
|
||||
|
||||
log("Method: connect, prerenderState is completed. Connecting");
|
||||
connectPreloadedEmbed({
|
||||
// We know after removing iframeAttrs, that it is of this type
|
||||
toBeThereParams,
|
||||
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
|
||||
});
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -457,6 +570,10 @@ const messageParent = (data: CustomEvent["detail"]) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is called once the iframe loads.
|
||||
* It isn't called on "connect"
|
||||
*/
|
||||
function keepParentInformedAboutDimensionChanges() {
|
||||
let knownIframeHeight: number | null = null;
|
||||
let knownIframeWidth: number | null = null;
|
||||
@@ -515,13 +632,14 @@ function keepParentInformedAboutDimensionChanges() {
|
||||
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
|
||||
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
|
||||
|
||||
embedStore.parentInformedAboutContentHeight = true;
|
||||
|
||||
if (!iframeHeight || !iframeWidth) {
|
||||
runAsap(informAboutScroll);
|
||||
return;
|
||||
}
|
||||
if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) {
|
||||
const isThereAChangeInDimensions = knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth;
|
||||
if (isThereAChangeInDimensions || !embedStore.parentInformedAboutContentHeight) {
|
||||
embedStore.parentInformedAboutContentHeight = true;
|
||||
|
||||
knownIframeHeight = iframeHeight;
|
||||
knownIframeWidth = iframeWidth;
|
||||
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
|
||||
@@ -556,7 +674,7 @@ function main() {
|
||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
||||
if (top === window) {
|
||||
unhideBody();
|
||||
showPageAsNonEmbed();
|
||||
// We would want to avoid a situation where Cal.com embeds cal.com and then embed-iframe is in the top as well. In such case, we would want to avoid infinite loop of events being passed.
|
||||
log("Embed SDK Skipped as we are in top");
|
||||
return;
|
||||
@@ -588,6 +706,9 @@ function main() {
|
||||
});
|
||||
|
||||
sdkActionManager?.on("*", (e) => {
|
||||
if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) {
|
||||
return;
|
||||
}
|
||||
const detail = e.detail;
|
||||
log(detail);
|
||||
messageParent(detail);
|
||||
@@ -601,7 +722,13 @@ function main() {
|
||||
}
|
||||
|
||||
function initializeAndSetupEmbed() {
|
||||
sdkActionManager?.fire("__iframeReady", {});
|
||||
sdkActionManager?.fire("__iframeReady", {
|
||||
isPrerendering: isPrerendering(),
|
||||
});
|
||||
|
||||
if (isPrerendering()) {
|
||||
embedStore.prerenderState = "inProgress";
|
||||
}
|
||||
|
||||
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
|
||||
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
|
||||
@@ -642,19 +769,100 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||
* url has the config as params
|
||||
*/
|
||||
function connectPreloadedEmbed({ url }: { url: URL }) {
|
||||
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
|
||||
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
|
||||
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
|
||||
embedStore.router.goto(url.toString());
|
||||
setTimeout(() => {
|
||||
// Firing this event would stop the loader and show the embed
|
||||
function connectPreloadedEmbed({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
toBeThereParams: Record<string, string | string[]>;
|
||||
toRemoveParams: string[];
|
||||
}) {
|
||||
const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
});
|
||||
|
||||
let waitForFrames = 0;
|
||||
|
||||
if (isBookerReady() && hasChanged) {
|
||||
// Give some time for react to update state that might lead booker to go to slotsLoading state
|
||||
waitForFrames = 5;
|
||||
}
|
||||
|
||||
// Booker might alreadyu be in slotsDone state. But we don't know if new getTeamSchedule request would intitiate or not. It would initiate when React updates the state but it might not go depending on if there is no actual state change in useSchedule components
|
||||
// But we can know if cal.routedTeamMemberIds is changed. If it is changed, then we reset slotsDone -> slotsLoading.
|
||||
|
||||
// Firing this event would stop the loader and show the embed
|
||||
// This causes loader to go away later.
|
||||
runAsap(function tryToFireLinkReady() {
|
||||
if (!isLinkReady() || waitForFrames > 0) {
|
||||
waitForFrames--;
|
||||
runAsap(tryToFireLinkReady);
|
||||
return;
|
||||
}
|
||||
// link is ready now, so we could stop doing it.
|
||||
// Also the page is visible to user now.
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
|
||||
});
|
||||
}
|
||||
|
||||
const isPrerendering = () => {
|
||||
return new URL(document.URL).searchParams.get("prerender") === "true";
|
||||
};
|
||||
|
||||
export function getEmbedBookerState({
|
||||
bookerState,
|
||||
slotsQuery,
|
||||
}: {
|
||||
bookerState: BookerState;
|
||||
slotsQuery: SlotsQuery;
|
||||
}): EmbedBookerState {
|
||||
if (bookerState === "loading") {
|
||||
return "initializing";
|
||||
}
|
||||
|
||||
if (slotsQuery.isLoading) {
|
||||
return "slotsLoading";
|
||||
}
|
||||
|
||||
// Pending but not loading, it means that request is intentionally disabled via enabled:false in useQuery
|
||||
if (slotsQuery.isPending) {
|
||||
return "slotsDone";
|
||||
}
|
||||
|
||||
if (slotsQuery.isSuccess) {
|
||||
return "slotsDone";
|
||||
}
|
||||
|
||||
if (slotsQuery.isError) {
|
||||
return "slotsLoadingError";
|
||||
}
|
||||
|
||||
return "slotsPending";
|
||||
}
|
||||
|
||||
/**
|
||||
* It is meant to sync BookerState to EmbedBookerState
|
||||
* This function is meant to be called outside useEffect so that we don't wait for React to re-render before doing our work
|
||||
*/
|
||||
export function updateEmbedBookerState({
|
||||
bookerState,
|
||||
slotsQuery,
|
||||
}: {
|
||||
bookerState: BookerState;
|
||||
slotsQuery: SlotsQuery;
|
||||
}) {
|
||||
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const _window = window as Window & {
|
||||
_embedBookerState?: EmbedBookerState;
|
||||
};
|
||||
|
||||
const embedBookerState = getEmbedBookerState({ bookerState, slotsQuery });
|
||||
_window._embedBookerState = embedBookerState;
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,29 @@ import { FloatingButton } from "./FloatingButton/FloatingButton";
|
||||
import { Inline } from "./Inline/inline";
|
||||
import { ModalBox } from "./ModalBox/ModalBox";
|
||||
import { addAppCssVars } from "./addAppCssVars";
|
||||
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
} 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 } from "./types";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
|
||||
import { getMaxHeightForModal } from "./ui-utils";
|
||||
import { fromEntriesWithDuplicateKeys, getConfigProp, generateDataAttributes } from "./utils";
|
||||
|
||||
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||
import {
|
||||
fromEntriesWithDuplicateKeys,
|
||||
isRouterPath,
|
||||
submitResponseAndGetRoutingResult,
|
||||
generateDataAttributes,
|
||||
getConfigProp,
|
||||
isSameBookingLink,
|
||||
} from "./utils";
|
||||
|
||||
// Exporting for consumption by @calcom/embed-core user
|
||||
export type { EmbedEvent } from "./sdk-action-manager";
|
||||
|
||||
export type { PrefillAndIframeAttrsConfig } from "./types";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
|
||||
export type Message = {
|
||||
@@ -36,13 +45,13 @@ customElements.define("cal-inline", Inline);
|
||||
|
||||
declare module "*.css";
|
||||
type Namespace = string;
|
||||
type InitConfig = {
|
||||
type CalConfig = {
|
||||
calOrigin: string;
|
||||
debug?: boolean;
|
||||
uiDebug?: boolean;
|
||||
};
|
||||
|
||||
type InitArgConfig = Partial<InitConfig> & {
|
||||
type InitArgConfig = Partial<CalConfig> & {
|
||||
origin?: string;
|
||||
};
|
||||
|
||||
@@ -193,7 +202,7 @@ type PrefillAndIframeAttrsConfigWithGuestAndColorScheme = PrefillAndIframeAttrsC
|
||||
export class Cal {
|
||||
iframe?: HTMLIFrameElement;
|
||||
|
||||
__config: InitConfig;
|
||||
__config: CalConfig;
|
||||
|
||||
modalBox?: Element;
|
||||
|
||||
@@ -209,10 +218,14 @@ export class Cal {
|
||||
|
||||
api: CalApi;
|
||||
|
||||
isPerendering?: boolean;
|
||||
isPrerendering?: boolean;
|
||||
|
||||
static actionsManagers: Record<Namespace, SdkActionManager>;
|
||||
|
||||
// Store calLink separately and not rely on deriving it from iframe.src, because we could load different URL in iframe(derived from calLink e.g. calLink=Router -> redirects to eventBookingUrl and then we load that URL in iframe)
|
||||
calLink: string | null = null;
|
||||
embedConfig: PrefillAndIframeAttrsConfig | null = null;
|
||||
// Tracks the time when the embed was last rendered with some changes to iframe i.e. it identifies if the iframe is freshly updated and when
|
||||
embedRenderStartTime: number | null = null;
|
||||
static ensureGuestKey(config: PrefillAndIframeAttrsConfig) {
|
||||
config = config || {};
|
||||
return {
|
||||
@@ -237,7 +250,7 @@ export class Cal {
|
||||
const [method, ...args] = instruction;
|
||||
if (!this.api[method]) {
|
||||
// Instead of throwing error, log and move forward in the queue
|
||||
log(`Instruction ${method} not FOUND`);
|
||||
error(`Instruction ${method} not FOUND`);
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -245,7 +258,7 @@ export class Cal {
|
||||
this.api[method](...args);
|
||||
} catch (e) {
|
||||
// Instead of throwing error, log and move forward in the queue
|
||||
log(`Instruction couldn't be executed`, e);
|
||||
error(`Instruction couldn't be executed`, e);
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
@@ -280,7 +293,25 @@ export class Cal {
|
||||
iframe.className = "cal-embed";
|
||||
iframe.name = `cal-embed=${this.namespace}`;
|
||||
iframe.title = `Book a call`;
|
||||
const embedConfig = this.getInitConfig();
|
||||
|
||||
this.loadInIframe({ calLink, config, calOrigin, iframe });
|
||||
return iframe;
|
||||
}
|
||||
|
||||
loadInIframe({
|
||||
calLink,
|
||||
config = {},
|
||||
calOrigin,
|
||||
iframe,
|
||||
}: {
|
||||
iframe: HTMLIFrameElement;
|
||||
calLink: string;
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
calOrigin: string | null;
|
||||
}) {
|
||||
log("Loading in iframe", calLink);
|
||||
iframe.dataset.calLink = calLink;
|
||||
const calConfig = this.getCalConfig();
|
||||
const { iframeAttrs, ...queryParamsFromConfig } = config;
|
||||
|
||||
if (iframeAttrs && iframeAttrs.id) {
|
||||
@@ -290,10 +321,11 @@ export class Cal {
|
||||
const searchParams = this.buildFilteredQueryParams(queryParamsFromConfig);
|
||||
|
||||
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
|
||||
const originToUse = (calOrigin || embedConfig.calOrigin || "").replace(
|
||||
const originToUse = (calOrigin || calConfig.calOrigin || "").replace(
|
||||
"https://cal.com",
|
||||
"https://app.cal.com"
|
||||
);
|
||||
|
||||
const urlInstance = new URL(`${originToUse}/${calLink}`);
|
||||
if (!urlInstance.pathname.endsWith("embed")) {
|
||||
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
|
||||
@@ -302,14 +334,14 @@ export class Cal {
|
||||
|
||||
urlInstance.searchParams.set("embed", this.namespace);
|
||||
|
||||
if (embedConfig.debug) {
|
||||
urlInstance.searchParams.set("debug", `${embedConfig.debug}`);
|
||||
if (calConfig.debug) {
|
||||
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
|
||||
}
|
||||
|
||||
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
|
||||
iframe.style.visibility = "hidden";
|
||||
|
||||
if (embedConfig.uiDebug) {
|
||||
if (calConfig.uiDebug) {
|
||||
iframe.style.border = "1px solid green";
|
||||
}
|
||||
|
||||
@@ -319,11 +351,26 @@ export class Cal {
|
||||
for (const [key, value] of searchParams) {
|
||||
urlInstance.searchParams.append(key, value);
|
||||
}
|
||||
|
||||
// Very Important:Reset iframe ready flag, as iframe might load a fresh URL and we need to check when it is ready.
|
||||
this.iframeReady = false;
|
||||
|
||||
if (iframe.src === urlInstance.toString()) {
|
||||
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
|
||||
// This param has no other purpose except to ensure forced reload.
|
||||
urlInstance.searchParams.append("__cal.reloadTs", Date.now().toString());
|
||||
}
|
||||
|
||||
iframe.src = urlInstance.toString();
|
||||
return iframe;
|
||||
}
|
||||
|
||||
getInitConfig() {
|
||||
/**
|
||||
* Returns the config applicable to entire Cal namespace.
|
||||
* Individual embeds can have their own embed config passed via `config` prop normally.
|
||||
* Also, calOrigin could be passed individually as well at the moment
|
||||
*/
|
||||
getCalConfig() {
|
||||
return this.__config;
|
||||
}
|
||||
|
||||
@@ -380,15 +427,14 @@ export class Cal {
|
||||
}
|
||||
});
|
||||
|
||||
this.actionManager.on("__iframeReady", () => {
|
||||
this.actionManager.on("__iframeReady", (e) => {
|
||||
this.iframeReady = true;
|
||||
if (this.iframe) {
|
||||
if (this.iframe && !e.detail.data.isPrerendering) {
|
||||
// It's a bit late to make the iframe visible here. We just needed to wait for the HTML tag of the embedded calLink to be rendered(which then informs the browser of the color-scheme)
|
||||
// Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
|
||||
// TODO: Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
|
||||
// But it's okay to do it here for now because the embedded calLink also keeps itself hidden till it receives `parentKnowsIframeReady` message(It has it's own reasons for that)
|
||||
// Once the embedded calLink starts not hiding the document, we should optimize this line to make the iframe visible earlier than this.
|
||||
|
||||
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A paren tof the iframe) has visibility:hidden set. Just reset the visibility to default
|
||||
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A parent of the iframe) has visibility:hidden set. Just reset the visibility to default
|
||||
this.iframe.style.visibility = "";
|
||||
}
|
||||
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
|
||||
@@ -411,10 +457,14 @@ export class Cal {
|
||||
});
|
||||
|
||||
this.actionManager.on("linkReady", () => {
|
||||
if (this.isPerendering) {
|
||||
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
|
||||
if (this.isPrerendering) {
|
||||
// Ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed could show-up without any user action
|
||||
return;
|
||||
}
|
||||
this.iframe!.style.visibility = "";
|
||||
|
||||
// Removes the loader
|
||||
// TODO: We should be using consistent approach of "state" attribute for modalBox and inlineEl.
|
||||
this.modalBox?.setAttribute("state", "loaded");
|
||||
this.inlineEl?.setAttribute("loading", "done");
|
||||
});
|
||||
@@ -424,6 +474,9 @@ export class Cal {
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
if (this.isPrerendering) {
|
||||
return;
|
||||
}
|
||||
this.inlineEl?.setAttribute("data-error-code", e.detail.data.code);
|
||||
this.modalBox?.setAttribute("data-error-code", e.detail.data.code);
|
||||
this.inlineEl?.setAttribute("loading", "failed");
|
||||
@@ -461,13 +514,247 @@ export class Cal {
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
getNextActionForModal({
|
||||
modal,
|
||||
pathWithQueryToLoad,
|
||||
stateData,
|
||||
}: {
|
||||
modal: { uid: string };
|
||||
pathWithQueryToLoad: string;
|
||||
stateData: {
|
||||
embedConfig: PrefillAndIframeAttrsConfig;
|
||||
previousEmbedConfig: PrefillAndIframeAttrsConfig | null;
|
||||
isConnectionInitiated: boolean;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
embedRenderStartTime: number;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
embedConfig,
|
||||
previousEmbedConfig,
|
||||
isConnectionInitiated,
|
||||
previousEmbedRenderStartTime,
|
||||
embedRenderStartTime,
|
||||
} = stateData;
|
||||
const calConfig = this.getCalConfig();
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
|
||||
const urlToLoadObject = new URL(pathWithQueryToLoad, calConfig.calOrigin as string);
|
||||
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${modal.uid}"]`);
|
||||
const urlToLoadPath = urlToLoadObject.pathname;
|
||||
// We only check for path because query params are handled by connect flow
|
||||
// Also origin we assume never changes without page reload of the embedding page
|
||||
const isSameCalLink =
|
||||
lastLoadedPathInIframe &&
|
||||
isSameBookingLink({
|
||||
bookingLinkPath1: lastLoadedPathInIframe,
|
||||
bookingLinkPath2: urlToLoadPath,
|
||||
});
|
||||
|
||||
const lastLoadedUrlInIframeObjectSearchParams = lastLoadedUrlInIframeObject?.searchParams.toString();
|
||||
const urlToLoadObjectSearchParams = urlToLoadObject.searchParams.toString();
|
||||
|
||||
const areSameQueryParams = lastLoadedUrlInIframeObjectSearchParams === urlToLoadObjectSearchParams;
|
||||
|
||||
const isSameConfig =
|
||||
previousEmbedConfig &&
|
||||
isSameEmbedConfig({
|
||||
embedConfig1: previousEmbedConfig,
|
||||
embedConfig2: embedConfig,
|
||||
});
|
||||
const isInFailedState = existingModalEl && existingModalEl.getAttribute("state") === "failed";
|
||||
|
||||
const timeSinceLastRender = previousEmbedRenderStartTime
|
||||
? embedRenderStartTime - previousEmbedRenderStartTime
|
||||
: 0;
|
||||
const crossedReloadThreshold = previousEmbedRenderStartTime
|
||||
? timeSinceLastRender > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
|
||||
: false;
|
||||
|
||||
const areSlotsStale = previousEmbedRenderStartTime
|
||||
? timeSinceLastRender > EMBED_MODAL_IFRAME_SLOT_STALE_TIME
|
||||
: false;
|
||||
|
||||
// Note that we don't worry about change in embed config because that is passed on as query params to the iframe and that is already supported by "connect" flow
|
||||
const isResetNeeded = !isSameCalLink || isInFailedState || crossedReloadThreshold;
|
||||
|
||||
const actionToTake = isResetNeeded
|
||||
? "fullReload"
|
||||
: !isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale
|
||||
? "connect"
|
||||
: "noAction";
|
||||
|
||||
log("Next Modal Action:", actionToTake, {
|
||||
path: {
|
||||
isSame: isSameCalLink,
|
||||
urlToLoadPath,
|
||||
lastLoadedPathInIframe,
|
||||
},
|
||||
config: {
|
||||
isSame: isSameConfig,
|
||||
previousEmbedConfig,
|
||||
embedConfig,
|
||||
},
|
||||
queryParams: {
|
||||
isSame: areSameQueryParams,
|
||||
lastLoadedUrlInIframeObjectSearchParams,
|
||||
urlToLoadObjectSearchParams,
|
||||
},
|
||||
areSlotsStale,
|
||||
crossedReloadThreshold,
|
||||
isInFailedState,
|
||||
isConnectionInitiated,
|
||||
});
|
||||
|
||||
return actionToTake;
|
||||
|
||||
function isSameEmbedConfig({
|
||||
embedConfig1,
|
||||
embedConfig2,
|
||||
}: {
|
||||
embedConfig1: PrefillAndIframeAttrsConfig;
|
||||
embedConfig2: PrefillAndIframeAttrsConfig;
|
||||
}) {
|
||||
if (Object.keys(embedConfig1).length !== Object.keys(embedConfig2).length) {
|
||||
return false;
|
||||
}
|
||||
// Verify the two config have all props as same
|
||||
return Object.keys(embedConfig1).every((key) => {
|
||||
if (typeof embedConfig1[key] !== typeof embedConfig2[key]) {
|
||||
return false;
|
||||
}
|
||||
// Now we know both have same type.
|
||||
const embedConfig1Value = embedConfig1[key];
|
||||
const embedConfig2Value = embedConfig2[key];
|
||||
if (embedConfig1Value instanceof Array && embedConfig2Value instanceof Array) {
|
||||
return (
|
||||
embedConfig1Value.length === embedConfig2Value.length &&
|
||||
embedConfig1Value.every((value: string) => embedConfig2Value.includes(value))
|
||||
);
|
||||
}
|
||||
if (typeof embedConfig1Value === "string") {
|
||||
return embedConfig1Value === embedConfig2Value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last loaded URL in iframe.
|
||||
* Removes /embed from the pathname and returns the origin.
|
||||
*/
|
||||
getLastLoadedLinkInframe() {
|
||||
if (!this.iframe || !this.iframe.dataset.calLink) {
|
||||
return null;
|
||||
}
|
||||
const calLink = this.iframe.dataset.calLink;
|
||||
if (!calLink) {
|
||||
return null;
|
||||
}
|
||||
const urlObject = new URL(calLink, new URL(this.iframe.src).origin);
|
||||
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: {
|
||||
embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme;
|
||||
previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null;
|
||||
embedRenderStartTime: number;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
isConnectionInitiated: boolean;
|
||||
};
|
||||
}) {
|
||||
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
|
||||
const { embedConfig } = stateData;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CalApi {
|
||||
cal: Cal;
|
||||
static initializedNamespaces = [] as string[];
|
||||
modalUid?: string;
|
||||
preloadedModalUid?: string;
|
||||
prerenderedModalUid?: string;
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
@@ -479,7 +766,7 @@ class CalApi {
|
||||
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
|
||||
let initForNamespace = "";
|
||||
if (typeof namespaceOrConfig !== "string") {
|
||||
config = (namespaceOrConfig || {}) as InitConfig;
|
||||
config = (namespaceOrConfig || {}) as CalConfig;
|
||||
} else {
|
||||
initForNamespace = namespaceOrConfig;
|
||||
}
|
||||
@@ -560,7 +847,7 @@ class CalApi {
|
||||
}
|
||||
|
||||
config.embedType = "inline";
|
||||
const calConfig = this.cal.getInitConfig();
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
|
||||
const iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
@@ -654,7 +941,7 @@ class CalApi {
|
||||
dataset["buttonTextColor"] = `${buttonTextColor}`;
|
||||
}
|
||||
|
||||
modal({
|
||||
async modal({
|
||||
calLink,
|
||||
config = {},
|
||||
calOrigin,
|
||||
@@ -665,16 +952,22 @@ class CalApi {
|
||||
calOrigin?: string;
|
||||
__prerender?: boolean;
|
||||
}) {
|
||||
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
|
||||
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
|
||||
// `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.isPerendering = !!__prerender;
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
|
||||
if (__prerender) {
|
||||
// Add preload query param
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
// 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";
|
||||
}
|
||||
|
||||
const configWithGuestKeyAndColorScheme = withColorScheme(
|
||||
@@ -684,29 +977,91 @@ class CalApi {
|
||||
}),
|
||||
containerEl
|
||||
);
|
||||
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
// calOrigin could have been passed as empty string by the user
|
||||
calOrigin = calOrigin || calConfig.calOrigin;
|
||||
|
||||
const embedRenderStartTime = Date.now();
|
||||
const previousEmbedConfig = this.cal.embedConfig;
|
||||
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
|
||||
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
|
||||
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
|
||||
if (existingModalEl) {
|
||||
if (isConnectingToPreloadedModal) {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: configWithGuestKeyAndColorScheme,
|
||||
});
|
||||
this.modalUid = uid;
|
||||
// isConnectionPossible
|
||||
if (!!existingModalEl && !!this.cal.iframe) {
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
log(`Trying to reuse modal ${uid}`);
|
||||
const stateData = {
|
||||
embedConfig: configWithGuestKeyAndColorScheme,
|
||||
previousEmbedConfig,
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
};
|
||||
if (isHeadlessRouterPath) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
return;
|
||||
|
||||
// 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,
|
||||
});
|
||||
} else {
|
||||
existingModalEl.setAttribute("state", "reopening");
|
||||
return;
|
||||
const actionToTake = this.cal.getNextActionForModal({
|
||||
modal: { uid },
|
||||
pathWithQueryToLoad: `${calLinkUrlObject.pathname}${calLinkUrlObject.search}`,
|
||||
stateData,
|
||||
});
|
||||
|
||||
if (actionToTake === "noAction") {
|
||||
log(`Reopening modal without any other action needed ${uid}`);
|
||||
// Reopen the modal, nothing else to do
|
||||
existingModalEl.setAttribute("state", "reopened");
|
||||
return;
|
||||
}
|
||||
|
||||
log("Attempting to load/connect regular booking link");
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
if (actionToTake === "fullReload") {
|
||||
log("Initiating full page load");
|
||||
this.cal.loadInIframe({
|
||||
calLink,
|
||||
calOrigin,
|
||||
iframe: this.cal.iframe,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
});
|
||||
} else if (actionToTake === "connect") {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We reach here in case of connect or fullReload
|
||||
this.modalUid = uid;
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Creating new modal ${uid}`);
|
||||
|
||||
if (__prerender) {
|
||||
this.preloadedModalUid = uid;
|
||||
this.prerenderedModalUid = uid;
|
||||
} else {
|
||||
// Intentionally not setting it to have the behaviour of reusing the same modal. Because it causes outdated content that might not be valid based on
|
||||
// 1. The time difference b/w reopening(availability getting changed in b/w)
|
||||
// 2. User using different query params but they not being used because of the same modal being reused. Happens in case of headless router being opened in embed
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -720,7 +1075,7 @@ class CalApi {
|
||||
iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
calOrigin: calOrigin || null,
|
||||
calOrigin,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -742,6 +1097,9 @@ class CalApi {
|
||||
</cal-modal-box>`;
|
||||
this.cal.modalBox = template.content.children[0];
|
||||
this.cal.modalBox.appendChild(iframe);
|
||||
// Set state through setAttribute so that onAttributeChangedCallback is triggered
|
||||
this.cal.modalBox.setAttribute("state", "loading");
|
||||
|
||||
if (__prerender) {
|
||||
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||
}
|
||||
@@ -800,12 +1158,14 @@ class CalApi {
|
||||
calLink,
|
||||
type,
|
||||
options = {},
|
||||
pageType,
|
||||
}: {
|
||||
calLink: string;
|
||||
type?: "modal" | "floatingButton";
|
||||
options?: {
|
||||
prerenderIframe?: boolean;
|
||||
};
|
||||
pageType?: EmbedPageType;
|
||||
}) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
validate(arguments[0], {
|
||||
@@ -835,7 +1195,7 @@ class CalApi {
|
||||
throw new Error(`Namespace ${namespace} isn't defined`);
|
||||
}
|
||||
|
||||
const config = this.cal.getInitConfig();
|
||||
const config = this.cal.getCalConfig();
|
||||
let prerenderIframe = options.prerenderIframe;
|
||||
if (type && prerenderIframe === undefined) {
|
||||
prerenderIframe = true;
|
||||
@@ -847,11 +1207,12 @@ class CalApi {
|
||||
|
||||
if (prerenderIframe) {
|
||||
if (type === "modal" || type === "floatingButton") {
|
||||
this.cal.isPerendering = true;
|
||||
this.cal.isPrerendering = true;
|
||||
this.modal({
|
||||
calLink,
|
||||
calOrigin: config.calOrigin,
|
||||
__prerender: true,
|
||||
...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}),
|
||||
});
|
||||
} else {
|
||||
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
|
||||
@@ -862,10 +1223,19 @@ class CalApi {
|
||||
}
|
||||
}
|
||||
|
||||
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
|
||||
prerender({
|
||||
calLink,
|
||||
type,
|
||||
pageType,
|
||||
}: {
|
||||
calLink: string;
|
||||
type: "modal" | "floatingButton";
|
||||
pageType?: EmbedPageType;
|
||||
}) {
|
||||
this.preload({
|
||||
calLink,
|
||||
type,
|
||||
pageType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1050,7 +1420,7 @@ function getEmbedApiFn(ns: string) {
|
||||
return api;
|
||||
}
|
||||
|
||||
function preloadAssetsForCalLink({ config, calLink }: { config: InitConfig; calLink: string }) {
|
||||
function preloadAssetsForCalLink({ config, calLink }: { config: CalConfig; calLink: string }) {
|
||||
const iframe = document.body.appendChild(document.createElement("iframe"));
|
||||
|
||||
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
|
||||
@@ -1081,5 +1451,14 @@ function initializeGlobalCalProps() {
|
||||
}
|
||||
|
||||
function log(...args: unknown[]) {
|
||||
console.log(...args);
|
||||
const searchString = location.search;
|
||||
globalCal.__logQueue = globalCal.__logQueue || [];
|
||||
globalCal.__logQueue.push(args);
|
||||
if (searchString.includes("cal.embed.logging=1") || process.env.INTEGRATION_TEST_MODE === "true") {
|
||||
console.log("Parent:", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function error(...args: unknown[]) {
|
||||
console.error(...args);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,9 @@ export type EventDataMap = {
|
||||
__routeChanged: Record<string, never>;
|
||||
__windowLoadComplete: Record<string, never>;
|
||||
__closeIframe: Record<string, never>;
|
||||
__iframeReady: Record<string, never>;
|
||||
__iframeReady: {
|
||||
isPrerendering: boolean;
|
||||
};
|
||||
__dimensionChanged: {
|
||||
iframeHeight: number;
|
||||
iframeWidth: number;
|
||||
|
||||
@@ -69,4 +69,31 @@ export type KnownConfig = {
|
||||
"cal.embed.pageType"?: EmbedPageType;
|
||||
};
|
||||
|
||||
export {};
|
||||
export type EmbedBookerState =
|
||||
| "initializing"
|
||||
| "slotsPending"
|
||||
| "slotsLoading"
|
||||
/**
|
||||
* When slots have loaded
|
||||
* Even when slots aren't requested, due to cal.skipSlotFetch, then also this state is achieved
|
||||
*/
|
||||
| "slotsDone"
|
||||
| "slotsLoadingError";
|
||||
|
||||
export type SlotsStatus = "loading" | "success" | "error";
|
||||
export type SlotsQuery = {
|
||||
isPending?: boolean;
|
||||
isError?: boolean;
|
||||
isSuccess?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
|
||||
*/
|
||||
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
|
||||
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
|
||||
iframeAttrs?: Record<string, string> & {
|
||||
id?: string;
|
||||
};
|
||||
} & KnownConfig;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { KnownConfig } from "./types";
|
||||
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
|
||||
|
||||
export const getErrorString = (errorCode: string | undefined) => {
|
||||
export const getErrorString = ({
|
||||
errorCode,
|
||||
errorMessage,
|
||||
}: {
|
||||
errorCode: string | undefined;
|
||||
errorMessage: string | undefined;
|
||||
}) => {
|
||||
const defaultErrorMessage = "Something went wrong.";
|
||||
if (errorCode === "404") {
|
||||
return `Error Code: 404. Cal Link seems to be wrong.`;
|
||||
errorMessage = errorMessage ?? "Cal Link seems to be wrong.";
|
||||
return `Error Code: 404. ${errorMessage}`;
|
||||
} else if (errorCode === "routerError") {
|
||||
errorMessage = errorMessage ?? defaultErrorMessage;
|
||||
return `Error Code: routerError. ${errorMessage}`;
|
||||
} else {
|
||||
return `Error Code: ${errorCode}. Something went wrong.`;
|
||||
errorMessage = errorMessage ?? defaultErrorMessage;
|
||||
return `Error Code: ${errorCode}. ${errorMessage}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,6 +50,28 @@ export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string,
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isParamValuePresentInUrlSearchParams({
|
||||
param,
|
||||
value,
|
||||
container,
|
||||
}: {
|
||||
param: string;
|
||||
value: string | string[];
|
||||
container: URLSearchParams;
|
||||
}) {
|
||||
// Because UrlSearchParams could have multiple entries with same key like guest=1&guest=2
|
||||
const containerEntries = fromEntriesWithDuplicateKeys(container.entries());
|
||||
if (!container.has(param)) {
|
||||
return false;
|
||||
}
|
||||
const containerValue = containerEntries[param];
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
if (valueArray.length !== containerValue.length) {
|
||||
return false;
|
||||
}
|
||||
return valueArray.every((valueItem) => containerValue.includes(valueItem));
|
||||
}
|
||||
|
||||
function listKnownConfigProps() {
|
||||
const knownConfigProps: (keyof KnownConfig)[] = [
|
||||
"flag.coep",
|
||||
@@ -98,3 +132,67 @@ export function generateDataAttributes(props: Record<string, string | null | und
|
||||
.map(([key, value]) => `data-${hyphenate(key)}="${escapeHtmlAttribute(value)}"`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function isRouterPath(path: string) {
|
||||
// URL doesn't matter
|
||||
const urlObject = new URL(path, "http://baseUrl.example");
|
||||
return urlObject.pathname === "/router";
|
||||
}
|
||||
|
||||
export async function submitResponseAndGetRoutingResult({
|
||||
headlessRouterPageUrl,
|
||||
}: {
|
||||
headlessRouterPageUrl: string;
|
||||
}) {
|
||||
const headlessRouterUrlObject = new URL(headlessRouterPageUrl);
|
||||
const searchParams = headlessRouterUrlObject.searchParams;
|
||||
const headlessRouterApiUrl = `${headlessRouterUrlObject.origin}${headlessRouterUrlObject.pathname.replace(
|
||||
/^\/?router/,
|
||||
"/api/router"
|
||||
)}`;
|
||||
const response = await fetch(headlessRouterApiUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fromEntriesWithDuplicateKeys(searchParams.entries())),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.status === "success") {
|
||||
if (!result.data.redirect && !result.data.message) {
|
||||
throw new Error("No `redirect` or `message` in response");
|
||||
}
|
||||
return result.data as { redirect: string } | { message: string };
|
||||
} else {
|
||||
if (!result.data.message) {
|
||||
throw new Error("No `message` in response");
|
||||
}
|
||||
console.warn("Error submitting response and getting routing result", result);
|
||||
return { error: result.data.message } as { error: string };
|
||||
}
|
||||
}
|
||||
|
||||
export function isSameBookingLink({
|
||||
bookingLinkPath1,
|
||||
bookingLinkPath2,
|
||||
}: {
|
||||
bookingLinkPath1: string;
|
||||
bookingLinkPath2: string;
|
||||
}) {
|
||||
// Headless router redirects to /team/event-booking-url at the moment. In future it might fix it to /event-booking-url
|
||||
// So, stripe /team from both the URLs if present so that they can be compared easily
|
||||
return bookingLinkPath1.replace(/^\/team\//, "/") === bookingLinkPath2.replace(/^\/team\//, "/");
|
||||
}
|
||||
|
||||
export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig) {
|
||||
const { iframeAttrs: _1, ...params } = config;
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value instanceof Array) {
|
||||
value.forEach((val) => searchParams.append(key, val));
|
||||
} else if (typeof value === "string") {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import PoweredBy from "@calcom/ee/components/PoweredBy";
|
||||
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
|
||||
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
|
||||
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
@@ -38,18 +39,10 @@ import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/us
|
||||
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
|
||||
import framerFeatures from "./framer-features";
|
||||
import { useBookerStore } from "./store";
|
||||
import type { BookerProps, WrappedBookerProps, BookerState } from "./types";
|
||||
import type { BookerProps, WrappedBookerProps } from "./types";
|
||||
import { isBookingDryRun } from "./utils/isBookingDryRun";
|
||||
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
|
||||
|
||||
function updateEmbedBookerState({ bookerState }: { bookerState: BookerState }) {
|
||||
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
|
||||
if (typeof window !== "undefined") {
|
||||
(window as Window & { _embedBookerState?: "initializing" | "done" })._embedBookerState =
|
||||
bookerState && bookerState !== "loading" ? "done" : "initializing";
|
||||
}
|
||||
}
|
||||
|
||||
const BookerComponent = ({
|
||||
username,
|
||||
eventSlug,
|
||||
@@ -186,7 +179,7 @@ const BookerComponent = ({
|
||||
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
|
||||
);
|
||||
|
||||
updateEmbedBookerState({ bookerState });
|
||||
updateEmbedBookerState({ bookerState, slotsQuery: schedule });
|
||||
|
||||
useEffect(() => {
|
||||
if (event.isPending) return setBookerState("loading");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
|
||||
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
|
||||
import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants";
|
||||
@@ -39,6 +41,8 @@ export const useSchedule = ({
|
||||
orgSlug,
|
||||
teamMemberEmail,
|
||||
}: UseScheduleWithCacheArgs) => {
|
||||
const bookerState = useBookerStore((state) => state.state);
|
||||
|
||||
const [startTime, endTime] = useTimesForSchedule({
|
||||
month,
|
||||
monthCount,
|
||||
@@ -56,11 +60,13 @@ export const useSchedule = ({
|
||||
const utils = trpc.useUtils();
|
||||
const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId");
|
||||
const email = searchParams?.get("email");
|
||||
|
||||
// We allow skipping the schedule fetch as a requirement for prerendering in iframe through embed as when the pre-rendered iframe is connected, then we would fetch the availability, which would be upto-date
|
||||
// Also, a reuse through Headless Router could completely change the availability as different team members are selected and thus it is unnecessary to fetch the schedule
|
||||
const skipGetSchedule = searchParams?.get("cal.skipSlotsFetch") === "true";
|
||||
const routingFormResponseId = routingFormResponseIdParam
|
||||
? parseInt(routingFormResponseIdParam, 10)
|
||||
: undefined;
|
||||
|
||||
const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || "";
|
||||
const input = {
|
||||
isTeamEvent,
|
||||
usernameList: getUsernameList(username ?? ""),
|
||||
@@ -83,6 +89,8 @@ export const useSchedule = ({
|
||||
_shouldServeCache,
|
||||
routingFormResponseId,
|
||||
email,
|
||||
// Ensures that connectVersion causes a refresh of the data
|
||||
...(embedConnectVersion ? { embedConnectVersion } : {}),
|
||||
};
|
||||
|
||||
const options = {
|
||||
@@ -98,6 +106,7 @@ export const useSchedule = ({
|
||||
// It allows long sitting users to get latest available slots
|
||||
refetchInterval: PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS * 1000,
|
||||
enabled:
|
||||
!skipGetSchedule &&
|
||||
Boolean(username) &&
|
||||
Boolean(month) &&
|
||||
Boolean(timezone) &&
|
||||
@@ -111,6 +120,12 @@ export const useSchedule = ({
|
||||
} else {
|
||||
schedule = trpc.viewer.slots.getSchedule.useQuery(input, options);
|
||||
}
|
||||
|
||||
updateEmbedBookerState({
|
||||
bookerState,
|
||||
slotsQuery: schedule,
|
||||
});
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Handlers = {
|
||||
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
|
||||
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "OPTIONS"]?: Promise<{ default: NextApiHandler }>;
|
||||
};
|
||||
|
||||
/** Allows us to split big API handlers by method */
|
||||
|
||||
@@ -48,7 +48,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
};
|
||||
|
||||
if (!queryParsed.success) {
|
||||
log.warn("Error parsing query", queryParsed.error);
|
||||
log.warn("Error parsing query", { issues: queryParsed.error.issues });
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -150,7 +150,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
props: {
|
||||
...pageProps,
|
||||
form: serializableForm,
|
||||
message: e.message,
|
||||
message: null,
|
||||
errorMessage: e.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -166,6 +167,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
...pageProps,
|
||||
form: serializableForm,
|
||||
message: decidedAction.value,
|
||||
errorMessage: null,
|
||||
},
|
||||
};
|
||||
} else if (decidedAction.type === "eventTypeRedirectUrl") {
|
||||
@@ -214,7 +216,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
props: {
|
||||
...pageProps,
|
||||
form: serializableForm,
|
||||
message: "Unhandled type of action",
|
||||
message: null,
|
||||
errorMessage: "Unhandled type of action",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -194,6 +194,15 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const insightsTeamMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create event types for the team
|
||||
let teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
@@ -207,6 +216,7 @@ async function main() {
|
||||
length: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
assignAllTeamMembers: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -242,6 +252,7 @@ async function main() {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
@@ -254,20 +265,32 @@ async function main() {
|
||||
length: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
assignAllTeamMembers: true,
|
||||
},
|
||||
});
|
||||
|
||||
// After creating or fetching teamEvents and insightsTeamMembers
|
||||
// Create Host records for all team members for each event with assignAllTeamMembers true
|
||||
const assignAllTeamMembersEvents = teamEvents.filter((event) => (event as any).assignAllTeamMembers);
|
||||
if (assignAllTeamMembersEvents.length > 0 && insightsTeamMembers.length > 0) {
|
||||
const hostRecords = assignAllTeamMembersEvents.flatMap((event) =>
|
||||
insightsTeamMembers.map((member) => ({
|
||||
userId: member.user.id,
|
||||
eventTypeId: event.id,
|
||||
isFixed: false,
|
||||
}))
|
||||
);
|
||||
if (hostRecords.length > 0) {
|
||||
await prisma.host.createMany({
|
||||
data: hostRecords,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const javascriptEventId = teamEvents.find((event) => event.slug === "team-javascript")?.id;
|
||||
const salesEventId = teamEvents.find((event) => event.slug === "team-sales")?.id;
|
||||
const insightsMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create bookings for the team events
|
||||
const baseBooking = {
|
||||
@@ -276,7 +299,7 @@ async function main() {
|
||||
description: "Team Meeting Should be changed in shuffle",
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
userId: insightsMembers[0].user.id, // Use first org member as default
|
||||
userId: insightsTeamMembers[0].user.id, // Use first org member as default
|
||||
};
|
||||
|
||||
// Create past bookings -2y, -1y, -0y
|
||||
@@ -287,7 +310,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 2,
|
||||
teamEvents,
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
insightsTeamMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -300,7 +323,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 1,
|
||||
teamEvents,
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
insightsTeamMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -313,7 +336,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y"),
|
||||
teamEvents,
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
insightsTeamMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -331,7 +354,7 @@ async function main() {
|
||||
}
|
||||
const seededForm = await seedRoutingForms(
|
||||
insightsTeam.id,
|
||||
owner?.user.id ?? insightsMembers[0].user.id,
|
||||
owner?.user.id ?? insightsTeamMembers[0].user.id,
|
||||
attributes
|
||||
);
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@ export async function seedRoutingForms(
|
||||
},
|
||||
});
|
||||
if (form) {
|
||||
console.log(`Skipping Routing Form - Form Seed, "Seeded Form - Pro" already exists`);
|
||||
console.log(`Skipping Routing Form - Form Seed, ${seededForm.name} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -477,6 +477,16 @@ export async function seedRoutingForms(
|
||||
throw new Error("No attributes found - Please call seedAttributes first");
|
||||
}
|
||||
|
||||
const formFieldSkillsOptions = attributeRaw[2].options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
}));
|
||||
|
||||
const formFieldLocationOptions = attributeRaw[1].options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
}));
|
||||
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
id: seededForm.id,
|
||||
@@ -491,6 +501,21 @@ export async function seedRoutingForms(
|
||||
queryValue: {
|
||||
id: "aaba9988-cdef-4012-b456-719300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98b98a8-0123-4456-b89a-b19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: seededForm.formFieldSkills.id,
|
||||
value: [
|
||||
formFieldSkillsOptions.filter((opt) => opt.label === "JavaScript").map((opt) => opt.id),
|
||||
],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: {
|
||||
id: "ab99bbb9-89ab-4cde-b012-319300f53ef8",
|
||||
@@ -522,6 +547,19 @@ export async function seedRoutingForms(
|
||||
queryValue: {
|
||||
id: "aaba9948-cdef-4012-b456-719300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"c98b98a8-1123-4456-e89a-a19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: seededForm.formFieldSkills.id,
|
||||
value: [formFieldSkillsOptions.filter((opt) => opt.label === "Sales").map((opt) => opt.id)],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: {
|
||||
id: "ab988888-89ab-4cde-b012-319300f53ef8",
|
||||
@@ -559,20 +597,14 @@ export async function seedRoutingForms(
|
||||
id: seededForm.formFieldLocation.id,
|
||||
type: "select",
|
||||
label: "Location",
|
||||
options: attributeRaw[1].options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
})),
|
||||
options: formFieldLocationOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: seededForm.formFieldSkills.id,
|
||||
type: "multiselect",
|
||||
label: "skills",
|
||||
options: attributeRaw[2].options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
})),
|
||||
options: formFieldSkillsOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user