Compare commits

...
15 Commits
Author SHA1 Message Date
Omar López d8176b2eb4 v2.7.6
release.yml / release (push) Canceled after 0s
2023-03-28 16:28:17 -07:00
6c51e2a970 insights follow up (#7922)
* init page

* init insights frontend

* nit

* nit

* nit

* merge

* fixed icons

* i18n, needs features

* Init insights trpc

* Using trpc on client

* Added events timeline

* Seed analytics script

* connect ui with trpc

* Added and fixed event time lines

* WIP popular days and avg time duration event type

* added new metric graphs

* improved upgrade tip

* always show upgrade screen

* upgrade tremor.so and select inputs for page

* Remove log

* Move everything to components and add context

* Fix select types using calcom ui one

* Adding translations

* Add missing translations

* Add more translations

* min fix

* Fixes for date select

* Prefer early return and mobile design fixes

* Fix style for mobile

* Fix data with userId filter from popular events

* add user id to average time duration

* fix types for select-react

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Add url state in insights

* Update FiltersProvider.tsx

* Cleanup

* Update embed-iframe.ts

* Update FilterType.tsx

* Update seed-app-store.config.json

* Update index.tsx

* Renamed seeder

* Update FiltersProvider.tsx

* Fix for query params

* no wrap on lg screen

* Fix shadow borders from tremor components, fix title font

* Add ring-gray to match filters

* add cursor pointer

* copy improvements

* blue to black

* fixed date select focus

* Adds missing translation strings

* Fix url state for filter type

* Apply suggestions from code review

* Updated yarn lock

* Adds feature flag

* Type fix

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-28 16:24:57 -07:00
zomars 3a08eb3dca Adds pagination 2023-03-28 15:11:27 -07:00
cee905bc5a Improves reported type errors on CI (#7813)
* Create env file workflow

* Add env-cache

* Fix env setter

* Fix

* Another fix

* Fix

* Fix

* Fixes

* FFS

* Fix

* Fix

* Fix

* Fix

* Fix

* Cache fixes

* Fixes

* Adds skipping steps

* db-cache fixes

* Test

* Cache fixes

* e2e

* Possible caching conflicts

* Running out of ideas

* Caching is hard

* One more time

* cache-build not skipping

* Fingers crossed

* a

* Test

* Pls

* Please

* LFG

* Build fix

* fix

* Whitespace!!

* Zomars/cal 884 paid events not sending the link (#7318)

* WIP

* Sends correct emails for paid bookings

* Update PaymentService.ts

* Update webhook.ts

* Update webhook.ts

* Update settings back button redirect link (#7403)

* fix(schedule): close on click #7143

* fix(EventSetupTab): validLocations length will always match validLocations length #7138

* fix(SettingsLayout): go back to right route #7379

* feat: get country code from ip geolocation (#6880)

* feat: get coutnry code from ip geolocation

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: create new api route for fetching code

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: replace city with country

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* refactor: create hook for country

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Team Workflows (#7038)

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* Add destination calendar name to DestinationCalendarSelector (#6701)

* Add destination calendar name

* Type fix

* Search through calendars only for destination calendar credential

* Refactor get connected calendars

* Clean up

---------

Co-authored-by: zomars <zomars@me.com>

* Update viewer.tsx (#7428)

* Fix - add team members to emails (#7207)

* On booking add team members & translation

* Add team members to round robin create

* Only update calendars on reschedule if there is a calendar reference

* Send email on reschedules

* Send team email on cancelled event

* Add team members to calendar event description

* Clean up

* Convert other emails to organizer & teams

* Type check fixes

* More type fixes

* Change organizer scheduled input to an object

* early return updateCalendarEvent

* Introduce team member type

* Fix type errors

* Put team members before attendees

* Remove lodash cloneDeep

* Update packages/core/EventManager.ts

Co-authored-by: Omar López <zomars@me.com>

* Remove booking select object

* Revert "Remove booking select object"

This reverts commit 9f121ff4ebbaec9a0643111f96c5337b930ddd5b.

* Refactor email manager (#7270)

Co-authored-by: zomars <zomars@me.com>

* Type change

* Remove conditional check for updateAllCalendarEvents

---------

Co-authored-by: zomars <zomars@me.com>

* Typefix

* Updates webhook response

* Update pr.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Update action.yml

* Is this redundant?

* Removed setup

* Update action.yml

* Update action.yml

* Consolitades setup step

* Revert "Consolitades setup step"

This reverts commit 5e8d1983cc5ac309fa6875a2fee77aa6287b7151.

* Fix?

* One more time

* Revert "One more time"

This reverts commit fd8b559a135820567f40dbb47a4b4dfc2ef44755.

* Benchmarking buildjet

* Update action.yml

* Re-introduce setup

* Adds embeds to missing pro cache

* Lint fixes

* Adds prettier ignore

* Upgrades to yarn 3

* Updates lockfile

* Reverts CI to ubuntu

* Testing new yarn install

* We cannot use immutable due to our private submodules

* Adds CI skip

* Fixes

* Adds plugin

* Forces local embed package

* Moves eslint to root

* Update yarn.lock

* Playwright fixes

* Embed test fixes

* Splits embed react tests

* Splits embed react tests

* Removes install step to benchmark

* Update playwright.config.ts

* One playwright config for all

* More test fixes

* Update basic.e2e.ts

* Added typescript as a global monorepo dev

* Update to v18

* Update yarn.lock

* Update env-create-file.yml

* Update .github/workflows/pr.yml

* Adds type check special for CI

* Update turbo.json

* Update turbo.json

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Esaú Morais <55207584+esau-morais@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-03-28 13:12:40 -07:00
72fd6334c6 Add the new Team->useHostDefault option (#7811)
* Add the new Team->useHostDefault option

* Added user.metadata to defaultEvents

* Type fixes

* Another type fix

* Implement feedback @jaibles

* Default -> Conferencing rename

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-03-28 20:03:54 +00:00
Omar López 63f51abd84 Adds cron endpoint to keep team subscriptions in sync (#7992)
* Fix missing edge case where team is paid but needs to be updated

* Reuses cron endpoint to update team subscriptions

* Update cron-downgradeUsers.yml

* Update payments.ts

* Update cron-downgradeUsers.yml

* update to 90 writes per second

Stripe allow up to 100 per second
2023-03-28 12:38:01 -07:00
Omar López 6e856b3bd1 Fix missing edge case where team is paid but needs to be updated (#7988) 2023-03-28 18:45:13 +00:00
Hariom Balhara 5fceee27fe Embed: Strictly type the codebase and fixes a few bugs found (#7536)
* Add strict types

* Make it array

* Make api consistent and support calOrigin in floatingPopup and modal

* Support calOrigin everywhere and fix branding config issue

* Fix styles not being uniformly applied across components

* Fix unused code
2023-03-28 11:17:40 -07:00
Alex van Andel 4f590ebbf7 Remove 'avatar' (which is unused) from eventType.hosts query (#7986) 2023-03-28 18:44:30 +02:00
Hariom Balhara 736cd63509 Fix: AppStore CLI Create command not working after upgrading to yarn 3 (#7975)
* Remove README.md that is now replaced by DESCRIPTION/md

* Fix AppStore Cli create command failure
2023-03-28 10:25:51 +00:00
3aaa1cde0d Fixes formatted description in email + sanitize html everywhere (#7928)
* use sanitize-html instead

* add list formatting

* add lost changes from merging main

* fixes caused by merge

* remove dompurify

* Revert "remove dompurify"

This reverts commit 583bb623c7d3c0c7daa28e63ab2b1ac2da68c0d8.

* sanitize already done in editor

* Update yarn.lock

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-28 09:40:13 +00:00
Keith Williams 02d32321aa Updated the PR template to not use checkboxes for 'Type of change' (#7960)
* Updated the PR template to not use checkboxes for 'Type of change'

* Changed wording of options to bullets
2023-03-28 10:43:27 +02:00
GitStart-Cal.comandgitstart-calcom 697f6db2c4 CALCOM-7962 - [CAL-1350] create "collective or round robin" doenst open dialog anymore (#7969)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-03-28 10:36:19 +02:00
Alex van Andel 83364a9e97 Update yarn.lock 2023-03-28 10:30:57 +02:00
Julius Marminge 1620ae660f fix(embeds): add support for RSC by adding use client directives (#7919) 2023-03-28 11:30:02 +05:30
106 changed files with 1911 additions and 1240 deletions
+6 -6
View File
@@ -12,13 +12,13 @@ Fixes # (issue)
## Type of change
<!-- Please delete options that are not relevant. -->
<!-- Please delete bullets that are not relevant. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- Bug fix (non-breaking change which fixes an issue)
- Chore (refactoring code, technical debt, workflow improvements)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- This change requires a documentation update
## How should this be tested?
+18
View File
@@ -0,0 +1,18 @@
{
"problemMatcher": [
{
"owner": "tsc-absolute",
"pattern": [
{
"regexp": "(?:^|\\s)([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
]
}
]
}
+5 -3
View File
@@ -10,8 +10,10 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Show info
run: node -e "console.log(require('v8').getHeapStatistics())"
- run: yarn type-check
- name: Configure TSC problem matcher
run: |
echo "::remove-matcher owner=tsc::"
echo "::add-matcher::.github/matchers/tsc-absolute.json"
- run: yarn type-check:ci
+3 -2
View File
@@ -1,11 +1,12 @@
name: Cron - downgradeUsers
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
- cron: "0,15,30,45 * * * *"
# Runs “Every month at 1st (see https://crontab.guru)
- cron: "0 0 1 * *"
jobs:
cron-downgradeUsers:
env:
+4 -5
View File
@@ -350,8 +350,7 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
<source media="(prefers-color-scheme: dark)" srcset="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=dark">
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
</picture>
</a>
</a>
<!-- CONTRIBUTORS -->
@@ -456,12 +455,12 @@ following
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
2. From within the API console page, go to "Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
4. Fill in any information you want in the "Client Details" tab
5. Go to tab "Client Secret" tab.
5. Go to tab "Client Secret" tab.
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
+2 -1
View File
@@ -11,7 +11,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
"start": "PORT=4000 next start",
"type-check": "tsc --pretty --noEmit"
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit"
},
"author": "Cal.com, Inc.",
"license": "MIT",
+4 -4
View File
@@ -167,7 +167,7 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
const height = getDimension(previewState.inline.height);
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
@@ -187,12 +187,12 @@ function MyComponent() {
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
Cal("floatingButton", ${floatingButtonArg});
cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}
})();
}, [])
@@ -201,7 +201,7 @@ function MyComponent() {
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
useEffect(()=>{
(async function () {
@@ -8,54 +8,61 @@ import { FiLink } from "@calcom/ui/components/icon";
import type { Props } from "./pages/AvailabilityPage";
const excludeNullValues = (value: unknown) => !!value;
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale();
return locations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
{locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
const renderLocations = locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
if (eventLocationType.variable === "hostDefault") {
return null;
}
const translateAbleKeys = [
"attendee_in_person",
"in_person",
"attendee_phone_number",
"link_meeting",
"organizer_phone_number",
];
const translateAbleKeys = [
"attendee_in_person",
"in_person",
"attendee_phone_number",
"link_meeting",
"organizer_phone_number",
];
const locationKey = z.string().default("").parse(locationKeyToString(location));
const locationKey = z.string().default("").parse(locationKeyToString(location));
const translatedLocation = location.type.startsWith("integrations:")
? eventLocationType.label
: translateAbleKeys.includes(locationKey)
? t(locationKey)
: locationKey;
const translatedLocation = location.type.startsWith("integrations:")
? eventLocationType.label
: translateAbleKeys.includes(locationKey)
? t(locationKey)
: locationKey;
return (
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<FiLink className="dark:text-darkgray-600 min-h-4 min-w-4 ml-[2px] opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
)}
alt={`${eventLocationType.label} icon`}
/>
return (
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<FiLink className="dark:text-darkgray-600 min-h-4 min-w-4 ml-[2px] opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
)}
<Tooltip content={translatedLocation}>
<p className="line-clamp-1">{translatedLocation}</p>
</Tooltip>
</div>
);
})}
alt={`${eventLocationType.label} icon`}
/>
)}
<Tooltip content={translatedLocation}>
<p className="line-clamp-1">{translatedLocation}</p>
</Tooltip>
</div>
);
});
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
return filteredLocations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
{filteredLocations}
</div>
) : null;
}
@@ -33,6 +33,7 @@ interface ISetLocationDialog {
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
selection?: LocationOption;
booking?: BookingItem;
isTeamEvent?: boolean;
defaultValues?: LocationObject[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean;
@@ -74,6 +75,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
saveLocation,
selection,
booking,
isTeamEvent,
setShowLocationModal,
isOpenDialog,
defaultValues,
@@ -290,7 +292,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
query={locationsQuery}
success={({ data }) => {
if (!data.length) return null;
const locationOptions = [...data];
const locationOptions = [...data].filter((option) => {
return !isTeamEvent ? option.label !== "Conferencing" : true;
});
if (booking) {
locationOptions.map((location) =>
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value))
@@ -35,16 +35,6 @@ const getLocationFromType = (
}
};
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
for (const locationType of options) {
for (const location of locationType.options) {
if (location.value === type && location.disabled === false) {
return location;
}
}
}
};
export const EventSetupTab = (
props: Pick<
EventTypeSetupProps,
@@ -53,12 +43,16 @@ export const EventSetupTab = (
) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, locationOptions, team, destinationCalendar } = props;
const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
const locationOptions = props.locationOptions.filter((option) => {
return !team ? option.label !== "Conferencing" : true;
});
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
@@ -409,6 +403,7 @@ export const EventSetupTab = (
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isTeamEvent={!!team}
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
@@ -1,11 +1,11 @@
export type EventTypeDescriptionSafeProps = {
eventType: { description: string | null };
eventType: { description: string | null; descriptionAsSafeHTML: string | null };
};
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML };
if (eventType.description)
props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
return <div {...props} />;
};
+6 -5
View File
@@ -2,13 +2,14 @@ import Link from "next/link";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
type MemberType = MembersType[number] & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
@@ -30,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
dangerouslySetInnerHTML={{ __html: member.safeBio || "" }}
/>
</>
) : (
@@ -43,7 +44,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
);
};
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
const Members = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
if (!members || members.length === 0) {
return null;
}
@@ -57,7 +58,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
);
};
const Team = ({ team }: { team: TeamType }) => {
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.7.5",
"version": "2.7.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -11,6 +11,7 @@
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"build": "next build",
"start": "next start",
"lint": "eslint . --ignore-path .gitignore",
@@ -119,6 +120,7 @@
"react-window": "^1.8.7",
"remark": "^14.0.2",
"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
"short-uuid": "^4.2.0",
"strip-markdown": "^5.0.0",
@@ -156,6 +158,7 @@
"@types/react-phone-number-input": "^3.0.14",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/sanitize-html": "^2.9.0",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12",
+38 -1
View File
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import { FiFileText, FiCheck, FiBookOpen, FiChevronRight } from "@calcom/ui/components/icon";
import { FiBookOpen, FiCheck, FiChevronRight, FiFileText } from "@calcom/ui/components/icon";
import { ssgInit } from "@server/lib/ssg";
@@ -40,6 +40,43 @@ export default function Custom404() {
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
const isSignup = router.asPath.startsWith("/signup");
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
/**
* If we're on 404 and the route is insights it means it is disabled
* TODO: Abstract this for all disabled features
**/
const isInsights = router.asPath.startsWith("/insights");
if (isInsights) {
return (
<>
<HeadSeo
title="Feature is currently disabled"
description={t("404_page_not_found")}
nextSeoProps={{
nofollow: true,
noindex: true,
}}
/>
<div className="min-h-screen bg-white px-4" data-testid="404-page">
<main className="mx-auto max-w-xl pt-16 pb-6 sm:pt-24">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-black">{t("error_404")}</p>
<h1 className="font-cal mt-2 text-4xl font-extrabold text-gray-900 sm:text-5xl">
Feature is currently disabled
</h1>
</div>
<div className="mt-12">
<div className="mt-8">
<Link href="/" className="text-base font-medium text-black hover:text-gray-500">
{t("or_go_back_home")}
<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
</div>
</main>
</div>
</>
);
}
return (
<>
+6 -2
View File
@@ -24,7 +24,7 @@ import defaultEvents, {
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
@@ -147,7 +147,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<>
<div
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
)}
@@ -343,6 +343,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
const isSingleUser = users.length === 1;
@@ -352,9 +353,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
})
: [];
const safeBio = markdownToSafeHTML(user.bio) || "";
return {
props: {
users,
safeBio,
profile,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
+2 -6
View File
@@ -5,7 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { addListFormatting } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { User } from "@calcom/prisma/client";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -59,7 +59,6 @@ Type.isThemeSupported = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies
const MarkdownIt = await import("markdown-it").then((mod) => mod.default);
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
@@ -124,9 +123,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
},
},
});
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & {
@@ -153,7 +149,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
descriptionAsSafeHTML: eventType.description ? addListFormatting(md.render(eventType.description)) : null,
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
// Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam =
+2
View File
@@ -14,6 +14,7 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
@@ -189,6 +190,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: u.username,
theme: u.theme,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
+37 -4
View File
@@ -1,4 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
@@ -11,10 +19,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
/**
* TODO:
* Remove this endpoint
*/
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
},
select: {
id: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
await updateQuantitySubscriptionFromStripe(team.id);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}
+2
View File
@@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -119,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: u.hideBranding,
timeZone: u.timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
});
const [user] = users;
+2
View File
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -93,6 +94,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
@@ -65,6 +65,7 @@ export type FormValues = {
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;
phone?: string;
hostDefault?: string;
}[];
customInputs: CustomInputParsed[];
schedule: number | null;
+1 -1
View File
@@ -141,7 +141,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
</div>
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription
/>
</Link>
+20 -3
View File
@@ -1,3 +1,4 @@
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import {
AverageEventDurationChart,
BookingKPICards,
@@ -39,17 +40,17 @@ export default function InsightsPage() {
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <FiUsers className="h-5 w-5 text-red-500" />,
icon: <FiUsers className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <FiRefreshCcw className="h-5 w-5 text-blue-500" />,
icon: <FiRefreshCcw className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <FiUserPlus className="h-5 w-5 text-green-500" />,
icon: <FiUserPlus className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
@@ -114,3 +115,19 @@ export default function InsightsPage() {
</div>
);
}
// If feature flag is disabled, return not found on getServerSideProps
export const getServerSideProps = async () => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
if (flags.insights === false) {
return {
notFound: true,
};
}
return {
props: {},
};
};
+10 -3
View File
@@ -10,7 +10,7 @@ import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -113,7 +113,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
)}
@@ -187,11 +187,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
}));
const safeBio = markdownToSafeHTML(team.bio) || "";
const members = team.members.map((member) => {
return { ...member, safeBio: markdownToSafeHTML(member.bio || "") };
});
return {
props: {
team,
team: { ...team, safeBio, members },
trpcState: ssr.dehydrate(),
},
} as const;
+2 -1
View File
@@ -4,6 +4,7 @@ import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -63,7 +64,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
id: true,
name: true,
avatar: true,
username: true,
timeZone: true,
hideBranding: true,
@@ -170,6 +170,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding,
timeZone,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
});
eventTypeObject.availability = [];
+2
View File
@@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -121,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: u.avatar,
slug: u.username,
})),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
};
})[0];
+1 -1
View File
@@ -45,7 +45,7 @@ export const createEmbedsFixture = (page: Page) => {
}
api("on", {
action: "*",
callback: (e: any) => {
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
@@ -1218,6 +1218,7 @@
"impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation",
"users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile",
"users_description": "Here you can find a list of all users",
"users_listing": "User listing",
@@ -1608,6 +1609,7 @@
"default_app_link_title": "Set a default app link",
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
"change_default_conferencing_app": "Set as default",
"organizer_default_conferencing_app": "Organizer's default app",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
@@ -1686,7 +1688,9 @@
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"analytics_for_organisation": "Analytics for {{organisationName}}",
"subtitle_analytics": "This is a organisation analytics",
"event_trends": "Event Trends"
"analytics_for_organisation": "Insights for {{organisationName}}",
"subtitle_analytics": "Learn more about your team's activity",
"event_trends": "Event Trends",
"clear_filters": "Clear Filters",
"insights": "Insights"
}
@@ -1213,6 +1213,7 @@
"impersonation": "Suplantación",
"impersonation_description": "Configuración para administrar la suplantación del usuario",
"users": "Usuarios",
"user": "Usuario",
"profile_description": "Administra los ajustes para tu perfil de {{appName}}",
"users_description": "Aquí puede encontrar una lista de todos los usuarios",
"users_listing": "Lista de usuarios",
+2
View File
@@ -497,3 +497,5 @@ hr {
::-webkit-search-cancel-button {
-webkit-appearance: none;
}
+3 -1
View File
@@ -70,6 +70,7 @@
"test-playwright": "yarn playwright test --config=playwright.config.ts",
"test": "jest",
"type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web"
},
"devDependencies": {
@@ -86,8 +87,9 @@
"jest-watch-select-projects": "^2.0.0",
"jest-watch-typeahead": "^2.0.0",
"lint-staged": "^12.5.0",
"prettier": "^2.8.4",
"prettier": "^2.8.6",
"ts-jest": "^28.0.8",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4"
},
"dependencies": {
+3 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import type seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync";
@@ -127,6 +127,8 @@ export const BaseAppFork = {
.replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug)
);
// New monorepo package has been added, so we need to run yarn again
await execSync("yarn");
},
delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
@@ -3,7 +3,6 @@
Don't modify this file manually.
**/
import dynamic from "next/dynamic";
export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/components/InstallAppButton")),
@@ -24,7 +24,6 @@ import { appKeysSchema as vital_zod_ts } from "./vital/zod";
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
@@ -58,7 +58,6 @@ import wordpress_config_json from "./wordpress/config.json";
import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
amie: amie_config_json,
applecalendar: applecalendar__metadata_ts,
@@ -24,7 +24,6 @@ import { appDataSchema as vital_zod_ts } from "./vital/zod";
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,
+1
View File
@@ -10,6 +10,7 @@ items:
{DESCRIPTION}
### Cron's most valuable features
<!-- From "FAQs: What are Cron’s most valuable features?"
https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 -->
+25 -4
View File
@@ -12,13 +12,20 @@ export type DefaultEventLocationType = {
type: DefaultEventLocationTypeEnum;
label: string;
messageForOrganizer: string;
category: "in person" | "other" | "phone";
category: "in person" | "conferencing" | "other" | "phone";
iconUrl: string;
urlRegExp?: string;
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places.
variable: "locationType" | "locationAddress" | "address" | "locationLink" | "locationPhoneNumber" | "phone";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone";
variable:
| "locationType"
| "locationAddress"
| "address"
| "locationLink"
| "locationPhoneNumber"
| "phone"
| "hostDefault";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone";
} & (
| {
organizerInputType: "phone" | "text" | null;
@@ -60,6 +67,7 @@ export enum DefaultEventLocationTypeEnum {
*/
UserPhone = "userPhone",
Link = "link",
Conferencing = "conferencing",
}
export const defaultLocations: DefaultEventLocationType[] = [
@@ -88,6 +96,17 @@ export const defaultLocations: DefaultEventLocationType[] = [
iconUrl: "/map-pin.svg",
category: "in person",
},
{
default: true,
type: DefaultEventLocationTypeEnum.Conferencing,
iconUrl: "/link.svg",
organizerInputType: null,
label: "organizer_default_conferencing_app",
variable: "hostDefault",
defaultValueVariable: "hostDefault",
category: "conferencing",
messageForOrganizer: "",
},
{
default: true,
type: DefaultEventLocationTypeEnum.Link,
@@ -130,7 +149,9 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = {
type: string;
displayLocationPublicly?: boolean;
} & Partial<Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone", string>>;
} & Partial<
Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string>
>;
// integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
export type BookingLocationValue = string;
+1
View File
@@ -8,6 +8,7 @@
],
"scripts": {
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
-14
View File
@@ -1,14 +0,0 @@
/* @see https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/31#issuecomment-1024722576 */
const tailwind = require("prettier-plugin-tailwindcss");
const sortImports = require("@trivago/prettier-plugin-sort-imports");
const combinedFormatter = {
parsers: {
typescript: {
...tailwind.parsers.typescript,
preprocess: sortImports.parsers.typescript.preprocess,
},
},
};
module.exports = combinedFormatter;
+3 -3
View File
@@ -20,13 +20,13 @@
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4",
"@trivago/prettier-plugin-sort-imports": "3.2.0",
"@trivago/prettier-plugin-sort-imports": "4.1.1",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"eslint": "^8.34.0",
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.1.13",
"prettier": "^2.8.6",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.1",
"typescript": "^4.9.4"
+8 -1
View File
@@ -16,7 +16,14 @@ module.exports = {
"^[./]",
],
importOrderSeparation: true,
plugins: [require("./merged-prettier-plugin")],
plugins: [
"@trivago/prettier-plugin-sort-imports",
/**
* **NOTE** tailwind plugin must come last!
* @see https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
*/
"prettier-plugin-tailwindcss",
],
overrides: [
{
files: ["apps/website/lib/utils/wordlist/wordlist.ts"],
+20 -1
View File
@@ -1,3 +1,5 @@
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: {
@@ -6,8 +8,14 @@ export const Info = (props: {
extraInfo?: React.ReactNode;
withSpacer?: boolean;
lineThrough?: boolean;
formatted?: boolean;
}) => {
if (!props.description || props.description === "") return null;
const descriptionCSS = "color: '#101010'; font-weight: 400; line-height: 24px; margin: 0;";
const safeDescription = markdownToSafeHTML(props.description.toString()) || "";
return (
<>
{props.withSpacer && <Spacer />}
@@ -21,7 +29,18 @@ export const Info = (props: {
whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined,
}}>
{props.description}
{props.formatted ? (
<p
className="dark:text-darkgray-600 mt-2 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{
__html: safeDescription
.replaceAll("<p>", `<p style="${descriptionCSS}">`)
.replaceAll("<li>", `<li style="${descriptionCSS}">`),
}}
/>
) : (
props.description
)}
</p>
{props.extraInfo}
</div>
@@ -76,7 +76,7 @@ export const BaseScheduledEmail = (
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses calEvent={props.calEvent} />
+15 -316
View File
@@ -4,6 +4,16 @@
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
function generateRandomHexColor() {
// Generate a random integer between 0 and 16777215 (FFFFFF in hex)
const randomInt = Math.floor(Math.random() * 16777216);
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
const hexString = randomInt.toString(16).padStart(6, '0');
// Return the hex string with a '#' prefix
return `#${hexString}`;
}
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
@@ -90,6 +100,7 @@
</div>
<span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span>
<div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button>
@@ -140,12 +151,12 @@
Align Center
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
backgroundColor: '#D3D3D3',
backgroundColor: generateRandomHexColor(),
},
disabledDateButton: {
backgroundColor: 'lightslategray',
backgroundColor: generateRandomHexColor(),
},}})})()">
Change Date Button Color
Change 'enabledDateButton` and `disabledDateButton` background Color
</button>
</div>
@@ -189,318 +200,6 @@
<div class="place">
</div>
</div>
<script>
const callback = function (e) {
const detail = e.detail;
const namespace = detail.namespace || "default";
if (detail.type === "linkReady") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
} else if (detail.type === "linkFailed") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(
e.detail
);
};
const searchParams = new URL(document.URL).searchParams;
let only = window.only
if (only === "all" || only === "ns:default") {
Cal("init", {
debug: 1,
origin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
__autoScroll:true,
iframeAttrs: {
id: "cal-booking-place-default-iframe"
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:second") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second", {
debug: 1,
origin: "http://localhost:3000",
});
// Bulk API is supported - Keep all configuration at one place.
Cal.ns.second(
[
"inline",
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
config: {
iframeAttrs: {
id: "cal-booking-place-second-iframe"
},
}
},
]
);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:third") {
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-third-iframe"
},
}
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
enabledDateButton: {
backgroundColor: "red"
},
disabledDateButton: {
backgroundColor: "green"
}
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fourth") {
Cal("init", "fourth", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.fourth(
[
"inline",
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
config: {
iframeAttrs: {
id: "cal-booking-place-fourth-iframe"
},
}
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fifth") {
Cal("init", "fifth", {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.fifth(
[
"inline",
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
config: {
iframeAttrs: {
id: "cal-booking-place-fifth-iframe"
},
}
},
],
);
Cal.ns.fifth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal('init', 'prerendertestLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
});
}
if (only === "all" || only === "inline-routing-form") {
Cal('init', 'inline-routing-form', {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns['inline-routing-form'](
[
"inline",
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe"
},
}
},
],
);
}
if (only === "all" || only === "hideEventTypeDetails") {
let identifier = "hideEventTypeDetails"
Cal('init', identifier, {
debug: 1,
origin: "http://localhost:3000",
})
Cal.ns.hideEventTypeDetails([
['inline', {
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
config: {
iframeAttrs: {
id: `cal-booking-place-${identifier}-iframe`
},
}
}],
[
'ui', {
hideEventTypeDetails:true
}
]
]);
}
Cal('init', 'popupDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupHideEventTypeDetails', {
debug: 1,
origin: "http://localhost:3000",
});
Cal.ns.popupHideEventTypeDetails('ui', {
hideEventTypeDetails:true
});
Cal('init', 'popupReschedule', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupAutoTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkLightTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinkDarkTheme', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupTeamLinksList', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'popupPaidEvent', {
debug: 1,
origin: "http://localhost:3000",
})
Cal("init", "floatingButton", {
debug: 1,
origin: "http://localhost:3000",
});
Cal('init', 'routingFormAuto', {
debug: 1,
origin: "http://localhost:3000",
})
Cal('init', 'routingFormDark', {
debug: 1,
origin: "http://localhost:3000",
})
if (only === "all" || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro"
})
}
</script>
<script></script>
<script src="./playground.ts"></script>
</body>
</html>
+1
View File
@@ -23,6 +23,7 @@
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"lint:fix": "eslint --ext .ts,.js src --fix",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts",
+321
View File
@@ -0,0 +1,321 @@
import type { GlobalCal, GlobalCalWithoutNs } from "./src/embed";
type A = GlobalCalWithoutNs;
const Cal = window.Cal as GlobalCal;
const callback = function (e) {
const detail = e.detail;
const namespace = detail.namespace || "default";
if (detail.type === "linkReady") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
} else if (detail.type === "linkFailed") {
document.querySelector(`#cal-booking-place-${namespace} .loader`).remove();
}
document.querySelector(`#cal-booking-place-${namespace} .last-action`).innerHTML = JSON.stringify(e.detail);
};
const searchParams = new URL(document.URL).searchParams;
const only = window.only;
if (only === "all" || only === "ns:default") {
Cal("init", {
debug: true,
calOrigin: "http://localhost:3000",
});
Cal("inline", {
elementOrSelector: "#cal-booking-place-default .place",
calLink: "pro?case=1",
config: {
iframeAttrs: {
id: "cal-booking-place-default-iframe",
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
Cal("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:second") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "second", {
debug: true,
origin: "http://localhost:3000",
});
// Bulk API is supported - Keep all configuration at one place.
// Not able to type Bulk Api correctly when the first argument itself is an array.
Cal.ns.second([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-second .place",
calLink: "pro?case=2",
config: {
iframeAttrs: {
id: "cal-booking-place-second-iframe",
},
},
},
]);
Cal.ns.second("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:third") {
// Create a namespace "third". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "third", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.third(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-third .place",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "cal-booking-place-third-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
enabledDateButton: {
backgroundColor: "red",
},
disabledDateButton: {
backgroundColor: "green",
},
},
},
]
);
Cal.ns.third("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fourth") {
Cal("init", "fourth", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.fourth(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fourth .place",
calLink: "team/seeded-team",
config: {
iframeAttrs: {
id: "cal-booking-place-fourth-iframe",
},
},
},
],
[
"ui",
{
styles: {
body: {
background: "transparent",
},
branding: {
brandColor: "#81e61c",
lightColor: "#494545",
lighterColor: "#4c4848",
lightestColor: "#7c7777",
highlightColor: "#9b0e0e",
medianColor: "black",
},
},
},
]
);
Cal.ns.fourth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:fifth") {
Cal("init", "fifth", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.fifth([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-fifth .place",
calLink: "team/seeded-team/collective-seeded-team-event",
config: {
iframeAttrs: {
id: "cal-booking-place-fifth-iframe",
},
},
},
]);
Cal.ns.fifth("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal("init", "prerendertestLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
});
}
if (only === "all" || only === "inline-routing-form") {
Cal("init", "inline-routing-form", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns["inline-routing-form"]([
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-inline-routing-form .place",
calLink: "forms/948ae412-d995-4865-875a-48302588de03",
config: {
iframeAttrs: {
id: "cal-booking-place-inline-routing-form-iframe",
},
},
},
]);
}
if (only === "all" || only === "hideEventTypeDetails") {
const identifier = "hideEventTypeDetails";
Cal("init", identifier, {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.hideEventTypeDetails(
[
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: `#cal-booking-place-${identifier} .place`,
calLink: "free/30min",
config: {
iframeAttrs: {
id: `cal-booking-place-${identifier}-iframe`,
},
},
},
],
[
"ui",
{
hideEventTypeDetails: true,
},
]
);
}
Cal("init", "popupDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupHideEventTypeDetails", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.popupHideEventTypeDetails("ui", {
hideEventTypeDetails: true,
});
Cal("init", "popupReschedule", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupAutoTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinksList", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupPaidEvent", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "floatingButton", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "routingFormAuto", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "routingFormDark", {
debug: true,
origin: "http://localhost:3000",
});
if (only === "all" || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro",
});
}
@@ -1,5 +1,36 @@
import getFloatingButtonHtml from "./FloatingButtonHtml";
type ModalTargetDatasetProps = {
calLink: string;
calNamespace: string;
calOrigin: string;
};
type CamelCase<T extends string> = T extends `${infer U}${infer V}` ? `${Uppercase<U>}${V}` : T;
type HyphenatedStringToCamelCase<S extends string> = S extends `${infer T}-${infer U}`
? `${T}${HyphenatedStringToCamelCase<CamelCase<U>>}`
: CamelCase<S>;
type HyphenatedDataStringToCamelCase<S extends string> = S extends `data-${infer U}`
? HyphenatedStringToCamelCase<U>
: S;
const dataAttributes = [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
] as const;
type DataAttributes = (typeof dataAttributes)[number];
type DataAttributesCamelCase = HyphenatedDataStringToCamelCase<DataAttributes>;
export type FloatingButtonDataset = {
[key in DataAttributesCamelCase]: string;
};
export class FloatingButton extends HTMLElement {
static updatedClassString(position: string, classString: string) {
return [
@@ -8,66 +39,69 @@ export class FloatingButton extends HTMLElement {
].join(" ");
}
// Button added here triggers the modal on click. So, it has to have the same data attributes as the modal target as well
dataset!: DOMStringMap & FloatingButtonDataset & ModalTargetDatasetProps;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return [
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
];
return dataAttributes;
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
attributeChangedCallback(name: DataAttributes, oldValue: string, newValue: string) {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
const buttonWrapperEl = this.shadowRoot?.querySelector<HTMLElement>("button");
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonEl) {
throw new Error("#button not found");
}
if (!buttonWrapperEl) {
throw new Error("button element not found");
}
if (!buttonIconEl) {
throw new Error("#button-icon not found");
}
if (name === "data-button-text") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.textContent = newValue;
} else if (name === "data-hide-button-icon") {
const buttonIconEl = this.shadowRoot?.querySelector<HTMLElement>("#button-icon");
if (!buttonIconEl) {
throw new Error("Button not found");
}
buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
} else if (name === "data-button-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.backgroundColor = newValue;
buttonWrapperEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.color = newValue;
buttonWrapperEl.style.color = newValue;
} else {
console.log("Unknown attribute changed", name, oldValue, newValue);
}
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
constructor() {
super();
const buttonText = this.dataset["buttonText"];
const buttonPosition = this.dataset["buttonPosition"];
const buttonColor = this.dataset["buttonColor"];
const buttonTextColor = this.dataset["buttonTextColor"];
const dataset = this.dataset as FloatingButtonDataset;
const buttonText = dataset["buttonText"];
const buttonPosition = dataset["buttonPosition"];
const buttonColor = dataset["buttonColor"];
const buttonTextColor = dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${window.Cal!.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText!,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")],
buttonColor: buttonColor!,
buttonTextColor: buttonTextColor!,
const buttonHtml = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
buttonColor: buttonColor,
buttonTextColor: buttonTextColor,
})}`;
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = buttonHtml;
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = buttonHtml;
}
}
+22 -11
View File
@@ -1,31 +1,42 @@
import loaderCss from "../loader.css";
import loaderCss from "../loader.css?inline";
import { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() {
return ["loading"];
}
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
const errorEl = this.shadowRoot.querySelector<HTMLElement>("#error");
const slotEl = this.shadowRoot.querySelector<HTMLElement>("slot");
if (!loaderEl || !slotEl || !errorEl) {
throw new Error("One of loaderEl, slotEl or errorEl is missing");
}
if (name === "loading") {
if (newValue == "done") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none";
loaderEl.style.display = "none";
} else if (newValue === "failed") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none";
this.shadowRoot!.querySelector<HTMLElement>("#error")!.style.display = "block";
this.shadowRoot!.querySelector<HTMLElement>("slot")!.style.visibility = "hidden";
loaderEl.style.display = "none";
slotEl.style.visibility = "hidden";
errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode);
this.shadowRoot!.querySelector<HTMLElement>("#error")!.innerText = errorString;
errorEl.innerText = errorString;
}
}
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = `<style>${
window.Cal!.__css
}</style><style>${loaderCss}</style>${inlineHtml}`;
this.assertHasShadowRoot();
this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
}
}
@@ -101,7 +101,7 @@ export class ModalBox extends HTMLElement {
constructor() {
super();
const modalHtml = `<style>${window.Cal!.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
+106 -84
View File
@@ -2,18 +2,40 @@ import { useRouter } from "next/router";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
export interface UiConfig {
export type UiConfig = {
hideEventTypeDetails?: boolean;
theme?: "dark" | "light" | "auto";
styles?: EmbedStyles;
}
styles?: EmbedStyles & EmbedNonStylesConfig;
};
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
nonStyles: {} as EmbedNonStylesConfig | undefined,
namespace: null as string | null,
embedType: undefined as undefined | null | string,
// Store all React State setters here.
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: string) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined,
};
declare global {
interface Window {
CalEmbed: {
__logQueue?: any[];
embedStore: any;
__logQueue?: unknown[];
embedStore: typeof embedStore;
};
CalComPageStatus: string;
isEmbed: () => boolean;
@@ -23,28 +45,6 @@ declare global {
}
}
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {},
namespace: null,
embedType: undefined,
// Store all React State setters here.
reactStylesStateSetters: {},
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
} as {
styles: UiConfig["styles"];
namespace: string | null;
embedType: undefined | null | string;
reactStylesStateSetters: any;
parentInformedAboutContentHeight: boolean;
windowLoadEventFired: boolean;
theme?: UiConfig["theme"];
uiConfig?: Omit<UiConfig, "styles" | "theme">;
setTheme?: (arg0: string) => void;
setUiConfig?: (arg0: UiConfig) => void;
};
let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined";
@@ -58,7 +58,7 @@ if (isBrowser) {
}
}
function runAsap(fn: (...arg: any) => void) {
function runAsap(fn: (...arg: unknown[]) => void) {
if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50);
@@ -66,7 +66,7 @@ function runAsap(fn: (...arg: any) => void) {
return requestAnimationFrame(fn);
}
function log(...args: any[]) {
function log(...args: unknown[]) {
if (isBrowser) {
const namespace = getNamespace();
@@ -95,7 +95,7 @@ interface EmbedStyles {
}
interface EmbedNonStylesConfig {
/** Default would be center */
align: "left";
align?: "left";
branding?: {
brandColor?: string;
lightColor?: string;
@@ -108,10 +108,10 @@ interface EmbedNonStylesConfig {
};
}
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
(setEmbedStyle as any)((styles: any) => {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
@@ -120,15 +120,48 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
}
};
const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig, setStyles: any) => {
embedStore.reactStylesStateSetters[elementName] = setStyles;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
setStyles(embedStore.styles);
const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.nonStyles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
setEmbedStyle((styles) => {
return {
...styles,
...stylesConfig,
};
});
}
};
const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig) => {
delete embedStore.reactStylesStateSetters[elementName];
const registerNewSetter = (
registration:
| {
elementName: keyof EmbedStyles;
setState: SetStyles;
styles: true;
}
| {
elementName: keyof EmbedNonStylesConfig;
setState: setNonStylesConfig;
styles: false;
}
) => {
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
// So, we should call the setter immediately with available embedStyles
if (registration.styles) {
embedStore.reactStylesStateSetters[registration.elementName as keyof EmbedStyles] = registration.setState;
registration.setState(embedStore.styles || {});
return () => {
delete embedStore.reactStylesStateSetters[registration.elementName];
};
} else {
embedStore.reactNonStylesStateSetters[registration.elementName as keyof EmbedNonStylesConfig] =
registration.setState;
registration.setState(embedStore.nonStyles || {});
return () => {
delete embedStore.reactNonStylesStateSetters[registration.elementName];
};
}
};
function isValidNamespace(ns: string | null | undefined) {
@@ -155,33 +188,28 @@ export const useEmbedUiConfig = () => {
// TODO: Make it usable as an attribute directly instead of styles value. It would allow us to go beyond styles e.g. for debugging we can add a special attribute indentifying the element on which UI config has been applied
export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [styles, setStyles] = useState({} as EmbedStyles);
const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
return registerNewSetter({ elementName, setState: setStyles, styles: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {};
};
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
const [styles, setStyles] = useState({} as EmbedNonStylesConfig);
const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => {
registerNewSetter(elementName, setStyles);
// It's important to have an element's embed style be required in only one component. If due to any reason it is required in multiple components, we would override state setter.
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return styles[elementName] || {};
// Always read the data from global embedStore so that even across components, the same data is used.
const nonStyles = embedStore.nonStyles || {};
return nonStyles[elementName] || {};
};
export const useIsBackgroundTransparent = () => {
@@ -251,8 +279,8 @@ function unhideBody() {
document.body.style.visibility = "visible";
}
// If you add a method here, give type safety to parent manually by adding it to embed.ts. Look for "parentKnowsIframeReady" in it
export const methods = {
// 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) {
// TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig);
@@ -277,8 +305,10 @@ export const methods = {
}
setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
},
parentKnowsIframeReady: () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
@@ -293,7 +323,14 @@ export const methods = {
},
};
const messageParent = (data: any) => {
export type InterfaceWithParent = {
// Ensure that only one argument is read by the method
[key in keyof typeof methods]: (firstAndOnlyArg: Parameters<(typeof methods)[key]>[number]) => void;
};
export const interfaceWithParent: InterfaceWithParent = methods;
const messageParent = (data: CustomEvent["detail"]) => {
parent.postMessage(
{
originator: "CAL",
@@ -344,13 +381,15 @@ function keepParentInformedAboutDimensionChanges() {
// Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll.
const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom));
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight));
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
@@ -394,35 +433,18 @@ if (isBrowser) {
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail);
messageParent(detail);
});
// This event should be fired whenever you want to let the content take automatic width which is available.
// Because on cal-iframe we set explicty width to make it look inline and part of page, there is never space available for content to automatically expand
// This is a HACK to quickly tell iframe to go full width and let iframe content adapt to that and set new width.
sdkActionManager?.on("__refreshWidth", () => {
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// runAsap(() => {
// sdkActionManager?.fire("__dimensionChanged", {
// iframeWidth: 100,
// __unit: "%",
// });
// });
});
window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data;
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof methods = data.method;
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
methods[method]?.(data.arg);
interfaceWithParent[method]?.(data.arg as never);
}
});
+366 -287
View File
@@ -1,11 +1,20 @@
/// <reference types="../env" />
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { methods, UiConfig } from "./embed-iframe";
import type { InterfaceWithParent, interfaceWithParent, UiConfig } from "./embed-iframe";
import css from "./embed.css";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } from "./sdk-action-manager";
import allCss from "./tailwind.generated.css";
import allCss from "./tailwind.generated.css?inline";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
export type Message = {
originator: string;
method: keyof InterfaceWithParent;
arg: InterfaceWithParent[keyof InterfaceWithParent];
};
// HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file.
// This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL =
@@ -18,10 +27,20 @@ customElements.define("cal-inline", Inline);
declare module "*.css";
type Namespace = string;
type Config = {
origin: string;
calOrigin: string;
debug?: boolean;
uiDebug?: boolean;
};
type InitArgConfig = Partial<Config> & {
origin?: string;
};
type DoInIframeArg = {
[K in keyof typeof interfaceWithParent]: {
method: K;
arg?: Parameters<(typeof interfaceWithParent)[K]>[0];
};
}[keyof typeof interfaceWithParent];
const globalCal = window.Cal;
if (!globalCal || !globalCal.q) {
@@ -35,9 +54,22 @@ globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as strin
globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css;
function log(...args: any[]) {
function log(...args: unknown[]) {
console.log(...args);
}
// eslint-disable-next-line @typescript-eslint/ban-types
type ValidationSchemaPropType = string | Function;
type ValidationSchema = {
required?: boolean;
props?: Record<
string,
ValidationSchema & {
type: ValidationSchemaPropType | ValidationSchemaPropType[];
}
>;
};
/**
* //TODO: Warn about extra properties not part of schema. Helps in fixing wrong expectations
* A very simple data validator written with intention of keeping payload size low.
@@ -45,8 +77,8 @@ function log(...args: any[]) {
* @param data
* @param schema
*/
function validate(data: any, schema: Record<"props" | "required", any>) {
function checkType(value: any, expectedType: any) {
function validate(data: Record<string, unknown>, schema: ValidationSchema) {
function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
if (typeof expectedType === "string") {
return typeof value == expectedType;
} else {
@@ -54,7 +86,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
}
}
function isUndefined(data: any) {
function isUndefined(data: unknown) {
return typeof data === "undefined";
}
@@ -62,7 +94,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
throw new Error("Argument is required");
}
for (const [prop, propSchema] of Object.entries<Record<"type" | "required", any>>(schema.props)) {
for (const [prop, propSchema] of Object.entries(schema.props || {})) {
if (propSchema.required && isUndefined(data[prop])) {
throw new Error(`"${prop}" is required`);
}
@@ -82,9 +114,23 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
}
}
export type Instruction = [method: string, argument: any] | [method: string, argument: any][];
type SingleInstructionMap = {
// TODO: This makes api("on", {}) loose it's generic type. Find a way to fix it.
// e.g. api("on", { action: "__dimensionChanged", callback: (e) => { /* `e.detail.data` has all possible values for all events/actions */} });
[K in keyof CalApi]: CalApi[K] extends (...args: never[]) => void ? [K, ...Parameters<CalApi[K]>] : never;
};
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
iframeAttrs?: Record<string, string> & {
id?: string;
};
};
export class Cal {
iframe?: HTMLIFrameElement;
@@ -100,35 +146,42 @@ export class Cal {
iframeReady!: boolean;
iframeDoQueue: { method: keyof typeof methods; arg: any }[] = [];
iframeDoQueue: DoInIframeArg[] = [];
api: CalApi;
static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: Record<string, string>) {
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
config = config || {};
return {
...config,
// guests is better for API but Booking Page accepts guest. So do the mapping
guest: config.guests ?? undefined,
};
} as PrefillAndIframeAttrsConfig & { guest?: string | string[] };
}
processInstruction(instruction: Instruction) {
instruction = [].slice.call(instruction, 0);
if (instruction[0] instanceof Array) {
processInstruction(instructionAsArgs: IArguments | Instruction) {
// The instruction is actually an array-like object(arguments). Make it an array.
const instruction: Instruction = [].slice.call(instructionAsArgs);
// If there are multiple instructions in the array, process them one by one
if (typeof instruction[0] !== "string") {
// It is an instruction
instruction.forEach((instruction) => {
this.processInstruction(instruction);
});
return;
}
const [method, ...args] = instruction;
if (!this[method]) {
if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`);
}
try {
(this[method] as (...args: any[]) => void)(...args);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore There can be any method which can have any number of arguments.
this.api[method](...args);
} catch (e) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e);
@@ -136,7 +189,7 @@ export class Cal {
return instruction;
}
processQueue(queue: InstructionQueue) {
processQueue(queue: IArguments[]) {
queue.forEach((instruction) => {
this.processInstruction(instruction);
});
@@ -153,9 +206,11 @@ export class Cal {
createIframe({
calLink,
queryObject = {},
calOrigin,
}: {
calLink: string;
queryObject?: Record<string, string | string[] | Record<string, string>>;
queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
calOrigin?: string;
}) {
const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed";
@@ -163,7 +218,7 @@ export class Cal {
const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject;
if (iframeAttrs && typeof iframeAttrs !== "string" && !(iframeAttrs instanceof Array)) {
if (iframeAttrs && iframeAttrs.id) {
iframe.setAttribute("id", iframeAttrs.id);
}
@@ -180,7 +235,7 @@ export class Cal {
}
}
const urlInstance = new URL(`${config.origin}/${calLink}`);
const urlInstance = new URL(`${calOrigin || config.calOrigin}/${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"
urlInstance.pathname = `${urlInstance.pathname}/embed`;
@@ -203,252 +258,34 @@ export class Cal {
return iframe;
}
init(namespaceOrConfig?: string | Config, config: Config = {} as Config) {
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
}
const { origin, ...restConfig } = config;
if (origin) {
this.__config.origin = origin;
}
this.__config = { ...this.__config, ...restConfig };
}
getConfig() {
return this.__config;
}
// TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions
/**
* It is an instruction that adds embed iframe inline as last child of the element
*/
inline({
calLink,
elementOrSelector,
config,
}: {
calLink: string;
elementOrSelector: string | HTMLElement;
config: Record<string, string>;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
required: true,
type: "string",
},
elementOrSelector: {
required: true,
type: ["string", HTMLElement],
},
config: {
required: false,
type: Object,
},
},
});
config = config || {};
// Keeping auto-scroll disabled for two reasons:
// - If user scrolls the content to an appropriate position, it again resets it to default position which might not be for the liking of the user
// - Sometimes, the position can be wrong(e.g. if there is a fixed position header on top coming above the iframe content).
// Best solution might be to autoscroll only if the iframe is not fully visible, detection of full visibility might be tough
// We need to keep in mind that autoscroll is meant to solve the problem when on a certain view(which is availability page right now), the height goes too high and then suddenly it becomes normal
(config as unknown as any).__autoScroll = !!(config as unknown as any).__autoScroll;
config.embedType = "inline";
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.height = "100%";
iframe.style.width = "100%";
const element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!element) {
throw new Error("Element not found");
}
element.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.inlineEl = template.content.children[0];
(this.inlineEl as unknown as any).__CalAutoScroll = config.__autoScroll;
this.inlineEl.appendChild(iframe);
element.appendChild(template.content);
}
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
}: {
calLink: string;
buttonText?: string;
attributes?: Record<string, string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor: string;
buttonTextColor: string;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let existingEl: HTMLElement | null = null;
if (attributes?.id) {
existingEl = document.getElementById(attributes.id);
}
let el: HTMLElement;
if (!existingEl) {
el = document.createElement("cal-floating-button");
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.namespace;
if (attributes?.id) {
el.id = attributes.id;
}
document.body.appendChild(el);
} else {
el = existingEl;
}
if (buttonText) {
el.setAttribute("data-button-text", buttonText);
}
el.setAttribute("data-hide-button-icon", "" + hideButtonIcon);
el.setAttribute("data-button-position", "" + buttonPosition);
el.setAttribute("data-button-color", "" + buttonColor);
el.setAttribute("data-button-text-color", "" + buttonTextColor);
}
modal({ calLink, config = {}, uid }: { calLink: string; config?: Record<string, string>; uid: number }) {
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
}
config.embedType = "modal";
const iframe = this.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.modalBox = template.content.children[0];
this.modalBox.appendChild(iframe);
this.actionManager.on("__closeIframe", () => {
this.modalBox.setAttribute("state", "closed");
});
document.body.appendChild(template.content);
}
on({
action,
callback,
}: {
action: Parameters<SdkActionManager["on"]>[0];
callback: Parameters<SdkActionManager["on"]>[1];
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
action: {
required: true,
type: "string",
},
callback: {
required: true,
type: Function,
},
},
});
this.actionManager.on(action, callback);
}
off({
action,
callback,
}: {
action: Parameters<SdkActionManager["on"]>[0];
callback: Parameters<SdkActionManager["on"]>[1];
}) {
this.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
type: "string",
required: true,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.getConfig();
const urlInstance = new URL(`${config.origin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}
ui(uiConfig: UiConfig) {
validate(uiConfig, {
required: true,
props: {
theme: {
required: false,
type: "string",
},
styles: {
required: false,
type: Object,
},
},
});
this.doInIframe({ method: "ui", arg: uiConfig });
}
doInIframe({
method,
arg,
}: // TODO: Need some TypeScript magic here to remove hardcoded types
| { method: "ui"; arg: Parameters<(typeof methods)["ui"]>[0] }
| { method: "parentKnowsIframeReady"; arg: undefined }) {
doInIframe(doInIframeArg: DoInIframeArg) {
if (!this.iframeReady) {
this.iframeDoQueue.push({ method, arg });
this.iframeDoQueue.push(doInIframeArg);
return;
}
// TODO: Ensure that origin is as defined by user. Generally it would be cal.com but in case of self hosting it can be anything.
this.iframe!.contentWindow!.postMessage({ originator: "CAL", method, arg }, "*");
if (!this.iframe) {
throw new Error("iframe doesn't exist. `createIframe` must be called before `doInIframe`");
}
if (this.iframe.contentWindow) {
// TODO: Ensure that targetOrigin is as defined by user(and not *). Generally it would be cal.com but in case of self hosting it can be anything.
// Maybe we can derive targetOrigin from __config.origin
this.iframe.contentWindow.postMessage(
{ originator: "CAL", method: doInIframeArg.method, arg: doInIframeArg.arg },
"*"
);
}
}
constructor(namespace: string, q: InstructionQueue) {
constructor(namespace: string, q: IArguments[]) {
this.__config = {
// Use WEBAPP_URL till full page reload problem with website URL is solved
origin: WEBAPP_URL,
calOrigin: WEBAPP_URL,
};
this.api = new CalApi(this);
this.namespace = namespace;
this.actionManager = new SdkActionManager(namespace);
@@ -462,24 +299,17 @@ export class Cal {
// 3. Parent window would check what iframe height can be set according to parent Element
this.actionManager.on("__dimensionChanged", (e) => {
const { data } = e.detail;
const iframe = this.iframe!;
const iframe = this.iframe;
if (!iframe) {
// Iframe might be pre-rendering
return;
}
let unit = "px";
if (data.__unit) {
unit = data.__unit;
}
const unit = "px";
if (data.iframeHeight) {
iframe.style.height = data.iframeHeight + unit;
}
// if (data.iframeWidth) {
// iframe.style.width = data.iframeWidth + unit;
// }
if (this.modalBox) {
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
@@ -490,9 +320,9 @@ export class Cal {
this.actionManager.on("__iframeReady", () => {
this.iframeReady = true;
this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined });
this.iframeDoQueue.forEach(({ method, arg }) => {
this.doInIframe({ method, arg });
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
this.iframeDoQueue.forEach((doInIframeArg) => {
this.doInIframe(doInIframeArg);
});
});
@@ -523,34 +353,276 @@ export class Cal {
}
}
export interface GlobalCal {
(methodName: string, arg?: any): void;
class CalApi {
cal: Cal;
constructor(cal: Cal) {
this.cal = cal;
}
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as Config;
}
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
this.cal.__config = { ...this.cal.__config, ...restConfig };
}
/**
* It is an instruction that adds embed iframe inline as last child of the element
*/
inline({
calLink,
elementOrSelector,
config,
}: {
calLink: string;
elementOrSelector: string | HTMLElement;
config?: PrefillAndIframeAttrsConfig;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
// TODO: Add a special type calLink for it and validate that it doesn't start with / or https?://
required: true,
type: "string",
},
elementOrSelector: {
required: true,
type: ["string", HTMLElement],
},
config: {
required: false,
type: Object,
},
},
});
config = config || {};
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "inline";
const iframe = this.cal.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
iframe.style.height = "100%";
iframe.style.width = "100%";
const element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!element) {
throw new Error("Element not found");
}
element.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.cal.inlineEl = template.content.children[0];
this.cal.inlineEl.appendChild(iframe);
element.appendChild(template.content);
}
floatingButton({
calLink,
buttonText = "Book my Cal",
hideButtonIcon = false,
attributes,
buttonPosition = "bottom-right",
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
calOrigin,
}: {
calLink: string;
buttonText?: string;
attributes?: Record<"id", string> & Record<string | "id", string>;
hideButtonIcon?: boolean;
buttonPosition?: "bottom-left" | "bottom-right";
buttonColor?: string;
buttonTextColor?: string;
calOrigin?: string;
}) {
// validate(arguments[0], {
// required: true,
// props: {
// calLink: {
// required: true,
// type: "string",
// },
// },
// });
let existingEl: HTMLElement | null = null;
if (attributes?.id) {
existingEl = document.getElementById(attributes.id);
}
let el: FloatingButton;
if (!existingEl) {
el = document.createElement("cal-floating-button") as FloatingButton;
// It makes it a target element that opens up embed modal on click
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.cal.namespace;
el.dataset.calOrigin = calOrigin ?? "";
if (attributes?.id) {
el.id = attributes.id;
}
document.body.appendChild(el);
} else {
el = existingEl as FloatingButton;
}
const dataset = el.dataset;
dataset["buttonText"] = buttonText;
dataset["hideButtonIcon"] = "" + hideButtonIcon;
dataset["buttonPosition"] = "" + buttonPosition;
dataset["buttonColor"] = "" + buttonColor;
dataset["buttonTextColor"] = "" + buttonTextColor;
}
modal({
calLink,
calOrigin,
config = {},
uid,
}: {
calLink: string;
config?: PrefillAndIframeAttrsConfig;
uid?: string | number;
calOrigin?: string;
}) {
uid = uid || 0;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "modal";
const iframe = this.cal.createIframe({ calLink, calOrigin, queryObject: Cal.getQueryObject(config) });
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
this.cal.actionManager.on("__closeIframe", () => {
this.cal.modalBox.setAttribute("state", "closed");
});
document.body.appendChild(template.content);
}
on<T extends keyof EventDataMap>({
action,
callback,
}: {
action: T;
callback: (arg0: CustomEvent<EventData<T>>) => void;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
action: {
required: true,
type: "string",
},
callback: {
required: true,
type: Function,
},
},
});
this.cal.actionManager.on(action, callback);
}
off({ action, callback }: { action: never; callback: never }) {
this.cal.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
props: {
calLink: {
type: "string",
required: true,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.cal.getConfig();
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}
ui(uiConfig: UiConfig) {
validate(uiConfig, {
required: true,
props: {
theme: {
required: false,
type: "string",
},
styles: {
required: false,
type: Object,
},
},
});
this.cal.doInIframe({ method: "ui", arg: uiConfig });
}
}
// This is a full fledged Cal instance but doesn't have ns property because it would be nested inside an ns instance already
export interface GlobalCalWithoutNs {
<T extends keyof SingleInstructionMap>(methodName: T, ...arg: Rest<SingleInstructionMap[T]>): void;
/** Marks that the embed.js is loaded. Avoids re-downloading it. */
loaded?: boolean;
/** Maintains a queue till the time embed.js isn't loaded */
q?: InstructionQueue;
q: IArguments[];
/** If user registers multiple namespaces, those are available here */
ns?: Record<string, GlobalCal>;
instance?: Cal;
__css?: string;
fingerprint?: string;
__logQueue?: any[];
__logQueue?: unknown[];
}
// Well Omit removes the Function Signature from a type if used. So, instead construct the types like this.
type GlobalCalWithNs = GlobalCalWithoutNs & {
ns: Record<string, GlobalCalWithoutNs>;
};
export type GlobalCal = GlobalCalWithNs;
declare global {
interface Window {
Cal?: GlobalCal;
Cal: GlobalCal;
}
}
export interface CalWindow extends Window {
Cal?: GlobalCal;
Cal: GlobalCal;
}
globalCal.instance = new Cal("", globalCal.q!);
for (const [ns, api] of Object.entries(globalCal.ns!)) {
api.instance = new Cal(ns, api.q!);
globalCal.instance = new Cal("", globalCal.q);
for (const [ns, api] of Object.entries(globalCal.ns)) {
api.instance = new Cal(ns, api.q);
}
/**
@@ -571,38 +643,45 @@ window.addEventListener("message", (e) => {
if (!actionManager) {
throw new Error("Unhandled Action" + parsedAction);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actionManager.fire(parsedAction.type, detail.data);
});
document.addEventListener("click", (e) => {
const htmlElement = e.target;
if (!(htmlElement instanceof HTMLElement)) {
const targetEl = e.target;
if (!(targetEl instanceof HTMLElement)) {
return;
}
const path = htmlElement.dataset.calLink;
const path = targetEl.dataset.calLink;
if (!path) {
return;
}
const modalUniqueId = ((htmlElement as unknown as any).uniqueId =
(htmlElement as unknown as any).uniqueId || Date.now());
const namespace = htmlElement.dataset.calNamespace;
const configString = htmlElement.dataset.calConfig || "";
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
const namespace = targetEl.dataset.calNamespace;
const configString = targetEl.dataset.calConfig || "";
const calOrigin = targetEl.dataset.calOrigin || "";
let config;
try {
config = JSON.parse(configString);
} catch (e) {
config = {};
}
let api = globalCal;
let api: GlobalCalWithoutNs = globalCal;
if (namespace) {
api = globalCal.ns![namespace];
api = globalCal.ns[namespace];
}
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
api("modal", {
calLink: path,
config,
uid: modalUniqueId,
calOrigin,
});
});
+16 -10
View File
@@ -2,10 +2,9 @@ const WEBAPP_URL =
import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`;
const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
(window as any).fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
// Install Cal Embed Code Snippet
(function (C, A, L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const p = function (a: any, ar: any) {
a.q.push(ar);
};
@@ -13,7 +12,7 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
C.Cal =
C.Cal ||
function () {
const cal = C.Cal!;
const cal = C.Cal;
// eslint-disable-next-line prefer-rest-params
const ar = arguments;
if (!cal.loaded) {
@@ -23,13 +22,16 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
cal.loaded = true;
}
if (ar[0] === L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api: { (): void; q?: any[] } = function () {
// eslint-disable-next-line prefer-rest-params
p(api, arguments);
};
const namespace = ar[1];
api!.q = api.q || [];
typeof namespace === "string" ? (cal!.ns![namespace] = api) && p(api, ar) : p(cal, ar);
api.q = api.q || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar);
return;
}
p(cal, ar);
@@ -37,20 +39,24 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
})(window, EMBED_LIB_URL, "init");
const previewWindow = window;
previewWindow.Cal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string;
previewWindow.Cal!("init", {
previewWindow.Cal("init", {
origin: WEBAPP_URL,
});
const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink");
if (embedType! === "inline") {
previewWindow.Cal!("inline", {
if (!calLink) {
throw new Error('Missing "calLink" query parameter');
}
if (embedType === "inline") {
previewWindow.Cal("inline", {
elementOrSelector: "#my-embed",
calLink: calLink,
});
} else if (embedType === "floating-popup") {
previewWindow.Cal!("floatingButton", {
previewWindow.Cal("floatingButton", {
calLink: calLink,
attributes: {
id: "my-floating-button",
@@ -58,7 +64,7 @@ if (embedType! === "inline") {
});
} else if (embedType === "element-click") {
const button = document.createElement("button");
button.setAttribute("data-cal-link", calLink!);
button.setAttribute("data-cal-link", calLink);
button.innerHTML = "I am a button that exists on your website";
document.body.appendChild(button);
}
@@ -1,5 +1,5 @@
type Namespace = string;
type CustomEventDetail = Record<string, any>;
type CustomEventDetail = Record<string, unknown>;
function _fireEvent(fullName: string, detail: CustomEventDetail) {
const event = new window.CustomEvent(fullName, {
@@ -9,6 +9,53 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event);
}
export type EventDataMap = {
eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventType: any;
};
linkFailed: {
code: string;
msg: string;
data: {
url: string;
};
};
linkReady: Record<string, never>;
bookingSuccessful: {
// TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them.
booking: unknown;
eventType: unknown;
date: string;
duration: number | undefined;
organizer: {
name: string;
email: string;
timeZone: string;
};
confirmed: boolean;
};
"*": Record<string, unknown>;
__routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>;
__closeIframe: Record<string, never>;
__iframeReady: Record<string, never>;
__dimensionChanged: {
iframeHeight: number;
iframeWidth: number;
isFirstTime: boolean;
};
};
export type EventData<T extends keyof EventDataMap> = {
[K in T]: {
type: string;
namespace: string;
fullType: string;
data: EventDataMap[K];
};
}[T];
export class SdkActionManager {
namespace: Namespace;
@@ -31,7 +78,7 @@ export class SdkActionManager {
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`;
}
fire(name: string, data: CustomEventDetail) {
fire<T extends keyof EventDataMap>(name: T, data: EventDataMap[T]) {
const fullName = this.getFullActionName(name);
const detail = {
type: name,
@@ -46,12 +93,12 @@ export class SdkActionManager {
_fireEvent(this.getFullActionName("*"), detail);
}
on(name: string, callback: (arg0: CustomEvent<CustomEventDetail>) => void) {
on<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.addEventListener(fullName, callback as EventListener);
}
off(name: string, callback: (arg0: CustomEvent<CustomEventDetail>) => void) {
off<T extends keyof EventDataMap>(name: T, callback: (arg0: CustomEvent<EventData<T>>) => void) {
const fullName = this.getFullActionName(name);
window.removeEventListener(fullName, callback as EventListener);
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"target": "ES2015",
"module": "esnext",
"paths": {
"@lib/*": ["../../../apps/web/lib/*"]
+10
View File
@@ -0,0 +1,10 @@
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./floating.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
/* First make sure that you have installed the package */
/* If you are using yarn */
// yarn add @calcom/embed-react
/* If you are using npm */
// npm install @calcom/embed-react
import { useEffect } from "react";
import * as React from "react";
import ReactDom from "react-dom";
import { getCalApi } from "./src/index";
function App() {
useEffect(() => {
(async function () {
const cal = await getCalApi("http://localhost:3000/embed/embed.js");
cal("floatingButton", { calLink: "teampro/abc", calOrigin: "http://localhost:3000" });
cal("ui", { styles: { branding: { brandColor: "#000000" } }, hideEventTypeDetails: false });
})();
}, []);
return null;
}
ReactDom.render(<App />, document.getElementById("root"));
+1 -1
View File
@@ -2,7 +2,7 @@
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./test-cal.tsx"></script>
<script type="module" src="./inline.tsx"></script>
</head>
<body>
<div id="root"></div>
@@ -1,3 +1,4 @@
import * as React from "react";
import { useEffect } from "react";
import { useState } from "react";
import ReactDom from "react-dom";
@@ -47,7 +48,6 @@ function App() {
theme: "dark",
}}
/>
<button data-cal-link="pro">Popup</button>
</>
);
}
+1
View File
@@ -15,6 +15,7 @@
"preview": "vite preview",
"prepare": "NEXT_PUBLIC_EMBED_LIB_URL='https://app.cal.com/embed/embed.js' NEXT_PUBLIC_WEBAPP_URL='https://app.cal.com' yarn build",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./src",
"embed-tests": "yarn playwright test --config=./playwright/config/playwright.config.ts",
"embed-tests-quick": "QUICK=true yarn embed-tests",
+4 -3
View File
@@ -1,4 +1,5 @@
/* eslint-disable prefer-const */
"use client";
import { useEffect, useRef } from "react";
import useEmbed from "./useEmbed";
@@ -10,7 +11,7 @@ type CalProps = {
debug?: boolean;
uiDebug?: boolean;
};
config?: any;
config?: Record<string, string>;
embedJsUrl?: string;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -23,7 +24,7 @@ const Cal = function Cal(props: CalProps) {
const Cal = useEmbed(embedJsUrl);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!Cal || initializedRef.current) {
if (!Cal || initializedRef.current || !ref.current) {
return;
}
initializedRef.current = true;
+4 -2
View File
@@ -1,11 +1,13 @@
"use client";
import type { GlobalCal } from "@calcom/embed-core";
import EmbedSnippet from "@calcom/embed-snippet";
import Cal from "./Cal";
export const getCalApi = (): Promise<GlobalCal> =>
export const getCalApi = (embedJsUrl?: string): Promise<GlobalCal> =>
new Promise(function tryReadingFromWindow(resolve) {
EmbedSnippet();
EmbedSnippet(embedJsUrl);
const api = window.Cal;
if (!api) {
setTimeout(() => {
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import EmbedSnippet from "@calcom/embed-snippet";
@@ -2,6 +2,7 @@
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2015",
"moduleResolution": "Node",
"baseUrl": ".",
"declaration": true,
@@ -14,6 +14,7 @@
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly --declarationDir dist",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src",
"prepare": "NEXT_PUBLIC_EMBED_LIB_URL='https://app.cal.com/embed/embed.js' NEXT_PUBLIC_WEBAPP_URL='https://app.cal.com' yarn build",
"publish-prod:patch": "yarn publish --access public --patch",
+17 -8
View File
@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/ban-ts-comment,prefer-rest-params,prefer-const */
import type { GlobalCal, GlobalCalWithoutNs } from "@calcom/embed-core";
import type { Optional } from "@calcom/types/utils";
/**
* As we want to keep control on the size of this snippet but we want some portion of it to be still readable.
* So, write the code that you need directly but keep it short.
*/
import type { CalWindow } from "@calcom/embed-core";
const WEBAPP_URL =
import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`;
@@ -12,37 +13,45 @@ const WEBAPP_URL =
const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
export default function EmbedSnippet(url = EMBED_LIB_URL) {
(function (C: CalWindow, A, L) {
let p = function (a: any, ar: any) {
(function (C, A, L) {
let p = function (a: GlobalCalWithoutNs, ar: IArguments) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal!;
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
// 'ns' and 'q' are now definitely set with the following 2 lines, so you can safely assert in TypeScript that it's GlobalCal now.
cal.ns = {};
cal.q = cal.q || [];
//@ts-ignore
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api: { (): void; q: any[] } = function () {
const api: GlobalCalWithoutNs = function () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
typeof namespace === "string" ? (cal.ns![namespace] = api) && p(api, ar) : p(cal, ar);
typeof namespace === "string"
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(cal.ns![namespace] = api) && p(api, ar)
: p(cal as GlobalCal, ar);
return;
}
p(cal, ar);
p(cal as GlobalCal, ar);
};
})(
window,
window as Omit<Window, "Cal"> & {
// Make 'ns' and 'q' optional as they are set through the snippet above
Cal: Optional<GlobalCal, "ns" | "q">;
},
//! Replace it with "https://cal.com/embed.js" or the URL where you have embed.js installed
url,
"init"
@@ -1,6 +1,7 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"target": "ES2015",
"baseUrl": ".",
"module": "esnext",
"declaration": true
@@ -622,6 +622,7 @@ async function handler(
let locationBodyString = location;
let defaultLocationUrl = undefined;
if (dynamicUserList.length > 1) {
users = users.sort((a, b) => {
const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0;
@@ -701,6 +702,18 @@ async function handler(
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
// use host default
if (isTeamEventType && locationBodyString === "conferencing") {
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
if (organizerMetadata) {
const app = getAppFromSlug(organizerMetadata?.defaultConferencingApp?.appSlug);
locationBodyString = app?.appData?.location?.type || locationBodyString;
defaultLocationUrl = organizerMetadata?.defaultConferencingApp?.appLink;
} else {
locationBodyString = "";
}
}
const invitee = [
{
@@ -96,11 +96,7 @@ export default function TeamList(props: Props) {
title={t("collective_or_roundrobin")}
description={t("book_your_team_members")}
actionButton={{
href:
"/event-types?dialog=new-eventtype&eventPage=team%2F" +
team.slug +
"&teamId=" +
team.id,
href: "/event-types?dialog=new&eventPage=team%2F" + team.slug + "&teamId=" + team.id,
child: t("create"),
}}
/>
+4 -1
View File
@@ -23,7 +23,7 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
if (!metadata?.paymentId) return { url: null };
const checkoutSession = await stripe.checkout.sessions.retrieve(metadata.paymentId);
/** If there's a pending session but it isn't paid, we need to pay this team */
if (checkoutSession.payment_status === "paid") return { url: null };
if (checkoutSession.payment_status !== "paid") return { url: null };
/** If the session is already paid we return the upgrade URL so team is updated. */
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
};
@@ -98,6 +98,9 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => {
await stripe.subscriptions.update(subscriptionId, {
items: [{ quantity: team.members.length, id: subscriptionItemId }],
});
console.info(
`Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.`
);
} catch (error) {
let message = "Unknown error on updateQuantitySubscriptionFromStripe";
if (error instanceof Error) message = error.message;
@@ -256,7 +256,7 @@ const ProfileView = () => {
<Label className="mt-5 text-black">{t("about")}</Label>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
dangerouslySetInnerHTML={{ __html: team.safeBio || "" }}
/>
</>
)}
@@ -7,7 +7,6 @@ import type { z } from "zod";
import { classNames, parseRecurringEvent } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { addListFormatting, md } from "@calcom/lib/markdownIt";
import type { baseEventTypeSelect } from "@calcom/prisma";
import type { EventTypeModel } from "@calcom/prisma/zod";
import { Badge } from "@calcom/ui";
@@ -26,6 +25,7 @@ export type EventTypeDescriptionProps = {
z.infer<typeof EventTypeModel>,
Exclude<keyof typeof baseEventTypeSelect, "recurringEvent"> | "metadata"
> & {
descriptionAsSafeHTML?: string | null;
recurringEvent: Prisma.JsonValue;
seatsPerTimeSlot?: number;
};
@@ -57,7 +57,7 @@ export const EventTypeDescription = ({
shortenDescription ? "line-clamp-4" : ""
)}
dangerouslySetInnerHTML={{
__html: addListFormatting(md.render(eventType.description)),
__html: eventType.descriptionAsSafeHTML || "",
}}
/>
)}
+2 -1
View File
@@ -4,8 +4,9 @@
**/
export type AppFlags = {
emails: boolean;
insights: boolean;
teams: boolean;
webhooks: boolean;
workflows: boolean;
"booking-page-v2": boolean;
"v2-booking-page": boolean;
};
@@ -1,10 +1,11 @@
import { Card, LineChart, Title } from "@tremor/react";
import { LineChart, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
export const AverageEventDurationChart = () => {
const { t } = useLocale();
@@ -23,7 +24,7 @@ export const AverageEventDurationChart = () => {
if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null;
return (
<Card>
<CardInsights>
<Title>{t("average_event_duration")}</Title>
<LineChart
className="mt-4 h-80"
@@ -33,6 +34,6 @@ export const AverageEventDurationChart = () => {
colors={["blue"]}
valueFormatter={valueFormatter}
/>
</Card>
</CardInsights>
);
};
@@ -1,10 +1,11 @@
import { Card, LineChart, Title } from "@tremor/react";
import { LineChart, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
export const BookingStatusLineChart = () => {
const { t } = useLocale();
@@ -25,7 +26,7 @@ export const BookingStatusLineChart = () => {
if (!isSuccess) return null;
return (
<Card>
<CardInsights>
<Title>{t("event_trends")}</Title>
<LineChart
className="mt-4 h-80"
@@ -35,6 +36,6 @@ export const BookingStatusLineChart = () => {
colors={["gray", "green", "blue", "red"]}
valueFormatter={valueFormatter}
/>
</Card>
</CardInsights>
);
};
@@ -0,0 +1,17 @@
import { Card } from "@tremor/react";
interface ICardProps {
children: React.ReactNode;
className?: string;
}
export const CardInsights = (props: ICardProps) => {
const { children, className = "", ...rest } = props;
return (
<Card className={`shadow-none ring-gray-300 ${className}`} {...rest}>
{children}
</Card>
);
};
@@ -1,9 +1,10 @@
import { Card, Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "@calcom/ui";
import { calculateDeltaType, colors, valueFormatter } from "../lib";
import { CardInsights } from "./Card";
export const KPICard = ({
title,
@@ -20,7 +21,7 @@ export const KPICard = ({
}) => {
const { t } = useLocale();
return (
<Card key={title}>
<CardInsights key={title}>
<Text>{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric>{valueFormatter(previousMetricData.count)}</Metric>
@@ -40,10 +41,12 @@ export const KPICard = ({
startDate: previousDateRange.startDate,
endDate: previousDateRange.endDate,
})}>
<small className="relative top-px text-xs text-gray-600">{t("from_last_period")}</small>
<small className="relative top-px cursor-pointer text-xs text-gray-600">
{t("from_last_period")}
</small>
</Tooltip>
</Flex>
</Flex>
</Card>
</CardInsights>
);
};
@@ -1,30 +1,31 @@
import { Card, Title } from "@tremor/react";
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const LeastBookedTeamMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange } = filter;
const { dateRange, selectedEventTypeId, selectedTeamId: teamId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
});
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
<Card>
<CardInsights>
<Title>{t("least_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
</CardInsights>
);
};
@@ -1,9 +1,10 @@
import { Card, Title } from "@tremor/react";
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const MostBookedTeamMembersTable = () => {
@@ -23,9 +24,9 @@ export const MostBookedTeamMembersTable = () => {
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
<Card>
<CardInsights className="shadow-none">
<Title>{t("most_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
</CardInsights>
);
};
@@ -1,9 +1,10 @@
import { Card, Title, Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import { Table, TableBody, TableCell, TableRow, Text, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
export const PopularEventsTable = () => {
const { t } = useLocale();
@@ -19,34 +20,25 @@ export const PopularEventsTable = () => {
userId: selectedUserId ?? undefined,
});
if (!startDate || !endDate || !teamId) return null;
if (!isSuccess || !startDate || !endDate || !teamId || data?.length === 0) return null;
return (
<Card>
<CardInsights>
<Title>{t("popular_events")}</Title>
<Table className="mt-5">
<TableBody>
{isSuccess ? (
data?.map((item) => (
<TableRow key={item.eventTypeId}>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>{t("no_event_types_found")}</TableCell>
{data.map((item) => (
<TableRow key={item.eventTypeId}>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<strong>0</strong>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
)}
))}
</TableBody>
</Table>
</Card>
</CardInsights>
);
};
@@ -1,4 +1,6 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
@@ -6,19 +8,60 @@ import type { FilterContextType } from "./provider";
import { FilterProvider } from "./provider";
export function FiltersProvider({ children }: { children: React.ReactNode }) {
// useRouter to get initial values from query params
const router = useRouter();
const { startTime, endTime, teamId, userId, eventTypeId, filter } = router.query;
const querySchema = z.object({
startTime: z.string().optional(),
endTime: z.string().optional(),
teamId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
eventTypeId: z.coerce.number().optional(),
filter: z.enum(["event-type", "user"]).optional(),
});
let startTimeParsed, endTimeParsed, teamIdParsed, userIdParsed, eventTypeIdParsed, filterParsed;
const safe = querySchema.safeParse({
startTime,
endTime,
teamId,
userId,
eventTypeId,
filter,
});
if (!safe.success) {
console.error("Failed to parse query params");
} else {
startTimeParsed = safe.data.startTime;
endTimeParsed = safe.data.endTime;
teamIdParsed = safe.data.teamId;
userIdParsed = safe.data.userId;
eventTypeIdParsed = safe.data.eventTypeId;
filterParsed = safe.data.filter;
}
// TODO: Sync insight filters with URL parameters
const [selectedTimeView, setSelectedTimeView] =
useState<FilterContextType["filter"]["selectedTimeView"]>("week");
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(null);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(null);
const [selectedEventTypeId, setSelectedEventTypeId] =
useState<FilterContextType["filter"]["selectedEventTypeId"]>(null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(null);
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(
userIdParsed || null
);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(
teamIdParsed || null
);
const [selectedEventTypeId, setSelectedEventTypeId] = useState<
FilterContextType["filter"]["selectedEventTypeId"]
>(eventTypeIdParsed || null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(
filterParsed ? [filterParsed] : null
);
const [selectedTeamName, setSelectedTeamName] =
useState<FilterContextType["filter"]["selectedTeamName"]>(null);
const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([
dayjs().subtract(1, "month"),
dayjs(),
startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "month"),
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
"t",
]);
return (
@@ -33,13 +76,58 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedEventTypeId,
selectedFilter,
},
setSelectedFilter: (filter) => setSelectedFilter(filter),
setDateRange: (dateRange) => setDateRange(dateRange),
setSelectedFilter: (filter) => {
setSelectedFilter(filter);
const userId = filter?.[0] === "user" ? selectedUserId : undefined;
const eventTypeId = filter?.[0] === "event-type" ? selectedEventTypeId : undefined;
router.push({
query: {
...router.query,
filter: filter?.[0],
userId,
eventTypeId,
},
});
},
setDateRange: (dateRange) => {
setDateRange(dateRange);
router.push({
query: {
...router.query,
startTime: dateRange[0].toISOString(),
endTime: dateRange[1].toISOString(),
},
});
},
setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
setSelectedUserId: (selectedUserId) => setSelectedUserId(selectedUserId),
setSelectedTeamId: (selectedTeamId) => setSelectedTeamId(selectedTeamId),
setSelectedUserId: (selectedUserId) => {
setSelectedUserId(selectedUserId);
router.push({
query: {
...router.query,
userId: selectedUserId,
},
});
},
setSelectedTeamId: (selectedTeamId) => {
setSelectedTeamId(selectedTeamId);
router.push({
query: {
...router.query,
teamId: selectedTeamId,
},
});
},
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
setSelectedEventTypeId: (selectedEventTypeId) => setSelectedEventTypeId(selectedEventTypeId),
setSelectedEventTypeId: (selectedEventTypeId) => {
setSelectedEventTypeId(selectedEventTypeId);
router.push({
query: {
...router.query,
eventTypeId: selectedEventTypeId,
},
});
},
}}>
{children}
</FilterProvider>
@@ -0,0 +1,7 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
}
.tremor-DateRangePicker-calendarButton, .tremor-DateRangePicker-dropdownButton {
@apply dark:bg-darkgray-100 dark:border-darkgray-300 border-gray-300 bg-white text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0 focus-within:ring-gray-800 hover:border-gray-400 dark:focus-within:ring-darkgray-900
}
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useFilterContext } from "../context/provider";
import "./DateSelect.css";
type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null;
@@ -15,44 +16,49 @@ export const DateSelect = () => {
const startValue = startDate?.toDate() || null;
const endValue = endDate?.toDate() || null;
return (
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
onValueChange={(datesArray) => {
const [selected, ...rest] = datesArray;
const [start, end, range] = datesArray;
// If range has value and it's of type RangeType
<div className="custom-date">
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
onValueChange={(datesArray) => {
const [selected, ...rest] = datesArray;
const [start, end, range] = datesArray;
// If range has value and it's of type RangeType
if (range && (range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")) {
setDateRange([dayjs(start), dayjs(end), range]);
return;
} else if (start && !end) {
// If only start time has value that means selected date should push to dateRange with last value null
const currentDates = filter.dateRange;
// remove last position of array
currentDates.pop();
// push new value to array
currentDates.push(dayjs(selected));
// if lenght > 2 then remove first value
if (currentDates.length > 2) {
currentDates.shift();
if (
range &&
(range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")
) {
setDateRange([dayjs(start), dayjs(end), range]);
return;
} else if (start && !end) {
// If only start time has value that means selected date should push to dateRange with last value null
const currentDates = filter.dateRange;
// remove last position of array
currentDates.pop();
// push new value to array
currentDates.push(dayjs(selected));
// if lenght > 2 then remove first value
if (currentDates.length > 2) {
currentDates.shift();
}
setDateRange([currentDates[0], currentDates[1], null]);
return;
}
setDateRange([currentDates[0], currentDates[1], null]);
return;
}
// If range has value and it's of type RangeType
}}
options={undefined}
enableDropdown={true}
placeholder={t("select_date_range")}
enableYearPagination={true}
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
color="blue"
className="h-[42px] max-w-sm"
/>
// If range has value and it's of type RangeType
}}
options={undefined}
enableDropdown={true}
placeholder={t("select_date_range")}
enableYearPagination={true}
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
color="gray"
className="h-[42px] max-w-sm"
/>
</div>
);
};
@@ -8,7 +8,8 @@ type Option = { value: "event-type" | "user"; label: string };
export const FilterType = () => {
const { t } = useLocale();
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId } = useFilterContext();
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId, filter } = useFilterContext();
const { selectedFilter } = filter;
const filterOptions: Option[] = [
{
@@ -21,20 +22,19 @@ export const FilterType = () => {
},
];
const filterValue = selectedFilter
? filterOptions.find((option) => option.value === selectedFilter[0])
: undefined;
return (
<Select<Option>
isMulti={false}
isSearchable={false}
options={filterOptions}
onChange={(input) => {
if (input) {
value={filterValue}
defaultValue={filterValue}
onChange={(newValue) => {
if (newValue) {
// This can multiple values, but for now we only want to have one filter active at a time
setSelectedFilter([input.value]);
if (input.value === "event-type") {
setSelectedUserId(null);
} else if (input.value === "user") {
setSelectedEventTypeId(null);
}
setSelectedFilter([newValue.value]);
}
}}
className="w-32 min-w-[130px]"
+34 -1
View File
@@ -1,12 +1,43 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Tooltip } from "@calcom/ui";
import { FiX } from "@calcom/ui/components/icon";
import { DateSelect } from "./DateSelect";
import { EventTypeListInTeam } from "./EventTypeListInTeam";
import { FilterType } from "./FilterType";
import { TeamList } from "./TeamList";
import { UserListInTeam } from "./UsersListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
const { filter, setSelectedUserId, setSelectedFilter, setSelectedEventTypeId } = useFilterContext();
const { selectedFilter } = filter;
if (!selectedFilter || selectedFilter?.length < 1) return null;
return (
<Tooltip content={t("clear_filters")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiX}
className="h-[38px]"
onClick={() => {
setSelectedFilter(null);
setSelectedUserId(null);
setSelectedEventTypeId(null);
}}
/>
</Tooltip>
);
};
export const Filters = () => {
return (
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row md:flex-nowrap">
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row lg:flex-nowrap">
<TeamList />
<FilterType />
@@ -17,6 +48,8 @@ export const Filters = () => {
<DateSelect />
<ClearFilters />
{/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
<Tooltip content={t("settings")}>
@@ -377,7 +377,6 @@ export const insightsRouter = router({
};
if (userId) {
delete whereConditional.eventType;
whereConditional["userId"] = userId;
}
@@ -707,6 +706,12 @@ export const insightsRouter = router({
}
const eventTypes = await ctx.prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: {
teamId: input.teamId,
},
+1 -1
View File
@@ -536,7 +536,7 @@ const navigation: NavigationItemType[] = [
icon: FiZap,
},
{
name: "Insights",
name: "insights",
href: "/insights",
icon: FiBarChart,
},
+1
View File
@@ -26,6 +26,7 @@ type UsernameSlugLinkProps = {
};
const user: User = {
metadata: null,
theme: null,
credentials: [],
username: "john.doe",
-9
View File
@@ -1,12 +1,3 @@
import MarkdownIt from "markdown-it";
export const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export function addListFormatting(html: string) {
return html
.replaceAll("<ul>", "<ul style='list-style-type: disc; list-style-position: inside; margin-left: 12px'>")
.replaceAll(
"<ol>",
"<ol style='list-style-type: decimal; list-style-position: inside; margin-left: 12px'>"
);
}
+23
View File
@@ -0,0 +1,23 @@
import sanitizeHtml from "sanitize-html";
import { md } from "@calcom/lib/markdownIt";
export function markdownToSafeHTML(markdown: string | null) {
if (!markdown) return null;
const html = md.render(markdown);
const safeHTML = sanitizeHtml(html);
const safeHTMLWithListFormatting = safeHTML
.replace(
/<ul>/g,
"<ul style='list-style-type: disc; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
)
.replace(
/<ol>/g,
"<ol style='list-style-type: decimal; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
);
return safeHTMLWithListFormatting;
}
+3 -3
View File
@@ -1,6 +1,7 @@
import { Prisma, PrismaClient } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import { bookingReferenceMiddleware, eventTypeDescriptionParseAndSanitizeMiddleware } from "./middleware";
import { bookingReferenceMiddleware } from "./middleware";
declare global {
// eslint-disable-next-line no-var
@@ -21,7 +22,6 @@ if (process.env.NODE_ENV !== "production") {
}
// If any changed on middleware server restart is required
bookingReferenceMiddleware(prisma);
eventTypeDescriptionParseAndSanitizeMiddleware(prisma);
export default prisma;
@@ -1,43 +0,0 @@
import type { PrismaClient, EventType } from "@prisma/client";
import { md } from "@calcom/lib/markdownIt";
function parseAndSanitize(description: string) {
const parsedMarkdown = md.render(description);
return parsedMarkdown;
}
function getParsedResults(eventTypes: EventType | EventType[]) {
const results = Array.isArray(eventTypes) ? eventTypes : [eventTypes];
const parsedResults = results.map((record) => ({
...record,
descriptionAsSafeHTML: record.description ? parseAndSanitize(record.description) : null,
}));
/* If the original result was an array, return the parsed array, otherwise is a single record */
return Array.isArray(eventTypes) ? parsedResults : parsedResults[0];
}
/**
* Parses event type descriptions and treat them as markdown,
* then sanitizes the resulting HTML and adds a new `descriptionAsSafeHTML` property.
*/
function eventTypeDescriptionParseAndSanitize(prisma: PrismaClient) {
prisma.$use(async (params, next) => {
if (params.model === "Team") {
const result = await next(params);
if (result?.eventTypes) {
result.eventTypes = getParsedResults(result.eventTypes);
}
return result;
} else if (params.model === "EventType") {
const result = await next(params);
if (result) {
return getParsedResults(result);
}
return result;
}
return next(params);
});
}
export default eventTypeDescriptionParseAndSanitize;
-1
View File
@@ -1,2 +1 @@
export { default as bookingReferenceMiddleware } from "./bookingReference";
export { default as eventTypeDescriptionParseAndSanitizeMiddleware } from "./eventTypeDescriptionParseAndSanitize";
@@ -0,0 +1,10 @@
-- Insert initial feature flags with their default values
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'insights',
true,
'Enable insights for this instance',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;
+1 -1
View File
@@ -17,7 +17,7 @@
"post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts",
"delete-app": "ts-node --transpile-only ./delete-app.ts",
"seed-analytics": "ts-node --transpile-only ./seed-analytics.ts"
"seed-insights": "ts-node --transpile-only ./seed-insights.ts"
},
"devDependencies": {
"npm-run-all": "^4.1.5"
+2 -1
View File
@@ -203,7 +203,8 @@
"categories": ["video"],
"slug": "facetime",
"type": "facetime_video",
"isTemplate": false},
"isTemplate": false
},
{
"dirName": "zohocrm",
"categories": ["other"],
+1
View File
@@ -45,6 +45,7 @@ export const userSelect = Prisma.validator<Prisma.UserArgs>()({
theme: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
...availabilityUserSelect,
},
});
@@ -12,6 +12,7 @@ import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-st
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod";
import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
@@ -283,6 +284,7 @@ export const eventTypesRouter = router({
const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({
...eventType,
safeDescription: markdownToSafeHTML(eventType.description),
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
// @FIXME: cc @hariombalhara This is failing with production data
// metadata: EventTypeMetaDataSchema.parse(eventType.metadata),

Some files were not shown because too many files have changed in this diff Show More