* 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>
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
|
|
|
|
export const getErrorString = ({
|
|
errorCode,
|
|
errorMessage,
|
|
}: {
|
|
errorCode: string | undefined;
|
|
errorMessage: string | undefined;
|
|
}) => {
|
|
const defaultErrorMessage = "Something went wrong.";
|
|
if (errorCode === "404") {
|
|
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 {
|
|
errorMessage = errorMessage ?? defaultErrorMessage;
|
|
return `Error Code: ${errorCode}. ${errorMessage}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* An alternative to Object.fromEntries that allows duplicate keys and converts the values corresponding to them in an array
|
|
*
|
|
* NOTE: This is a duplicate of the function in @calcom/lib/hooks/useRouterQuery.ts. It has to be here because embed is published to npm and shouldn't refer to any private package
|
|
*/
|
|
export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
|
|
const result: Record<string, string | string[]> = {};
|
|
|
|
if (entries === null) {
|
|
return result;
|
|
}
|
|
|
|
// Consider setting atleast ES2015 as target
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
for (const [key, value] of entries) {
|
|
if (result.hasOwnProperty(key)) {
|
|
let currentValue = result[key];
|
|
if (!Array.isArray(currentValue)) {
|
|
currentValue = [currentValue];
|
|
}
|
|
currentValue.push(value);
|
|
result[key] = currentValue;
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
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",
|
|
"layout",
|
|
"ui.color-scheme",
|
|
"theme",
|
|
"cal.embed.pageType",
|
|
];
|
|
return knownConfigProps;
|
|
}
|
|
|
|
export function getConfigProp<TProp extends keyof KnownConfig>(config: KnownConfig, prop: TProp) {
|
|
const knownConfigProps = listKnownConfigProps();
|
|
if (!knownConfigProps.includes(prop)) {
|
|
console.warn(`Not reading unknown config prop: ${prop}`);
|
|
return null;
|
|
}
|
|
return config[prop] ?? null;
|
|
}
|
|
|
|
export function getQueryParamProvidedByConfig<TProp extends keyof KnownConfig>(
|
|
queryParam: TProp
|
|
): NonNullable<KnownConfig[TProp]> | null {
|
|
const knownConfigProps = listKnownConfigProps();
|
|
if (!knownConfigProps.includes(queryParam)) {
|
|
console.warn(`Not reading unknown config prop from query: ${queryParam}`);
|
|
return null;
|
|
}
|
|
const url = new URL(document.URL);
|
|
const value = url.searchParams.get(queryParam);
|
|
if (typeof value === "string") {
|
|
return value as NonNullable<KnownConfig[TProp]>;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function hyphenate(str: string): string {
|
|
return str.charAt(0).toLowerCase() + str.slice(1).replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
}
|
|
|
|
/**
|
|
* Escapes special characters in HTML attribute values to prevent XSS
|
|
* @param value The string to escape
|
|
* @returns Escaped string safe for use in HTML attributes
|
|
*/
|
|
function escapeHtmlAttribute(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
export function generateDataAttributes(props: Record<string, string | null | undefined>): string {
|
|
return Object.entries(props)
|
|
.filter((pair): pair is [string, string] => !!pair[1])
|
|
.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;
|
|
}
|