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 1906 additions and 1235 deletions

No files matched your search

+6 -6
View File
@@ -12,13 +12,13 @@ Fixes # (issue)
## Type of change ## 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) - Bug fix (non-breaking change which fixes an issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements) - Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality) - New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update - This change requires a documentation update
## How should this be tested? ## 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: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-install
- name: Show info - name: Show info
run: node -e "console.log(require('v8').getHeapStatistics())" run: node -e "console.log(require('v8').getHeapStatistics())"
- name: Configure TSC problem matcher
- run: yarn type-check 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 name: Cron - downgradeUsers
on: on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch." # "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 # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule: schedule:
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru) # Runs “Every month at 1st (see https://crontab.guru)
- cron: "0,15,30,45 * * * *" - cron: "0 0 1 * *"
jobs: jobs:
cron-downgradeUsers: cron-downgradeUsers:
env: env:
-1
View File
@@ -352,7 +352,6 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
</picture> </picture>
</a> </a>
<!-- CONTRIBUTORS --> <!-- CONTRIBUTORS -->
### Contributors ### Contributors
+2 -1
View File
@@ -11,7 +11,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json", "lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
"start": "PORT=4000 next start", "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.", "author": "Cal.com, Inc.",
"license": "MIT", "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); const height = getDimension(previewState.inline.height);
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
@@ -187,12 +187,12 @@ function MyComponent() {
}) => { }) => {
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
const cal = await getCalApi(); const cal = await getCalApi();
Cal("floatingButton", ${floatingButtonArg}); cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode} ${uiInstructionCode}
})(); })();
}, []) }, [])
@@ -201,7 +201,7 @@ function MyComponent() {
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => { "element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() { function MyComponent() {
useEffect(()=>{ useEffect(()=>{
(async function () { (async function () {
@@ -8,54 +8,61 @@ import { FiLink } from "@calcom/ui/components/icon";
import type { Props } from "./pages/AvailabilityPage"; import type { Props } from "./pages/AvailabilityPage";
const excludeNullValues = (value: unknown) => !!value;
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) { export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale(); const { t } = useLocale();
return locations.length ? ( const renderLocations = locations.map((location, index) => {
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600"> const eventLocationType = getEventLocationType(location.type);
{locations.map((location, index) => { if (!eventLocationType) {
const eventLocationType = getEventLocationType(location.type); // It's possible that the location app got uninstalled
if (!eventLocationType) { return null;
// It's possible that the location app got uninstalled }
return null; if (eventLocationType.variable === "hostDefault") {
} return null;
}
const translateAbleKeys = [ const translateAbleKeys = [
"attendee_in_person", "attendee_in_person",
"in_person", "in_person",
"attendee_phone_number", "attendee_phone_number",
"link_meeting", "link_meeting",
"organizer_phone_number", "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:") return (
? eventLocationType.label <div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
: translateAbleKeys.includes(locationKey) {eventLocationType.iconUrl === "/link.svg" ? (
? t(locationKey) <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 " />
: locationKey; ) : (
<img
return ( src={eventLocationType.iconUrl}
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium"> className={classNames(
{eventLocationType.iconUrl === "/link.svg" ? ( "ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
<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 " /> !eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
) : (
<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`}
/>
)} )}
<Tooltip content={translatedLocation}> alt={`${eventLocationType.label} icon`}
<p className="line-clamp-1">{translatedLocation}</p> />
</Tooltip> )}
</div> <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> </div>
) : null; ) : null;
} }
@@ -33,6 +33,7 @@ interface ISetLocationDialog {
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void; saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
selection?: LocationOption; selection?: LocationOption;
booking?: BookingItem; booking?: BookingItem;
isTeamEvent?: boolean;
defaultValues?: LocationObject[]; defaultValues?: LocationObject[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>; setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean; isOpenDialog: boolean;
@@ -74,6 +75,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
saveLocation, saveLocation,
selection, selection,
booking, booking,
isTeamEvent,
setShowLocationModal, setShowLocationModal,
isOpenDialog, isOpenDialog,
defaultValues, defaultValues,
@@ -290,7 +292,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
query={locationsQuery} query={locationsQuery}
success={({ data }) => { success={({ data }) => {
if (!data.length) return null; if (!data.length) return null;
const locationOptions = [...data]; const locationOptions = [...data].filter((option) => {
return !isTeamEvent ? option.label !== "Conferencing" : true;
});
if (booking) { if (booking) {
locationOptions.map((location) => locationOptions.map((location) =>
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value)) 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 = ( export const EventSetupTab = (
props: Pick< props: Pick<
EventTypeSetupProps, EventTypeSetupProps,
@@ -53,12 +43,16 @@ export const EventSetupTab = (
) => { ) => {
const { t } = useLocale(); const { t } = useLocale();
const formMethods = useFormContext<FormValues>(); const formMethods = useFormContext<FormValues>();
const { eventType, locationOptions, team, destinationCalendar } = props; const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false); const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>(""); const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined); const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration); 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) => ({ const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
value: mins, value: mins,
label: t("multiple_duration_mins", { count: 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 */} {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog <EditLocationDialog
isTeamEvent={!!team}
isOpenDialog={showLocationModal} isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal} setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation} saveLocation={saveLocation}
@@ -1,11 +1,11 @@
export type EventTypeDescriptionSafeProps = { export type EventTypeDescriptionSafeProps = {
eventType: { description: string | null }; eventType: { description: string | null; descriptionAsSafeHTML: string | null };
}; };
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => { export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true }; const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts if (eventType.description)
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML }; props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
return <div {...props} />; return <div {...props} />;
}; };
+6 -5
View File
@@ -2,13 +2,14 @@ import Link from "next/link";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams"; import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui"; import { Avatar } from "@calcom/ui";
type TeamType = NonNullable<TeamWithMembers>; type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"]; 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 Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale(); const { t } = useLocale();
@@ -30,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<> <>
<div <div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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) { if (!members || members.length === 0) {
return null; 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 ( return (
<div> <div>
<Members members={team.members} teamName={team.name} /> <Members members={team.members} teamName={team.name} />
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "2.7.5", "version": "2.7.6",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -11,6 +11,7 @@
"dx": "yarn dev", "dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000", "test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint . --ignore-path .gitignore", "lint": "eslint . --ignore-path .gitignore",
@@ -119,6 +120,7 @@
"react-window": "^1.8.7", "react-window": "^1.8.7",
"remark": "^14.0.2", "remark": "^14.0.2",
"rrule": "^2.7.1", "rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0", "schema-dts": "^1.1.0",
"short-uuid": "^4.2.0", "short-uuid": "^4.2.0",
"strip-markdown": "^5.0.0", "strip-markdown": "^5.0.0",
@@ -156,6 +158,7 @@
"@types/react-phone-number-input": "^3.0.14", "@types/react-phone-number-input": "^3.0.14",
"@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
"@types/sanitize-html": "^2.9.0",
"@types/stripe": "^8.0.417", "@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1", "@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12", "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 { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui"; 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"; import { ssgInit } from "@server/lib/ssg";
@@ -40,6 +40,43 @@ export default function Custom404() {
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage; const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
const isSignup = router.asPath.startsWith("/signup"); const isSignup = router.asPath.startsWith("/signup");
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com"; 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 ( return (
<> <>
+6 -2
View File
@@ -24,7 +24,7 @@ import defaultEvents, {
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme"; 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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects"; import { baseEventTypeSelect } from "@calcom/prisma/selects";
@@ -147,7 +147,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<> <>
<div <div
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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) => ({ const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType, ...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
})); }));
const isSingleUser = users.length === 1; const isSingleUser = users.length === 1;
@@ -352,9 +353,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}) })
: []; : [];
const safeBio = markdownToSafeHTML(user.bio) || "";
return { return {
props: { props: {
users, users,
safeBio,
profile, profile,
user: { user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), 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 { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale"; 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 type { User } from "@calcom/prisma/client";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -59,7 +59,6 @@ Type.isThemeSupported = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() }); const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) { async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies // 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 prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations"); const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent"); 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 }; if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & { const [eventType]: ((typeof user.eventTypes)[number] & {
@@ -153,7 +149,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent), recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations), 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 // Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam = const hasActiveTeam =
+2
View File
@@ -14,6 +14,7 @@ import {
getUsernameList, getUsernameList,
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma"; import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import { import {
customInputSchema, customInputSchema,
@@ -189,6 +190,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: u.username, slug: u.username,
theme: u.theme, theme: u.theme,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
+37 -4
View File
@@ -1,4 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey; const apiKey = req.headers.authorization || req.query.apiKey;
@@ -11,10 +19,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return; return;
} }
/** const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
* TODO: const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
* Remove this endpoint 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 }); 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 { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability"; import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma"; import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -119,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: u.hideBranding, hideBranding: u.hideBranding,
timeZone: u.timeZone, timeZone: u.timeZone,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
}); });
const [user] = users; const [user] = users;
+2
View File
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects"; import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -93,6 +94,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
brandColor: u.brandColor, brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor, darkBrandColor: u.darkBrandColor,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
@@ -65,6 +65,7 @@ export type FormValues = {
hostPhoneNumber?: string; hostPhoneNumber?: string;
displayLocationPublicly?: boolean; displayLocationPublicly?: boolean;
phone?: string; phone?: string;
hostDefault?: string;
}[]; }[];
customInputs: CustomInputParsed[]; customInputs: CustomInputParsed[];
schedule: number | null; schedule: number | null;
+1 -1
View File
@@ -141,7 +141,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
</div> </div>
<EventTypeDescription <EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon // @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type} eventType={{ ...type, descriptionAsSafeHTML: type.safeDescription }}
shortenDescription shortenDescription
/> />
</Link> </Link>
+20 -3
View File
@@ -1,3 +1,4 @@
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { import {
AverageEventDurationChart, AverageEventDurationChart,
BookingKPICards, BookingKPICards,
@@ -39,17 +40,17 @@ export default function InsightsPage() {
const { data: user } = trpc.viewer.me.useQuery(); const { data: user } = trpc.viewer.me.useQuery();
const features = [ const features = [
{ {
icon: <FiUsers className="h-5 w-5 text-red-500" />, icon: <FiUsers className="h-5 w-5" />,
title: t("view_bookings_across"), title: t("view_bookings_across"),
description: t("view_bookings_across_description"), 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"), title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"), 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"), title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"), description: t("spot_popular_event_types_description"),
}, },
@@ -114,3 +115,19 @@ export default function InsightsPage() {
</div> </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 { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme"; 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 { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -113,7 +113,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<> <>
<div <div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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, ...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png", 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 { return {
props: { props: {
team, team: { ...team, safeBio, members },
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
}, },
} as const; } as const;
+2 -1
View File
@@ -4,6 +4,7 @@ import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location"; import { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability"; import { getWorkingHours } from "@calcom/lib/availability";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -63,7 +64,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: { select: {
id: true, id: true,
name: true, name: true,
avatar: true,
username: true, username: true,
timeZone: true, timeZone: true,
hideBranding: true, hideBranding: true,
@@ -170,6 +170,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding, hideBranding,
timeZone, timeZone,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}); });
eventTypeObject.availability = []; 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 { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib"; import { parseRecurringEvent } from "@calcom/lib";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -121,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: u.avatar, image: u.avatar,
slug: u.username, slug: u.username,
})), })),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}; };
})[0]; })[0];
+1 -1
View File
@@ -45,7 +45,7 @@ export const createEmbedsFixture = (page: Page) => {
} }
api("on", { api("on", {
action: "*", action: "*",
callback: (e: any) => { callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @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 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": "Impersonation",
"impersonation_description": "Settings to manage user impersonation", "impersonation_description": "Settings to manage user impersonation",
"users": "Users", "users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile", "profile_description": "Manage settings for your {{appName}} profile",
"users_description": "Here you can find a list of all users", "users_description": "Here you can find a list of all users",
"users_listing": "User listing", "users_listing": "User listing",
@@ -1608,6 +1609,7 @@
"default_app_link_title": "Set a default app link", "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.", "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", "change_default_conferencing_app": "Set as default",
"organizer_default_conferencing_app": "Organizer's default app",
"under_maintenance": "Down for maintenance", "under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.", "under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats", "event_type_seats": "{{numberOfSeats}} seats",
@@ -1686,7 +1688,9 @@
"events_rescheduled": "Events Rescheduled", "events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period", "from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}", "from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"analytics_for_organisation": "Analytics for {{organisationName}}", "analytics_for_organisation": "Insights for {{organisationName}}",
"subtitle_analytics": "This is a organisation analytics", "subtitle_analytics": "Learn more about your team's activity",
"event_trends": "Event Trends" "event_trends": "Event Trends",
"clear_filters": "Clear Filters",
"insights": "Insights"
} }
@@ -1213,6 +1213,7 @@
"impersonation": "Suplantación", "impersonation": "Suplantación",
"impersonation_description": "Configuración para administrar la suplantación del usuario", "impersonation_description": "Configuración para administrar la suplantación del usuario",
"users": "Usuarios", "users": "Usuarios",
"user": "Usuario",
"profile_description": "Administra los ajustes para tu perfil de {{appName}}", "profile_description": "Administra los ajustes para tu perfil de {{appName}}",
"users_description": "Aquí puede encontrar una lista de todos los usuarios", "users_description": "Aquí puede encontrar una lista de todos los usuarios",
"users_listing": "Lista de usuarios", "users_listing": "Lista de usuarios",
+2
View File
@@ -497,3 +497,5 @@ hr {
::-webkit-search-cancel-button { ::-webkit-search-cancel-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
+3 -1
View File
@@ -70,6 +70,7 @@
"test-playwright": "yarn playwright test --config=playwright.config.ts", "test-playwright": "yarn playwright test --config=playwright.config.ts",
"test": "jest", "test": "jest",
"type-check": "turbo run type-check", "type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web" "web": "yarn workspace @calcom/web"
}, },
"devDependencies": { "devDependencies": {
@@ -86,8 +87,9 @@
"jest-watch-select-projects": "^2.0.0", "jest-watch-select-projects": "^2.0.0",
"jest-watch-typeahead": "^2.0.0", "jest-watch-typeahead": "^2.0.0",
"lint-staged": "^12.5.0", "lint-staged": "^12.5.0",
"prettier": "^2.8.4", "prettier": "^2.8.6",
"ts-jest": "^28.0.8", "ts-jest": "^28.0.8",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"dependencies": { "dependencies": {
+3 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs"; import fs from "fs";
import path from "path"; 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 { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync"; import execSync from "./utils/execSync";
@@ -127,6 +127,8 @@ export const BaseAppFork = {
.replace(/_DESCRIPTION_/g, description) .replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug) .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 }) { delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
@@ -3,7 +3,6 @@
Don't modify this file manually. Don't modify this file manually.
**/ **/
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
export const InstallAppButtonMap = { export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")), applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/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 wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod"; import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = { export const appKeysSchemas = {
dailyvideo: dailyvideo_zod_ts, dailyvideo: dailyvideo_zod_ts,
fathom: fathom_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 { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zohocrm_config_json from "./zohocrm/config.json"; import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = { export const appStoreMetadata = {
amie: amie_config_json, amie: amie_config_json,
applecalendar: applecalendar__metadata_ts, 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 wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod"; import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = { export const appDataSchemas = {
dailyvideo: dailyvideo_zod_ts, dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts, fathom: fathom_zod_ts,
+1
View File
@@ -10,6 +10,7 @@ items:
{DESCRIPTION} {DESCRIPTION}
### Cron's most valuable features ### Cron's most valuable features
<!-- From "FAQs: What are Cron’s most valuable features?" <!-- From "FAQs: What are Cron’s most valuable features?"
https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 --> https://cronhq.notion.site/Cron-Calendar-5625be54feac4e13a75b10271b65ddb7 -->
+25 -4
View File
@@ -12,13 +12,20 @@ export type DefaultEventLocationType = {
type: DefaultEventLocationTypeEnum; type: DefaultEventLocationTypeEnum;
label: string; label: string;
messageForOrganizer: string; messageForOrganizer: string;
category: "in person" | "other" | "phone"; category: "in person" | "conferencing" | "other" | "phone";
iconUrl: string; iconUrl: string;
urlRegExp?: string; urlRegExp?: string;
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places. // 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"; variable:
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone"; | "locationType"
| "locationAddress"
| "address"
| "locationLink"
| "locationPhoneNumber"
| "phone"
| "hostDefault";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone";
} & ( } & (
| { | {
organizerInputType: "phone" | "text" | null; organizerInputType: "phone" | "text" | null;
@@ -60,6 +67,7 @@ export enum DefaultEventLocationTypeEnum {
*/ */
UserPhone = "userPhone", UserPhone = "userPhone",
Link = "link", Link = "link",
Conferencing = "conferencing",
} }
export const defaultLocations: DefaultEventLocationType[] = [ export const defaultLocations: DefaultEventLocationType[] = [
@@ -88,6 +96,17 @@ export const defaultLocations: DefaultEventLocationType[] = [
iconUrl: "/map-pin.svg", iconUrl: "/map-pin.svg",
category: "in person", 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, default: true,
type: DefaultEventLocationTypeEnum.Link, type: DefaultEventLocationTypeEnum.Link,
@@ -130,7 +149,9 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = { export type LocationObject = {
type: string; type: string;
displayLocationPublicly?: boolean; 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 // integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
export type BookingLocationValue = string; export type BookingLocationValue = string;
+1
View File
@@ -8,6 +8,7 @@
], ],
"scripts": { "scripts": {
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx", "lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json" "lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
Whitespace-only changes.
-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/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0", "@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4", "@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/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0", "@typescript-eslint/parser": "^5.52.0",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^2.8.4", "prettier": "^2.8.6",
"prettier-plugin-tailwindcss": "^0.1.13", "prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1", "tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.1", "tailwindcss": "^3.2.1",
"typescript": "^4.9.4" "typescript": "^4.9.4"
+8 -1
View File
@@ -16,7 +16,14 @@ module.exports = {
"^[./]", "^[./]",
], ],
importOrderSeparation: true, 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: [ overrides: [
{ {
files: ["apps/website/lib/utils/wordlist/wordlist.ts"], 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 }} />; const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: { export const Info = (props: {
@@ -6,8 +8,14 @@ export const Info = (props: {
extraInfo?: React.ReactNode; extraInfo?: React.ReactNode;
withSpacer?: boolean; withSpacer?: boolean;
lineThrough?: boolean; lineThrough?: boolean;
formatted?: boolean;
}) => { }) => {
if (!props.description || props.description === "") return null; 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 ( return (
<> <>
{props.withSpacer && <Spacer />} {props.withSpacer && <Spacer />}
@@ -21,7 +29,18 @@ export const Info = (props: {
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined, 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> </p>
{props.extraInfo} {props.extraInfo}
</div> </div>
@@ -76,7 +76,7 @@ export const BaseScheduledEmail = (
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} /> <WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhoInfo calEvent={props.calEvent} t={t} /> <WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo 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 /> <Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />} {props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses calEvent={props.calEvent} /> <UserFieldsResponses calEvent={props.calEvent} />
+15 -316
View File
@@ -4,6 +4,16 @@
<!-- <link rel="prerender" href="http://localhost:3000/free"> --> <!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> --> <!-- <script src="./src/embed.ts" type="module"></script> -->
<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")) { if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>'); document.write('<meta name="viewport" content="width=device-width"/>');
} }
@@ -90,6 +100,7 @@
</div> </div>
<span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span> <span style="display: block;" ><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span>
<div> <div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2> <h2>Popup Examples</h2>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button> <button data-cal-namespace="popupAutoTheme" data-cal-link="free">Book with Free User[Auto Theme]</button>
@@ -140,12 +151,12 @@
Align Center Align Center
</button> </button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: { <button onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
backgroundColor: '#D3D3D3', backgroundColor: generateRandomHexColor(),
}, },
disabledDateButton: { disabledDateButton: {
backgroundColor: 'lightslategray', backgroundColor: generateRandomHexColor(),
},}})})()"> },}})})()">
Change Date Button Color Change 'enabledDateButton` and `disabledDateButton` background Color
</button> </button>
</div> </div>
@@ -189,318 +200,6 @@
<div class="place"> <div class="place">
</div> </div>
</div> </div>
<script src="./playground.ts"></script>
<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>
</body> </body>
</html> </html>
+1
View File
@@ -23,6 +23,7 @@
"dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'", "dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'",
"dev-real": "vite dev --port 3100", "dev-real": "vite dev --port 3100",
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src", "lint": "eslint --ext .ts,.js src",
"lint:fix": "eslint --ext .ts,.js src --fix", "lint:fix": "eslint --ext .ts,.js src --fix",
"embed-tests": "yarn playwright test --config=playwright/config/playwright.config.ts", "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"; 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 { export class FloatingButton extends HTMLElement {
static updatedClassString(position: string, classString: string) { static updatedClassString(position: string, classString: string) {
return [ return [
@@ -8,66 +39,69 @@ export class FloatingButton extends HTMLElement {
].join(" "); ].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 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
static get observedAttributes() { static get observedAttributes() {
return [ return dataAttributes;
"data-button-text",
"data-hide-button-icon",
"data-button-position",
"data-button-color",
"data-button-text-color",
];
} }
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") { if (name === "data-button-text") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("#button");
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.textContent = newValue; buttonEl.textContent = newValue;
} else if (name === "data-hide-button-icon") { } 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"; buttonIconEl.style.display = newValue == "true" ? "none" : "block";
} else if (name === "data-button-position") { } else if (name === "data-button-position") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.className = FloatingButton.updatedClassString(newValue, buttonWrapperEl.className);
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.className = FloatingButton.updatedClassString(newValue, buttonEl.className);
} else if (name === "data-button-color") { } else if (name === "data-button-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.style.backgroundColor = newValue;
if (!buttonEl) {
throw new Error("Button not found");
}
buttonEl.style.backgroundColor = newValue;
} else if (name === "data-button-text-color") { } else if (name === "data-button-text-color") {
const buttonEl = this.shadowRoot?.querySelector<HTMLElement>("button"); buttonWrapperEl.style.color = newValue;
if (!buttonEl) { } else {
throw new Error("Button not found"); console.log("Unknown attribute changed", name, oldValue, newValue);
}
buttonEl.style.color = newValue;
} }
} }
assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
constructor() { constructor() {
super(); super();
const buttonText = this.dataset["buttonText"]; const dataset = this.dataset as FloatingButtonDataset;
const buttonPosition = this.dataset["buttonPosition"]; const buttonText = dataset["buttonText"];
const buttonColor = this.dataset["buttonColor"]; const buttonPosition = dataset["buttonPosition"];
const buttonTextColor = this.dataset["buttonTextColor"]; const buttonColor = dataset["buttonColor"];
const buttonTextColor = dataset["buttonTextColor"];
//TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place //TODO: Logic is duplicated over HTML generation and attribute change, keep it at one place
const buttonHtml = `<style>${window.Cal!.__css}</style> ${getFloatingButtonHtml({ const buttonHtml = `<style>${window.Cal.__css}</style> ${getFloatingButtonHtml({
buttonText: buttonText!, buttonText: buttonText,
buttonClasses: [FloatingButton.updatedClassString(buttonPosition!, "")], buttonClasses: [FloatingButton.updatedClassString(buttonPosition, "")],
buttonColor: buttonColor!, buttonColor: buttonColor,
buttonTextColor: buttonTextColor!, buttonTextColor: buttonTextColor,
})}`; })}`;
this.attachShadow({ mode: "open" }); 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 { getErrorString } from "../utils";
import inlineHtml from "./inlineHtml"; import inlineHtml from "./inlineHtml";
export class Inline extends HTMLElement { export class Inline extends HTMLElement {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
static get observedAttributes() { static get observedAttributes() {
return ["loading"]; 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) { 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 (name === "loading") {
if (newValue == "done") { if (newValue == "done") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none"; loaderEl.style.display = "none";
} else if (newValue === "failed") { } else if (newValue === "failed") {
this.shadowRoot!.querySelector<HTMLElement>(".loader")!.style.display = "none"; loaderEl.style.display = "none";
this.shadowRoot!.querySelector<HTMLElement>("#error")!.style.display = "block"; slotEl.style.visibility = "hidden";
this.shadowRoot!.querySelector<HTMLElement>("slot")!.style.visibility = "hidden"; errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode); const errorString = getErrorString(this.dataset.errorCode);
this.shadowRoot!.querySelector<HTMLElement>("#error")!.innerText = errorString; errorEl.innerText = errorString;
} }
} }
} }
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = `<style>${ this.assertHasShadowRoot();
window.Cal!.__css this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
}</style><style>${loaderCss}</style>${inlineHtml}`;
} }
} }
@@ -101,7 +101,7 @@ export class ModalBox extends HTMLElement {
constructor() { constructor() {
super(); 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" }); this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow; ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
+105 -83
View File
@@ -2,18 +2,40 @@ import { useRouter } from "next/router";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event"; import { sdkActionManager } from "./sdk-event";
export interface UiConfig { export type UiConfig = {
hideEventTypeDetails?: boolean; hideEventTypeDetails?: boolean;
theme?: "dark" | "light" | "auto"; 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 { declare global {
interface Window { interface Window {
CalEmbed: { CalEmbed: {
__logQueue?: any[]; __logQueue?: unknown[];
embedStore: any; embedStore: typeof embedStore;
}; };
CalComPageStatus: string; CalComPageStatus: string;
isEmbed: () => boolean; 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; let isSafariBrowser = false;
const isBrowser = typeof window !== "undefined"; 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) { if (isSafariBrowser) {
// https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/ // https://adpiler.com/blog/the-full-solution-why-do-animations-run-slower-in-safari/
return setTimeout(fn, 50); return setTimeout(fn, 50);
@@ -66,7 +66,7 @@ function runAsap(fn: (...arg: any) => void) {
return requestAnimationFrame(fn); return requestAnimationFrame(fn);
} }
function log(...args: any[]) { function log(...args: unknown[]) {
if (isBrowser) { if (isBrowser) {
const namespace = getNamespace(); const namespace = getNamespace();
@@ -95,7 +95,7 @@ interface EmbedStyles {
} }
interface EmbedNonStylesConfig { interface EmbedNonStylesConfig {
/** Default would be center */ /** Default would be center */
align: "left"; align?: "left";
branding?: { branding?: {
brandColor?: string; brandColor?: string;
lightColor?: string; lightColor?: string;
@@ -108,10 +108,10 @@ interface EmbedNonStylesConfig {
}; };
} }
const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => { const setEmbedStyles = (stylesConfig: EmbedStyles) => {
embedStore.styles = stylesConfig; embedStore.styles = stylesConfig;
for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) { for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
(setEmbedStyle as any)((styles: any) => { setEmbedStyle((styles) => {
return { return {
...styles, ...styles,
...stylesConfig, ...stylesConfig,
@@ -120,15 +120,48 @@ const setEmbedStyles = (stylesConfig: UiConfig["styles"]) => {
} }
}; };
const registerNewSetter = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig, setStyles: any) => { const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
embedStore.reactStylesStateSetters[elementName] = setStyles; embedStore.nonStyles = stylesConfig;
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe. for (const [, setEmbedStyle] of Object.entries(embedStore.reactStylesStateSetters)) {
// So, we should call the setter immediately with available embedStyles setEmbedStyle((styles) => {
setStyles(embedStore.styles); return {
...styles,
...stylesConfig,
};
});
}
}; };
const removeFromEmbedStylesSetterMap = (elementName: keyof EmbedStyles | keyof EmbedNonStylesConfig) => { const registerNewSetter = (
delete embedStore.reactStylesStateSetters[elementName]; 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) { 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 // 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) => { export const useEmbedStyles = (elementName: keyof EmbedStyles) => {
const [styles, setStyles] = useState({} as EmbedStyles); const [, setStyles] = useState<EmbedStyles>({});
useEffect(() => { useEffect(() => {
registerNewSetter(elementName, setStyles); return registerNewSetter({ elementName, setState: setStyles, styles: true });
// 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. // eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []); }, []);
const styles = embedStore.styles || {};
// Always read the data from global embedStore so that even across components, the same data is used.
return styles[elementName] || {}; return styles[elementName] || {};
}; };
export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => { export const useEmbedNonStylesConfig = (elementName: keyof EmbedNonStylesConfig) => {
const [styles, setStyles] = useState({} as EmbedNonStylesConfig); const [, setNonStyles] = useState({} as EmbedNonStylesConfig);
useEffect(() => { useEffect(() => {
registerNewSetter(elementName, setStyles); return registerNewSetter({ elementName, setState: setNonStyles, styles: false });
// 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. // eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
// Once the component is unmounted, we can remove that state setter.
removeFromEmbedStylesSetterMap(elementName);
};
}, []); }, []);
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 = () => { export const useIsBackgroundTransparent = () => {
@@ -251,8 +279,8 @@ function unhideBody() {
document.body.style.visibility = "visible"; 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 // It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
export const methods = { const methods = {
ui: function style(uiConfig: UiConfig) { ui: function style(uiConfig: UiConfig) {
// TODO: Create automatic logger for all methods. Useful for debugging. // TODO: Create automatic logger for all methods. Useful for debugging.
log("Method: ui called", uiConfig); log("Method: ui called", uiConfig);
@@ -277,8 +305,10 @@ export const methods = {
} }
setEmbedStyles(stylesConfig || {}); setEmbedStyles(stylesConfig || {});
setEmbedNonStyles(stylesConfig || {});
}, },
parentKnowsIframeReady: () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called"); log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() { runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight // 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( parent.postMessage(
{ {
originator: "CAL", originator: "CAL",
@@ -344,12 +381,14 @@ 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. // 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( const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) + parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) + parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)); parseFloat(mainElementStyles.marginBottom)
);
const contentWidth = Math.ceil( const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) + parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) + parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)); parseFloat(mainElementStyles.marginRight)
);
// During first render let iframe tell parent that how much is the expected height to avoid scroll. // 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. // Parent would set the same value as the height of iframe which would prevent scroll.
@@ -394,35 +433,18 @@ if (isBrowser) {
sdkActionManager?.on("*", (e) => { sdkActionManager?.on("*", (e) => {
const detail = e.detail; const detail = e.detail;
//console.log(detail.fullType, detail.type, detail.data);
log(detail); log(detail);
messageParent(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) => { window.addEventListener("message", (e) => {
const data: Record<string, any> = e.data; const data: Message = e.data;
if (!data) { if (!data) {
return; return;
} }
const method: keyof typeof methods = data.method; const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") { 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 { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline"; import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox"; 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 css from "./embed.css";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } 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. // 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. // This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL = const WEBAPP_URL =
@@ -18,10 +27,20 @@ customElements.define("cal-inline", Inline);
declare module "*.css"; declare module "*.css";
type Namespace = string; type Namespace = string;
type Config = { type Config = {
origin: string; calOrigin: string;
debug?: boolean; debug?: boolean;
uiDebug?: 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; const globalCal = window.Cal;
if (!globalCal || !globalCal.q) { if (!globalCal || !globalCal.q) {
@@ -35,9 +54,22 @@ globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as strin
globalCal.__css = allCss; globalCal.__css = allCss;
document.head.appendChild(document.createElement("style")).innerHTML = css; document.head.appendChild(document.createElement("style")).innerHTML = css;
function log(...args: any[]) { function log(...args: unknown[]) {
console.log(...args); 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 * //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. * A very simple data validator written with intention of keeping payload size low.
@@ -45,8 +77,8 @@ function log(...args: any[]) {
* @param data * @param data
* @param schema * @param schema
*/ */
function validate(data: any, schema: Record<"props" | "required", any>) { function validate(data: Record<string, unknown>, schema: ValidationSchema) {
function checkType(value: any, expectedType: any) { function checkType(value: unknown, expectedType: ValidationSchemaPropType) {
if (typeof expectedType === "string") { if (typeof expectedType === "string") {
return typeof value == expectedType; return typeof value == expectedType;
} else { } 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"; return typeof data === "undefined";
} }
@@ -62,7 +94,7 @@ function validate(data: any, schema: Record<"props" | "required", any>) {
throw new Error("Argument is required"); 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])) { if (propSchema.required && isUndefined(data[prop])) {
throw new Error(`"${prop}" is required`); 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[]; export type InstructionQueue = Instruction[];
type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
iframeAttrs?: Record<string, string> & {
id?: string;
};
};
export class Cal { export class Cal {
iframe?: HTMLIFrameElement; iframe?: HTMLIFrameElement;
@@ -100,35 +146,42 @@ export class Cal {
iframeReady!: boolean; iframeReady!: boolean;
iframeDoQueue: { method: keyof typeof methods; arg: any }[] = []; iframeDoQueue: DoInIframeArg[] = [];
api: CalApi;
static actionsManagers: Record<Namespace, SdkActionManager>; static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: Record<string, string>) { static getQueryObject(config: PrefillAndIframeAttrsConfig) {
config = config || {}; config = config || {};
return { return {
...config, ...config,
// guests is better for API but Booking Page accepts guest. So do the mapping // guests is better for API but Booking Page accepts guest. So do the mapping
guest: config.guests ?? undefined, guest: config.guests ?? undefined,
}; } as PrefillAndIframeAttrsConfig & { guest?: string | string[] };
} }
processInstruction(instruction: Instruction) { processInstruction(instructionAsArgs: IArguments | Instruction) {
instruction = [].slice.call(instruction, 0); // The instruction is actually an array-like object(arguments). Make it an array.
if (instruction[0] instanceof 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 // It is an instruction
instruction.forEach((instruction) => { instruction.forEach((instruction) => {
this.processInstruction(instruction); this.processInstruction(instruction);
}); });
return; return;
} }
const [method, ...args] = instruction; const [method, ...args] = instruction;
if (!this[method]) { if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue // Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`); log(`Instruction ${method} not FOUND`);
} }
try { 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) { } catch (e) {
// Instead of throwing error, log and move forward in the queue // Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e); log(`Instruction couldn't be executed`, e);
@@ -136,7 +189,7 @@ export class Cal {
return instruction; return instruction;
} }
processQueue(queue: InstructionQueue) { processQueue(queue: IArguments[]) {
queue.forEach((instruction) => { queue.forEach((instruction) => {
this.processInstruction(instruction); this.processInstruction(instruction);
}); });
@@ -153,9 +206,11 @@ export class Cal {
createIframe({ createIframe({
calLink, calLink,
queryObject = {}, queryObject = {},
calOrigin,
}: { }: {
calLink: string; calLink: string;
queryObject?: Record<string, string | string[] | Record<string, string>>; queryObject?: PrefillAndIframeAttrsConfig & { guest?: string | string[] };
calOrigin?: string;
}) { }) {
const iframe = (this.iframe = document.createElement("iframe")); const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed"; iframe.className = "cal-embed";
@@ -163,7 +218,7 @@ export class Cal {
const config = this.getConfig(); const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject; const { iframeAttrs, ...restQueryObject } = queryObject;
if (iframeAttrs && typeof iframeAttrs !== "string" && !(iframeAttrs instanceof Array)) { if (iframeAttrs && iframeAttrs.id) {
iframe.setAttribute("id", 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")) { 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" // 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`; urlInstance.pathname = `${urlInstance.pathname}/embed`;
@@ -203,252 +258,34 @@ export class Cal {
return iframe; 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() { getConfig() {
return this.__config; return this.__config;
} }
// TODO: Maintain exposed methods in a separate namespace, so that unexpected methods don't become instructions doInIframe(doInIframeArg: DoInIframeArg) {
/**
* 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 }) {
if (!this.iframeReady) { if (!this.iframeReady) {
this.iframeDoQueue.push({ method, arg }); this.iframeDoQueue.push(doInIframeArg);
return; 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. if (!this.iframe) {
this.iframe!.contentWindow!.postMessage({ originator: "CAL", method, arg }, "*"); 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 = { this.__config = {
// Use WEBAPP_URL till full page reload problem with website URL is solved // 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.namespace = namespace;
this.actionManager = new SdkActionManager(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 // 3. Parent window would check what iframe height can be set according to parent Element
this.actionManager.on("__dimensionChanged", (e) => { this.actionManager.on("__dimensionChanged", (e) => {
const { data } = e.detail; const { data } = e.detail;
const iframe = this.iframe!; const iframe = this.iframe;
if (!iframe) { if (!iframe) {
// Iframe might be pre-rendering // Iframe might be pre-rendering
return; return;
} }
let unit = "px"; const unit = "px";
if (data.__unit) {
unit = data.__unit;
}
if (data.iframeHeight) { if (data.iframeHeight) {
iframe.style.height = data.iframeHeight + unit; iframe.style.height = data.iframeHeight + unit;
} }
// if (data.iframeWidth) {
// iframe.style.width = data.iframeWidth + unit;
// }
if (this.modalBox) { 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 // 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. // This case is reproducible when viewing in ModalBox on Mobile.
@@ -490,9 +320,9 @@ export class Cal {
this.actionManager.on("__iframeReady", () => { this.actionManager.on("__iframeReady", () => {
this.iframeReady = true; this.iframeReady = true;
this.doInIframe({ method: "parentKnowsIframeReady", arg: undefined }); this.doInIframe({ method: "parentKnowsIframeReady" } as const);
this.iframeDoQueue.forEach(({ method, arg }) => { this.iframeDoQueue.forEach((doInIframeArg) => {
this.doInIframe({ method, arg }); this.doInIframe(doInIframeArg);
}); });
}); });
@@ -523,34 +353,276 @@ export class Cal {
} }
} }
export interface GlobalCal { class CalApi {
(methodName: string, arg?: any): void; 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. */ /** Marks that the embed.js is loaded. Avoids re-downloading it. */
loaded?: boolean; loaded?: boolean;
/** Maintains a queue till the time embed.js isn't loaded */ /** Maintains a queue till the time embed.js isn't loaded */
q?: InstructionQueue; q: IArguments[];
/** If user registers multiple namespaces, those are available here */ /** If user registers multiple namespaces, those are available here */
ns?: Record<string, GlobalCal>;
instance?: Cal; instance?: Cal;
__css?: string; __css?: string;
fingerprint?: 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 { declare global {
interface Window { interface Window {
Cal?: GlobalCal; Cal: GlobalCal;
} }
} }
export interface CalWindow extends Window { export interface CalWindow extends Window {
Cal?: GlobalCal; Cal: GlobalCal;
} }
globalCal.instance = new Cal("", globalCal.q!); globalCal.instance = new Cal("", globalCal.q);
for (const [ns, api] of Object.entries(globalCal.ns)) {
for (const [ns, api] of Object.entries(globalCal.ns!)) { api.instance = new Cal(ns, api.q);
api.instance = new Cal(ns, api.q!);
} }
/** /**
@@ -571,38 +643,45 @@ window.addEventListener("message", (e) => {
if (!actionManager) { if (!actionManager) {
throw new Error("Unhandled Action" + parsedAction); throw new Error("Unhandled Action" + parsedAction);
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actionManager.fire(parsedAction.type, detail.data); actionManager.fire(parsedAction.type, detail.data);
}); });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const htmlElement = e.target; const targetEl = e.target;
if (!(htmlElement instanceof HTMLElement)) { if (!(targetEl instanceof HTMLElement)) {
return; return;
} }
const path = htmlElement.dataset.calLink; const path = targetEl.dataset.calLink;
if (!path) { if (!path) {
return; return;
} }
const modalUniqueId = ((htmlElement as unknown as any).uniqueId = const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
(htmlElement as unknown as any).uniqueId || Date.now()); const namespace = targetEl.dataset.calNamespace;
const namespace = htmlElement.dataset.calNamespace; const configString = targetEl.dataset.calConfig || "";
const configString = htmlElement.dataset.calConfig || ""; const calOrigin = targetEl.dataset.calOrigin || "";
let config; let config;
try { try {
config = JSON.parse(configString); config = JSON.parse(configString);
} catch (e) { } catch (e) {
config = {}; config = {};
} }
let api = globalCal;
let api: GlobalCalWithoutNs = globalCal;
if (namespace) { if (namespace) {
api = globalCal.ns![namespace]; api = globalCal.ns[namespace];
} }
if (!api) { if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`); throw new Error(`Namespace ${namespace} isn't defined`);
} }
api("modal", { api("modal", {
calLink: path, calLink: path,
config, config,
uid: modalUniqueId, 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}`; 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`; 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 // Install Cal Embed Code Snippet
(function (C, A, L) { (function (C, A, L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const p = function (a: any, ar: any) { const p = function (a: any, ar: any) {
a.q.push(ar); 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 =
C.Cal || C.Cal ||
function () { function () {
const cal = C.Cal!; const cal = C.Cal;
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
const ar = arguments; const ar = arguments;
if (!cal.loaded) { if (!cal.loaded) {
@@ -23,13 +22,16 @@ const EMBED_LIB_URL = import.meta.env.EMBED_PUBLIC_EMBED_LIB_URL || `${WEBAPP_UR
cal.loaded = true; cal.loaded = true;
} }
if (ar[0] === L) { if (ar[0] === L) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api: { (): void; q?: any[] } = function () { const api: { (): void; q?: any[] } = function () {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
p(api, arguments); p(api, arguments);
}; };
const namespace = ar[1]; const namespace = ar[1];
api!.q = api.q || []; api.q = api.q || [];
typeof namespace === "string" ? (cal!.ns![namespace] = api) && p(api, ar) : p(cal, ar); // 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; return;
} }
p(cal, ar); 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"); })(window, EMBED_LIB_URL, "init");
const previewWindow = window; 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, origin: WEBAPP_URL,
}); });
const searchParams = new URL(document.URL).searchParams; const searchParams = new URL(document.URL).searchParams;
const embedType = searchParams.get("embedType"); const embedType = searchParams.get("embedType");
const calLink = searchParams.get("calLink"); const calLink = searchParams.get("calLink");
if (embedType! === "inline") { if (!calLink) {
previewWindow.Cal!("inline", { throw new Error('Missing "calLink" query parameter');
}
if (embedType === "inline") {
previewWindow.Cal("inline", {
elementOrSelector: "#my-embed", elementOrSelector: "#my-embed",
calLink: calLink, calLink: calLink,
}); });
} else if (embedType === "floating-popup") { } else if (embedType === "floating-popup") {
previewWindow.Cal!("floatingButton", { previewWindow.Cal("floatingButton", {
calLink: calLink, calLink: calLink,
attributes: { attributes: {
id: "my-floating-button", id: "my-floating-button",
@@ -58,7 +64,7 @@ if (embedType! === "inline") {
}); });
} else if (embedType === "element-click") { } else if (embedType === "element-click") {
const button = document.createElement("button"); 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"; button.innerHTML = "I am a button that exists on your website";
document.body.appendChild(button); document.body.appendChild(button);
} }
@@ -1,5 +1,5 @@
type Namespace = string; type Namespace = string;
type CustomEventDetail = Record<string, any>; type CustomEventDetail = Record<string, unknown>;
function _fireEvent(fullName: string, detail: CustomEventDetail) { function _fireEvent(fullName: string, detail: CustomEventDetail) {
const event = new window.CustomEvent(fullName, { const event = new window.CustomEvent(fullName, {
@@ -9,6 +9,53 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event); 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 { export class SdkActionManager {
namespace: Namespace; namespace: Namespace;
@@ -31,7 +78,7 @@ export class SdkActionManager {
return this.namespace ? `CAL:${this.namespace}:${name}` : `CAL::${name}`; 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 fullName = this.getFullActionName(name);
const detail = { const detail = {
type: name, type: name,
@@ -46,12 +93,12 @@ export class SdkActionManager {
_fireEvent(this.getFullActionName("*"), detail); _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); const fullName = this.getFullActionName(name);
window.addEventListener(fullName, callback as EventListener); 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); const fullName = this.getFullActionName(name);
window.removeEventListener(fullName, callback as EventListener); window.removeEventListener(fullName, callback as EventListener);
} }
+1
View File
@@ -1,6 +1,7 @@
{ {
"extends": "@calcom/tsconfig/base.json", "extends": "@calcom/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2015",
"module": "esnext", "module": "esnext",
"paths": { "paths": {
"@lib/*": ["../../../apps/web/lib/*"] "@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> <head>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script> <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 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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -1,3 +1,4 @@
import * as React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useState } from "react"; import { useState } from "react";
import ReactDom from "react-dom"; import ReactDom from "react-dom";
@@ -47,7 +48,6 @@ function App() {
theme: "dark", theme: "dark",
}} }}
/> />
<button data-cal-link="pro">Popup</button>
</> </>
); );
} }
+1
View File
@@ -15,6 +15,7 @@
"preview": "vite preview", "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", "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": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./src", "lint": "eslint --ext .ts,.js,.tsx,.jsx ./src",
"embed-tests": "yarn playwright test --config=./playwright/config/playwright.config.ts", "embed-tests": "yarn playwright test --config=./playwright/config/playwright.config.ts",
"embed-tests-quick": "QUICK=true yarn embed-tests", "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 { useEffect, useRef } from "react";
import useEmbed from "./useEmbed"; import useEmbed from "./useEmbed";
@@ -10,7 +11,7 @@ type CalProps = {
debug?: boolean; debug?: boolean;
uiDebug?: boolean; uiDebug?: boolean;
}; };
config?: any; config?: Record<string, string>;
embedJsUrl?: string; embedJsUrl?: string;
} & React.HTMLAttributes<HTMLDivElement>; } & React.HTMLAttributes<HTMLDivElement>;
@@ -23,7 +24,7 @@ const Cal = function Cal(props: CalProps) {
const Cal = useEmbed(embedJsUrl); const Cal = useEmbed(embedJsUrl);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!Cal || initializedRef.current) { if (!Cal || initializedRef.current || !ref.current) {
return; return;
} }
initializedRef.current = true; initializedRef.current = true;
+4 -2
View File
@@ -1,11 +1,13 @@
"use client";
import type { GlobalCal } from "@calcom/embed-core"; import type { GlobalCal } from "@calcom/embed-core";
import EmbedSnippet from "@calcom/embed-snippet"; import EmbedSnippet from "@calcom/embed-snippet";
import Cal from "./Cal"; import Cal from "./Cal";
export const getCalApi = (): Promise<GlobalCal> => export const getCalApi = (embedJsUrl?: string): Promise<GlobalCal> =>
new Promise(function tryReadingFromWindow(resolve) { new Promise(function tryReadingFromWindow(resolve) {
EmbedSnippet(); EmbedSnippet(embedJsUrl);
const api = window.Cal; const api = window.Cal;
if (!api) { if (!api) {
setTimeout(() => { setTimeout(() => {
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import EmbedSnippet from "@calcom/embed-snippet"; import EmbedSnippet from "@calcom/embed-snippet";
@@ -2,6 +2,7 @@
"extends": "@calcom/tsconfig/base.json", "extends": "@calcom/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"target": "ES2015",
"moduleResolution": "Node", "moduleResolution": "Node",
"baseUrl": ".", "baseUrl": ".",
"declaration": true, "declaration": true,
@@ -14,6 +14,7 @@
"scripts": { "scripts": {
"build": "vite build && tsc --emitDeclarationOnly --declarationDir dist", "build": "vite build && tsc --emitDeclarationOnly --declarationDir dist",
"type-check": "tsc --pretty --noEmit", "type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"lint": "eslint --ext .ts,.js src", "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", "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", "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 */ /* 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. * 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. * So, write the code that you need directly but keep it short.
*/ */
import type { CalWindow } from "@calcom/embed-core";
const WEBAPP_URL = const WEBAPP_URL =
import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_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`; 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) { export default function EmbedSnippet(url = EMBED_LIB_URL) {
(function (C: CalWindow, A, L) { (function (C, A, L) {
let p = function (a: any, ar: any) { let p = function (a: GlobalCalWithoutNs, ar: IArguments) {
a.q.push(ar); a.q.push(ar);
}; };
let d = C.document; let d = C.document;
C.Cal = C.Cal =
C.Cal || C.Cal ||
function () { function () {
let cal = C.Cal!; let cal = C.Cal;
let ar = arguments; let ar = arguments;
if (!cal.loaded) { 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.ns = {};
cal.q = cal.q || []; cal.q = cal.q || [];
//@ts-ignore //@ts-ignore
d.head.appendChild(d.createElement("script")).src = A; d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true; cal.loaded = true;
} }
if (ar[0] === L) { if (ar[0] === L) {
const api: { (): void; q: any[] } = function () { const api: GlobalCalWithoutNs = function () {
p(api, arguments); p(api, arguments);
}; };
const namespace = ar[1]; const namespace = ar[1];
api.q = api.q || []; 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; 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 //! Replace it with "https://cal.com/embed.js" or the URL where you have embed.js installed
url, url,
"init" "init"
@@ -1,6 +1,7 @@
{ {
"extends": "@calcom/tsconfig/base.json", "extends": "@calcom/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2015",
"baseUrl": ".", "baseUrl": ".",
"module": "esnext", "module": "esnext",
"declaration": true "declaration": true
@@ -622,6 +622,7 @@ async function handler(
let locationBodyString = location; let locationBodyString = location;
let defaultLocationUrl = undefined; let defaultLocationUrl = undefined;
if (dynamicUserList.length > 1) { if (dynamicUserList.length > 1) {
users = users.sort((a, b) => { users = users.sort((a, b) => {
const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0; const aIndex = (a.username && dynamicUserList.indexOf(a.username)) || 0;
@@ -701,6 +702,18 @@ async function handler(
const [organizerUser] = users; const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); 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 = [ const invitee = [
{ {
@@ -96,11 +96,7 @@ export default function TeamList(props: Props) {
title={t("collective_or_roundrobin")} title={t("collective_or_roundrobin")}
description={t("book_your_team_members")} description={t("book_your_team_members")}
actionButton={{ actionButton={{
href: href: "/event-types?dialog=new&eventPage=team%2F" + team.slug + "&teamId=" + team.id,
"/event-types?dialog=new-eventtype&eventPage=team%2F" +
team.slug +
"&teamId=" +
team.id,
child: t("create"), child: t("create"),
}} }}
/> />
+4 -1
View File
@@ -23,7 +23,7 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
if (!metadata?.paymentId) return { url: null }; if (!metadata?.paymentId) return { url: null };
const checkoutSession = await stripe.checkout.sessions.retrieve(metadata.paymentId); 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 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. */ /** 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}` }; 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, { await stripe.subscriptions.update(subscriptionId, {
items: [{ quantity: team.members.length, id: subscriptionItemId }], items: [{ quantity: team.members.length, id: subscriptionItemId }],
}); });
console.info(
`Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.`
);
} catch (error) { } catch (error) {
let message = "Unknown error on updateQuantitySubscriptionFromStripe"; let message = "Unknown error on updateQuantitySubscriptionFromStripe";
if (error instanceof Error) message = error.message; if (error instanceof Error) message = error.message;
@@ -256,7 +256,7 @@ const ProfileView = () => {
<Label className="mt-5 text-black">{t("about")}</Label> <Label className="mt-5 text-black">{t("about")}</Label>
<div <div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600" 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 { classNames, parseRecurringEvent } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { addListFormatting, md } from "@calcom/lib/markdownIt";
import type { baseEventTypeSelect } from "@calcom/prisma"; import type { baseEventTypeSelect } from "@calcom/prisma";
import type { EventTypeModel } from "@calcom/prisma/zod"; import type { EventTypeModel } from "@calcom/prisma/zod";
import { Badge } from "@calcom/ui"; import { Badge } from "@calcom/ui";
@@ -26,6 +25,7 @@ export type EventTypeDescriptionProps = {
z.infer<typeof EventTypeModel>, z.infer<typeof EventTypeModel>,
Exclude<keyof typeof baseEventTypeSelect, "recurringEvent"> | "metadata" Exclude<keyof typeof baseEventTypeSelect, "recurringEvent"> | "metadata"
> & { > & {
descriptionAsSafeHTML?: string | null;
recurringEvent: Prisma.JsonValue; recurringEvent: Prisma.JsonValue;
seatsPerTimeSlot?: number; seatsPerTimeSlot?: number;
}; };
@@ -57,7 +57,7 @@ export const EventTypeDescription = ({
shortenDescription ? "line-clamp-4" : "" shortenDescription ? "line-clamp-4" : ""
)} )}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: addListFormatting(md.render(eventType.description)), __html: eventType.descriptionAsSafeHTML || "",
}} }}
/> />
)} )}
+2 -1
View File
@@ -4,8 +4,9 @@
**/ **/
export type AppFlags = { export type AppFlags = {
emails: boolean; emails: boolean;
insights: boolean;
teams: boolean; teams: boolean;
webhooks: boolean; webhooks: boolean;
workflows: 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter"; import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
export const AverageEventDurationChart = () => { export const AverageEventDurationChart = () => {
const { t } = useLocale(); const { t } = useLocale();
@@ -23,7 +24,7 @@ export const AverageEventDurationChart = () => {
if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null; if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null;
return ( return (
<Card> <CardInsights>
<Title>{t("average_event_duration")}</Title> <Title>{t("average_event_duration")}</Title>
<LineChart <LineChart
className="mt-4 h-80" className="mt-4 h-80"
@@ -33,6 +34,6 @@ export const AverageEventDurationChart = () => {
colors={["blue"]} colors={["blue"]}
valueFormatter={valueFormatter} 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter"; import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
export const BookingStatusLineChart = () => { export const BookingStatusLineChart = () => {
const { t } = useLocale(); const { t } = useLocale();
@@ -25,7 +26,7 @@ export const BookingStatusLineChart = () => {
if (!isSuccess) return null; if (!isSuccess) return null;
return ( return (
<Card> <CardInsights>
<Title>{t("event_trends")}</Title> <Title>{t("event_trends")}</Title>
<LineChart <LineChart
className="mt-4 h-80" className="mt-4 h-80"
@@ -35,6 +36,6 @@ export const BookingStatusLineChart = () => {
colors={["gray", "green", "blue", "red"]} colors={["gray", "green", "blue", "red"]}
valueFormatter={valueFormatter} 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "@calcom/ui"; import { Tooltip } from "@calcom/ui";
import { calculateDeltaType, colors, valueFormatter } from "../lib"; import { calculateDeltaType, colors, valueFormatter } from "../lib";
import { CardInsights } from "./Card";
export const KPICard = ({ export const KPICard = ({
title, title,
@@ -20,7 +21,7 @@ export const KPICard = ({
}) => { }) => {
const { t } = useLocale(); const { t } = useLocale();
return ( return (
<Card key={title}> <CardInsights key={title}>
<Text>{title}</Text> <Text>{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate"> <Flex className="items-baseline justify-start space-x-3 truncate">
<Metric>{valueFormatter(previousMetricData.count)}</Metric> <Metric>{valueFormatter(previousMetricData.count)}</Metric>
@@ -40,10 +41,12 @@ export const KPICard = ({
startDate: previousDateRange.startDate, startDate: previousDateRange.startDate,
endDate: previousDateRange.endDate, 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> </Tooltip>
</Flex> </Flex>
</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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const LeastBookedTeamMembersTable = () => { export const LeastBookedTeamMembersTable = () => {
const { t } = useLocale(); const { t } = useLocale();
const { filter } = useFilterContext(); const { filter } = useFilterContext();
const { dateRange } = filter; const { dateRange, selectedEventTypeId, selectedTeamId: teamId } = filter;
const [startDate, endDate] = dateRange; const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({ const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
startDate: startDate.toISOString(), startDate: startDate.toISOString(),
endDate: endDate.toISOString(), endDate: endDate.toISOString(),
teamId, teamId,
eventTypeId: selectedEventTypeId ?? undefined,
}); });
if (!isSuccess || !startDate || !endDate || !teamId) return null; if (!isSuccess || !startDate || !endDate || !teamId) return null;
return ( return (
<Card> <CardInsights>
<Title>{t("least_booked_members")}</Title> <Title>{t("least_booked_members")}</Title>
<TotalBookingUsersTable data={data} /> <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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const MostBookedTeamMembersTable = () => { export const MostBookedTeamMembersTable = () => {
@@ -23,9 +24,9 @@ export const MostBookedTeamMembersTable = () => {
if (!isSuccess || !startDate || !endDate || !teamId) return null; if (!isSuccess || !startDate || !endDate || !teamId) return null;
return ( return (
<Card> <CardInsights className="shadow-none">
<Title>{t("most_booked_members")}</Title> <Title>{t("most_booked_members")}</Title>
<TotalBookingUsersTable data={data} /> <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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
export const PopularEventsTable = () => { export const PopularEventsTable = () => {
const { t } = useLocale(); const { t } = useLocale();
@@ -19,34 +20,25 @@ export const PopularEventsTable = () => {
userId: selectedUserId ?? undefined, userId: selectedUserId ?? undefined,
}); });
if (!startDate || !endDate || !teamId) return null; if (!isSuccess || !startDate || !endDate || !teamId || data?.length === 0) return null;
return ( return (
<Card> <CardInsights>
<Title>{t("popular_events")}</Title> <Title>{t("popular_events")}</Title>
<Table className="mt-5"> <Table className="mt-5">
<TableBody> <TableBody>
{isSuccess ? ( {data.map((item) => (
data?.map((item) => ( <TableRow key={item.eventTypeId}>
<TableRow key={item.eventTypeId}> <TableCell>{item.eventTypeName}</TableCell>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>{t("no_event_types_found")}</TableCell>
<TableCell> <TableCell>
<strong>0</strong> <Text>
<strong>{item.count}</strong>
</Text>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} ))}
</TableBody> </TableBody>
</Table> </Table>
</Card> </CardInsights>
); );
}; };
@@ -1,4 +1,6 @@
import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
@@ -6,19 +8,60 @@ import type { FilterContextType } from "./provider";
import { FilterProvider } from "./provider"; import { FilterProvider } from "./provider";
export function FiltersProvider({ children }: { children: React.ReactNode }) { 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 // TODO: Sync insight filters with URL parameters
const [selectedTimeView, setSelectedTimeView] = const [selectedTimeView, setSelectedTimeView] =
useState<FilterContextType["filter"]["selectedTimeView"]>("week"); useState<FilterContextType["filter"]["selectedTimeView"]>("week");
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(null); const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(null); userIdParsed || null
const [selectedEventTypeId, setSelectedEventTypeId] = );
useState<FilterContextType["filter"]["selectedEventTypeId"]>(null); const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(null); teamIdParsed || null
);
const [selectedEventTypeId, setSelectedEventTypeId] = useState<
FilterContextType["filter"]["selectedEventTypeId"]
>(eventTypeIdParsed || null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(
filterParsed ? [filterParsed] : null
);
const [selectedTeamName, setSelectedTeamName] = const [selectedTeamName, setSelectedTeamName] =
useState<FilterContextType["filter"]["selectedTeamName"]>(null); useState<FilterContextType["filter"]["selectedTeamName"]>(null);
const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([ const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([
dayjs().subtract(1, "month"), startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "month"),
dayjs(), endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
"t", "t",
]); ]);
return ( return (
@@ -33,13 +76,58 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedEventTypeId, selectedEventTypeId,
selectedFilter, selectedFilter,
}, },
setSelectedFilter: (filter) => setSelectedFilter(filter), setSelectedFilter: (filter) => {
setDateRange: (dateRange) => setDateRange(dateRange), 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), setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
setSelectedUserId: (selectedUserId) => setSelectedUserId(selectedUserId), setSelectedUserId: (selectedUserId) => {
setSelectedTeamId: (selectedTeamId) => setSelectedTeamId(selectedTeamId), setSelectedUserId(selectedUserId);
router.push({
query: {
...router.query,
userId: selectedUserId,
},
});
},
setSelectedTeamId: (selectedTeamId) => {
setSelectedTeamId(selectedTeamId);
router.push({
query: {
...router.query,
teamId: selectedTeamId,
},
});
},
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName), setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
setSelectedEventTypeId: (selectedEventTypeId) => setSelectedEventTypeId(selectedEventTypeId), setSelectedEventTypeId: (selectedEventTypeId) => {
setSelectedEventTypeId(selectedEventTypeId);
router.push({
query: {
...router.query,
eventTypeId: selectedEventTypeId,
},
});
},
}}> }}>
{children} {children}
</FilterProvider> </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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { useFilterContext } from "../context/provider"; import { useFilterContext } from "../context/provider";
import "./DateSelect.css";
type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null; type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null;
@@ -15,44 +16,49 @@ export const DateSelect = () => {
const startValue = startDate?.toDate() || null; const startValue = startDate?.toDate() || null;
const endValue = endDate?.toDate() || null; const endValue = endDate?.toDate() || null;
return ( return (
<DateRangePicker <div className="custom-date">
value={[startValue, endValue, range]} <DateRangePicker
defaultValue={[startValue, endValue, range]} value={[startValue, endValue, range]}
onValueChange={(datesArray) => { defaultValue={[startValue, endValue, range]}
const [selected, ...rest] = datesArray; onValueChange={(datesArray) => {
const [start, end, range] = datesArray; const [selected, ...rest] = datesArray;
// If range has value and it's of type RangeType 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")) { if (
setDateRange([dayjs(start), dayjs(end), range]); range &&
return; (range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")
} else if (start && !end) { ) {
// If only start time has value that means selected date should push to dateRange with last value null setDateRange([dayjs(start), dayjs(end), range]);
const currentDates = filter.dateRange; return;
// remove last position of array } else if (start && !end) {
currentDates.pop(); // If only start time has value that means selected date should push to dateRange with last value null
// push new value to array const currentDates = filter.dateRange;
currentDates.push(dayjs(selected)); // remove last position of array
// if lenght > 2 then remove first value currentDates.pop();
if (currentDates.length > 2) { // push new value to array
currentDates.shift(); 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]); // If range has value and it's of type RangeType
}}
return; options={undefined}
} enableDropdown={true}
placeholder={t("select_date_range")}
// If range has value and it's of type RangeType enableYearPagination={true}
}} minDate={currentDate.subtract(2, "year").toDate()}
options={undefined} maxDate={currentDate.toDate()}
enableDropdown={true} color="gray"
placeholder={t("select_date_range")} className="h-[42px] max-w-sm"
enableYearPagination={true} />
minDate={currentDate.subtract(2, "year").toDate()} </div>
maxDate={currentDate.toDate()}
color="blue"
className="h-[42px] max-w-sm"
/>
); );
}; };
@@ -8,7 +8,8 @@ type Option = { value: "event-type" | "user"; label: string };
export const FilterType = () => { export const FilterType = () => {
const { t } = useLocale(); const { t } = useLocale();
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId } = useFilterContext(); const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId, filter } = useFilterContext();
const { selectedFilter } = filter;
const filterOptions: Option[] = [ const filterOptions: Option[] = [
{ {
@@ -21,20 +22,19 @@ export const FilterType = () => {
}, },
]; ];
const filterValue = selectedFilter
? filterOptions.find((option) => option.value === selectedFilter[0])
: undefined;
return ( return (
<Select<Option> <Select<Option>
isMulti={false}
isSearchable={false}
options={filterOptions} options={filterOptions}
onChange={(input) => { value={filterValue}
if (input) { defaultValue={filterValue}
onChange={(newValue) => {
if (newValue) {
// This can multiple values, but for now we only want to have one filter active at a time // This can multiple values, but for now we only want to have one filter active at a time
setSelectedFilter([input.value]); setSelectedFilter([newValue.value]);
if (input.value === "event-type") {
setSelectedUserId(null);
} else if (input.value === "user") {
setSelectedEventTypeId(null);
}
} }
}} }}
className="w-32 min-w-[130px]" 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 { DateSelect } from "./DateSelect";
import { EventTypeListInTeam } from "./EventTypeListInTeam"; import { EventTypeListInTeam } from "./EventTypeListInTeam";
import { FilterType } from "./FilterType"; import { FilterType } from "./FilterType";
import { TeamList } from "./TeamList"; import { TeamList } from "./TeamList";
import { UserListInTeam } from "./UsersListInTeam"; 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 = () => { export const Filters = () => {
return ( 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 /> <TeamList />
<FilterType /> <FilterType />
@@ -17,6 +48,8 @@ export const Filters = () => {
<DateSelect /> <DateSelect />
<ClearFilters />
{/* @NOTE: To be released in next iteration */} {/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}> {/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
<Tooltip content={t("settings")}> <Tooltip content={t("settings")}>
@@ -377,7 +377,6 @@ export const insightsRouter = router({
}; };
if (userId) { if (userId) {
delete whereConditional.eventType;
whereConditional["userId"] = userId; whereConditional["userId"] = userId;
} }
@@ -707,6 +706,12 @@ export const insightsRouter = router({
} }
const eventTypes = await ctx.prisma.eventType.findMany({ const eventTypes = await ctx.prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: { where: {
teamId: input.teamId, teamId: input.teamId,
}, },
+1 -1
View File
@@ -536,7 +536,7 @@ const navigation: NavigationItemType[] = [
icon: FiZap, icon: FiZap,
}, },
{ {
name: "Insights", name: "insights",
href: "/insights", href: "/insights",
icon: FiBarChart, icon: FiBarChart,
}, },
+1
View File
@@ -26,6 +26,7 @@ type UsernameSlugLinkProps = {
}; };
const user: User = { const user: User = {
metadata: null,
theme: null, theme: null,
credentials: [], credentials: [],
username: "john.doe", username: "john.doe",
-9
View File
@@ -1,12 +1,3 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
export const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); 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 { declare global {
// eslint-disable-next-line no-var // 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 // If any changed on middleware server restart is required
bookingReferenceMiddleware(prisma); bookingReferenceMiddleware(prisma);
eventTypeDescriptionParseAndSanitizeMiddleware(prisma);
export default 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 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", "post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts", "seed-app-store": "ts-node --transpile-only ./seed-app-store.ts",
"delete-app": "ts-node --transpile-only ./delete-app.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": { "devDependencies": {
"npm-run-all": "^4.1.5" "npm-run-all": "^4.1.5"
+2 -1
View File
@@ -203,7 +203,8 @@
"categories": ["video"], "categories": ["video"],
"slug": "facetime", "slug": "facetime",
"type": "facetime_video", "type": "facetime_video",
"isTemplate": false}, "isTemplate": false
},
{ {
"dirName": "zohocrm", "dirName": "zohocrm",
"categories": ["other"], "categories": ["other"],
File renamed without changes.
+1
View File
@@ -45,6 +45,7 @@ export const userSelect = Prisma.validator<Prisma.UserArgs>()({
theme: true, theme: true,
brandColor: true, brandColor: true,
darkBrandColor: true, darkBrandColor: true,
metadata: true,
...availabilityUserSelect, ...availabilityUserSelect,
}, },
}); });
@@ -12,6 +12,7 @@ import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-st
import { validateIntervalLimitOrder } from "@calcom/lib"; import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById"; import getEventTypeById from "@calcom/lib/getEventTypeById";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma"; import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod";
import type { CustomInputSchema } from "@calcom/prisma/zod-utils"; import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
@@ -283,6 +284,7 @@ export const eventTypesRouter = router({
const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({
...eventType, ...eventType,
safeDescription: markdownToSafeHTML(eventType.description),
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
// @FIXME: cc @hariombalhara This is failing with production data // @FIXME: cc @hariombalhara This is failing with production data
// metadata: EventTypeMetaDataSchema.parse(eventType.metadata), // metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more