Compare commits

...
24 Commits
Author SHA1 Message Date
Alex van Andel 74d3a84e27 v4.1.5 (#15230)
release.yml / release (push) Canceled after 0s
2024-05-28 20:15:53 +00:00
Somay Chauhan 33c1d49c52 feat: jelly seamless signup (#15220)
* feat: jelly seamless signup

* fix: wrong import

* add credentialId id to defaultLocation

* add CredentialId and correct appType

* fix: type error
2024-05-28 17:54:59 +00:00
889ff177c1 feat: Add {first name} variable to Organizer & Scheduler custom event… (#14156)
* feat: Add {first name} variable to Organizer & Scheduler custom event name

* fix: use split name booking field

* remove unwanted translation changes

* Update packages/core/event.ts

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* chore: improve code

* add scheduler last name

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-28 14:32:12 +00:00
Morgan d8a6c375d0 chore: Platform Booker defaultFormValues for prefilling (#15219)
* chore: Platform Booker defaultFormValues for prefilling

* fixup! chore: Platform Booker defaultFormValues for prefilling
2024-05-28 11:36:54 +00:00
Lauris Skraucis 655127d155 fix: exclude atoms web wrappers from build (#15216)
* fix: exclude atoms web wrappers from build

* refactor: tsconfig web wrapper regex

* refactor: wrappers follow schema Component(Web|Platform)Wrapper.tsx

* fix: tsconfig regex for web wrapper
2024-05-28 10:09:03 +00:00
Morgan 17c4a10e33 fix: platform plan name is always uppercase (#15215) 2024-05-28 08:17:42 +00:00
Udit Takkar 30f9f19a9f fix: avatar on booking page (#15190)
* fix: avatar on booking page

* chore

* fix: type err

* fix: unpublished org
2024-05-27 19:31:08 +00:00
Anik Dhabal BabuandPeer Richelsen be7d45afe2 feat: autosave availability after making changes (#14849)
* feat: availability after making changes

* update

* update

* Update Schedule.tsx

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-27 14:25:29 +00:00
Amit SharmaandUdit Takkar d2bec46d89 fix: event duration missing translation (#14862)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-27 14:12:00 +00:00
Sujit Ale MagarandUdit Takkar b90500778e fix: Event Type Toggle Shift (#15066)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-27 14:10:53 +00:00
Rajiv SahalandMorgan Vernay bddc235c3c fix: platform booker icons and address property for type inPerson (#15207)
* fix: platform booker icons

* fix label not being shown for address

* fix type check

* PR feedback

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-05-27 11:02:58 +00:00
360b729d08 chore: added event-types link to settings (#14274)
* added event-types link

* fix: add item in settings layout

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-27 09:35:52 +00:00
Alex van Andel 5d325495a9 fix: Profile page crash due to invalid topLevelElement (#15198) 2024-05-24 16:12:15 +00:00
Amit Sharma dead2b843a fix: attendeeSeatId is not being returned on webhook response for events with seats (#15184) 2024-05-24 15:47:35 +00:00
Olga Stogova 1174cb0d8e fix(atoms): fix max-height bug for the Booker component (#15195) 2024-05-24 13:15:04 +00:00
Olga Stogova 58e6f18594 fix: styles for the schedule component, make it mobile-friendly (#15178)
* fix styles for the schedule component, make it mobile-friendly

* CR changes

* no need that condition
2024-05-24 11:06:40 +00:00
Udit Takkar ffe6a46e66 chore: return empty array if no transcription found (#15185) 2024-05-24 02:20:50 +00:00
Keith Williams 37c02572de chore: v4.1.4 (#15186)
release.yml / release (push) Canceled after 0s
2024-05-23 20:06:37 +00:00
b82b0a33a4 feat: events apps new install flow (#14616)
* feat(appStore): add isOAuth config

* refactor(appStore): EventTypeAppSettngsInterface

* refactor(qr_code): split EventTypeAppCardInterface and EventTypeAppSettingsInterface

* feat(appStore): redirect to onboarding for stripe and basecam

* fix(ui/ScrollableArea): overflow indicator not working

* feat(apps): new install app flow

* fix(configureStep): get disabled props for settings

* fix: getAppInstallsBySlug now use teamIds

* chore: Alby to AppSettingsInterface

* chore: Giphy to appsettings interface

* chore: GTM migration

* chore: GT4 - migration

* chore: fathom migration

* chore: paypal

* feat: basecamp migration

* feat: metapixel migration

* feat:plausable

* feat:stripe

* fix merge issues

* fix merge issues in new app install flow steps

* wip: callback to onboarding

* fix: imports

* fix: more imports

* feat: use redirect to onboarding on install flow

* fix: redirect to event-type after oauth flow

* When clicking on install new app button, only redirect to the new app install flow if it is an oAuth app or an app that extends the EventType feature in it's config

* code refactor, skip event-type step

* removed extra consoles

* so that no one can change the eventTypeId in url

* theme support enabled

* only pass eventTypeId instead of whole evetType obj to OmniInstallAppButton

* fix: clicking on event-type gives error and redirects to /apps

* saved appOnboardingRedirectUrl to OAuth State

* fix: a user having no team breaks the onboarding flow

* add optional chaining to appMetadata.extendsFeature and appMetadata.isOAuth;

* encode appOnboardingRedirectUrl

* fix: dark theme ui issues

* skip configure step if no-event types

if user/team does not have event-type then skip configure step and goto apps/installed/[category]

* exclude calendars from apps- onboarding flow

* Update getAppOnboardingRedirectUrl.ts

* fix: On configure step, inputs aren't showing errors

* change route from `apps/onboarding` to `apps/installation`

* fix: flow not correctly working for teams

* should only allow team ADMIN or OWER to install an app to team

* Prevent two payment apps to be enabled

* removed loading state

* Fix type error

* undo new-app-flow from OmniInstallAppButton

* remove OAuth Step, handle step numbering

* fix: redirect url

* getAppOnboardingRedirectUrl refactor

* for debugging

* Revert "for debugging"

This reverts commit b9362c1a8f47f2280e684c213d7ec08d04a8ac4c.

* fix: added toaster

* feat: added support to select multiple events (almost)

* hide button

* Move inner form outside outer form using portal

Due to limitations with nested forms in React Hook Form, moved the inner form outside the outer form using a portal to ensure proper form handling.

* submit buttons refs madness

* removed configure step

we had to use query params to manage selected event-types, between routes which was causing 2 issues:
* each time we selected or unselected an event the whole page was reloaded including getserversideprops being called again and again
* the form was reloaded causing more issues

* removed extra unwanted code

* small fix

* fix: type error

* fix: types

* implemented suggested changes

* removed step footer
* fixed error.message
* removed commented styles

* removed step footer

* fix: Display team event types instead of user's on event types step when team selected

* fix: typo

* removed shouldLockDisableProps

* refactor setUpdatedEventTypesStatus, fix type

* removed extra code

* can't delete while saving

* added translations

* fix: loading in select account step, fix handle onSelect params type

* Fix typo

* feat: add skip button to event-types step

* use set-up-later button in tests

* added skip button to configure step as well

* feat: add test for analytics apps using the new app install flow

* feat: add test for stripe 1

* feat: add test for stripe 2

* Fix: Redirect only those apps that extend 'EventType' to the new app install flow (#14527)

* fix: type errors

* fix: failing tests

* fix: tests

* fix test flakiness

* better naming and code refactor for analytics apps tests

* run tests parallelly

* removed network idle, giving error

* another shot at test flakiness

* Fix:  can't install apps without team selector dropdown on main app store page.

* fix: test flakiness

* fix: account select loading issue

* removed getPaymentCredential (unused)

* fix: only redirect after the app has been added to all the event-types

* remove overflowIndicatorStyles

* fixed failing test

* fix: exclude org team events

* undo some random merge changes

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-23 19:53:28 +00:00
Udit TakkarandKeith Williams b1bbbcee90 fix: signing_up_terms i18n (#15156)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-23 19:06:30 +00:00
Hariom BalharaandKeith Williams 1a381695e5 fix: Org Seeder - to better represent production scenario and fix admin org delete cleanup (#15157)
* Fix seeder and update message to inform well about org deletion implication

* Cleanup tempOrgRedirect as well

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-23 18:01:25 +00:00
Keith WilliamsandOmar López 8831ff13c7 chore: Skip flaky tests that have been failing often (#15161)
* chore: Skip flaky tests that have been failing often

* Update basic.e2e.ts

* Skipped another test

* Only run on legacy routes as a test

* Removing future routes from build

* Updated the remove script

* Fixing command

* Bring back the future routes

* Skipped another

---------

Co-authored-by: Omar López <zomars@me.com>
2024-05-23 14:00:56 -04:00
Keith Williams 64845c670d fix: Required check for CI (#15181) 2024-05-23 13:26:17 -04:00
Keith Williams ba06c6c26a chore: Bring back merge queues (#15177)
* chore: Bring back merge queues

* Removed lint as pre-req for integration test

* Cleaned up warnings
2024-05-23 12:49:29 -04:00
188 changed files with 3137 additions and 903 deletions
+85
View File
@@ -0,0 +1,85 @@
name: All checks
on:
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
type-check:
name: Type check
uses: ./.github/workflows/check-types.yml
secrets: inherit
lint:
name: Linters
uses: ./.github/workflows/lint.yml
secrets: inherit
unit-test:
name: Tests
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-api-v2:
name: Production builds
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
integration-test:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1
+8 -7
View File
@@ -75,15 +75,16 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- name: Run Tests
run: yarn test -- --integrationTestsOnly
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results
# TODO: Generate test results so we can upload them
# - name: Upload Test Results
# if: ${{ always() }}
# uses: actions/upload-artifact@v4
# with:
# name: test-results
# path: test-results
+5 -62
View File
@@ -4,7 +4,6 @@ on:
pull_request_target:
branches:
- main
merge_group:
workflow_dispatch:
concurrency:
@@ -35,13 +34,6 @@ jobs:
uses: ./.github/workflows/check-types.yml
secrets: inherit
unit-test:
name: Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
lint:
name: Linters
needs: [changes]
@@ -49,71 +41,22 @@ jobs:
uses: ./.github/workflows/lint.yml
secrets: inherit
build-api-v1:
name: Production builds
unit-test:
name: Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-api-v2:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build-without-database.yml
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [changes, build]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [changes, lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, unit-test, integration-test]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
@@ -122,7 +122,7 @@ export class BillingController {
await this.billingService.setSubscriptionForTeam(
teamId,
subscription,
PlatformPlan[plan as keyof typeof PlatformPlan]
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
return {
@@ -11,7 +11,7 @@ export class BillingConfigService {
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key as keyof typeof PlatformPlan],
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
}
+12
View File
@@ -7,6 +7,7 @@ import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/comp
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App as AppType } from "@calcom/types/App";
@@ -21,6 +22,7 @@ export type AppPageProps = {
isGlobal?: AppType["isGlobal"];
logo: string;
slug: string;
dirName: string | undefined;
variant: string;
body: React.ReactNode;
categories: string[];
@@ -68,8 +70,11 @@ export const AppPage = ({
dependencies,
concurrentMeetings,
paid,
dirName,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const searchParams = useCompatSearchParams();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const mutation = useAddAppMutation(null, {
@@ -118,6 +123,11 @@ export const AppPage = ({
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
// Such apps, can only be installed once.
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
useEffect(() => {
if (searchParams?.get("defaultInstall") === "true") {
mutation.mutate({ type, variant, slug, defaultInstall: true });
}
}, []);
return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
@@ -223,6 +233,7 @@ export const AppPage = ({
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
);
@@ -262,6 +273,7 @@ export const AppPage = ({
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
);
@@ -1,7 +1,14 @@
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -26,7 +33,9 @@ export const InstallAppButtonChild = ({
multiInstall,
credentials,
concurrentMeetings,
dirName,
paid,
onClick,
...props
}: {
userAdminTeams?: UserAdminTeams;
@@ -36,8 +45,10 @@ export const InstallAppButtonChild = ({
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
dirName: string | undefined;
} & ButtonProps) => {
const { t } = useLocale();
const router = useRouter();
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
@@ -49,6 +60,18 @@ export const InstallAppButtonChild = ({
},
});
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata];
const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]);
const _onClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (redirectToAppOnboarding) {
router.push(
getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP })
);
} else if (onClick) {
onClick(e);
}
};
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
@@ -56,6 +79,7 @@ export const InstallAppButtonChild = ({
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
disabled={shouldDisableInstallation}
color="primary"
@@ -72,6 +96,7 @@ export const InstallAppButtonChild = ({
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
@@ -83,6 +108,19 @@ export const InstallAppButtonChild = ({
);
}
if (redirectToAppOnboarding) {
return (
<Button
data-testid="install-app-button"
disabled={shouldDisableInstallation}
onClick={_onClick}
color="primary"
size="base"
{...props}>
{multiInstall ? t("install_another") : t("install_app")}
</Button>
);
}
return (
<Dropdown>
<DropdownMenuTrigger asChild>
@@ -0,0 +1,97 @@
import type { FC } from "react";
import React, { useState } from "react";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Team, User } from "@calcom/prisma/client";
import { Avatar, StepCard } from "@calcom/ui";
type AccountSelectorProps = {
avatar?: string;
name: string;
alreadyInstalled: boolean;
onClick: () => void;
loading: boolean;
testId: string;
};
const AccountSelector: FC<AccountSelectorProps> = ({
avatar,
alreadyInstalled,
name,
onClick,
loading,
testId,
}) => {
const { t } = useLocale();
const [selected, setSelected] = useState(false);
return (
<div
className={classNames(
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
(alreadyInstalled || loading) && "cursor-not-allowed",
selected && "bg-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
if (!alreadyInstalled && !loading) {
setSelected(true);
onClick();
}
}}>
<Avatar
alt={avatar || ""}
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
size="sm"
/>
<div className="text-md pt-0.5 font-medium text-gray-500">
{name}
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
</div>
</div>
);
};
export type PersonalAccountProps = Pick<User, "id" | "avatar" | "name"> & { alreadyInstalled: boolean };
export type TeamsProp = (Pick<Team, "id" | "name" | "logo"> & {
alreadyInstalled: boolean;
})[];
type AccountStepCardProps = {
teams: TeamsProp;
personalAccount: PersonalAccountProps;
onSelect: (id?: number) => void;
loading: boolean;
};
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
const { t } = useLocale();
return (
<StepCard>
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
<AccountSelector
testId="install-app-button-personal"
avatar={personalAccount.avatar ?? ""}
name={personalAccount.name ?? ""}
alreadyInstalled={personalAccount.alreadyInstalled}
onClick={() => onSelect()}
loading={loading}
/>
{teams.map((team) => (
<AccountSelector
key={team.id}
testId={`install-app-button-team${team.id}`}
alreadyInstalled={team.alreadyInstalled}
avatar={team.logo ?? ""}
name={team.name}
onClick={() => onSelect(team.id)}
loading={loading}
/>
))}
</div>
</StepCard>
);
};
@@ -0,0 +1,223 @@
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import { X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useForm } from "react-hook-form";
import type { z } from "zod";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Button, Form } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
type TFormType = {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
};
type ConfigureStepCardProps = {
slug: string;
userName: string;
categories: AppCategories[];
credentialId?: number;
loading?: boolean;
formPortalRef: React.RefObject<HTMLDivElement>;
eventTypes: TEventType[] | undefined;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
handleSetUpLater: () => void;
};
type EventTypeAppSettingsFormProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId" | "loading"
> & {
eventType: TEventType;
handleDelete: () => void;
onSubmit: (values: z.infer<typeof EventTypeMetaDataSchema>) => void;
};
type EventTypeAppSettingsWrapperProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId"
> & {
eventType: TEventType;
};
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
slug,
eventType,
categories,
credentialId,
}) => {
const { getAppDataGetter, getAppDataSetter } = useAppsData();
useEffect(() => {
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
appDataSetter("enabled", true);
}, []);
return (
<EventTypeAppSettings
slug={slug}
eventType={eventType}
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
/>
);
};
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
function EventTypeAppSettingsForm(props, ref) {
const { handleDelete, onSubmit, eventType, loading } = props;
const formMethods = useForm<TFormType>({
defaultValues: {
metadata: eventType?.metadata,
},
});
return (
<Form
form={formMethods}
id={`eventtype-${eventType.id}`}
handleSubmit={() => {
const data = formMethods.getValues("metadata");
onSubmit(data);
}}>
<div>
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
<div>
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{eventType.title}</span>{" "}
<small className="text-subtle hidden font-normal sm:inline">
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
</small>
</div>
<EventTypeAppSettingsWrapper {...props} />
<X
data-testid={`remove-event-type-${eventType.id}`}
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
onClick={() => !loading && handleDelete()}
/>
<button type="submit" className="hidden" ref={ref}>
Save
</button>
</div>
</div>
</Form>
);
}
);
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
loading,
formPortalRef,
eventTypes,
setConfigureStep,
handleSetUpLater,
...props
}) => {
const { t } = useLocale();
const { control, getValues } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
keyName: "fieldId",
});
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
submitRefs.current = fields.map(
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
);
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
);
const [submit, setSubmit] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
useEffect(() => {
setUpdatedEventTypesStatus((prev) =>
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
);
if (!fields.some((field) => field.selected)) {
setConfigureStep(false);
}
}, [fields]);
useEffect(() => {
if (submit && allUpdated && mainForSubmitRef.current) {
mainForSubmitRef.current?.click();
setSubmit(false);
}
}, [submit, allUpdated, getValues, mainForSubmitRef]);
return (
formPortalRef?.current &&
createPortal(
<div className="mt-8">
<div className="flex flex-col space-y-6">
{fields.map((field, index) => {
return (
field.selected && (
<EventTypeAppSettingsForm
key={field.fieldId}
eventType={field}
loading={loading}
handleDelete={() => {
const eventMetadataDb = eventTypes?.find(
(eventType) => eventType.id == field.id
)?.metadata;
update(index, { ...field, selected: false, metadata: eventMetadataDb });
}}
onSubmit={(data) => {
update(index, { ...field, metadata: data });
setUpdatedEventTypesStatus((prev) =>
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
);
}}
ref={submitRefs.current[index]}
{...props}
/>
)
);
})}
</div>
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
Save
</button>
<Button
className="text-md mt-6 w-full justify-center"
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.reverse().map((ref) => ref.current?.click());
setSubmit(true);
}}
loading={loading}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>,
formPortalRef?.current
)
);
};
@@ -0,0 +1,130 @@
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ScrollableArea, Badge, Button } from "@calcom/ui";
type EventTypesCardProps = {
userName: string;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
handleSetUpLater: () => void;
};
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
setConfigureStep,
userName,
handleSetUpLater,
}) => {
const { t } = useLocale();
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
keyName: "fieldId",
});
return (
<div>
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
{fields.map((field, index) => (
<EventTypeCard
handleSelect={() => update(index, { ...field, selected: !field.selected })}
userName={userName}
key={field.fieldId}
{...field}
/>
))}
</ul>
</ScrollableArea>
</div>
<Button
className="text-md mt-6 w-full justify-center"
data-testid="save-event-types"
onClick={() => {
setConfigureStep(true);
}}
disabled={!fields.some((field) => field.selected === true)}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>
);
};
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
const EventTypeCard: FC<EventTypeCardProps> = ({
title,
description,
id,
metadata,
length,
selected,
slug,
handleSelect,
team,
userName,
}) => {
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
const durations =
parsedMetaData.success &&
parsedMetaData.data?.multipleDuration &&
Boolean(parsedMetaData.data?.multipleDuration.length)
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
: [length];
return (
<div
data-testid={`select-event-type-${id}`}
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
onClick={() => handleSelect()}>
<input
id={`${id}`}
checked={selected}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
/>
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
<li>
<div>
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{title}</span>{" "}
<small className="text-subtle hidden font-normal sm:inline">
/{team ? team.slug : userName}/{slug}
</small>
</div>
{Boolean(description) && (
<div className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
{description}
</div>
)}
<div className="mt-2 flex flex-row flex-wrap gap-2">
{Boolean(durations.length) &&
durations.map((duration) => (
<Badge key={`event-type-${id}-duration-${duration}`} variant="gray" startIcon="clock">
{duration}m
</Badge>
))}
</div>
</li>
</label>
</div>
);
};
@@ -0,0 +1,19 @@
import type { FC, ReactNode } from "react";
type StepHeaderProps = {
children?: ReactNode;
title: string;
subtitle: string;
};
export const StepHeader: FC<StepHeaderProps> = ({ children, title, subtitle }) => {
return (
<div>
<header>
<p className="font-cal mb-3 text-[28px] font-medium capitalize leading-7">{title}</p>
<p className="text-subtle font-sans text-sm font-normal">{subtitle}</p>
</header>
{children}
</div>
);
};
@@ -19,11 +19,12 @@ interface CustomEventTypeModalFormProps {
setValue: (value: string) => void;
event: EventNameObjectType;
defaultValue: string;
isNameFieldSplit: boolean;
}
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
const { t } = useLocale();
const { placeHolder, close, setValue, event } = props;
const { placeHolder, close, setValue, event, isNameFieldSplit } = props;
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
const onSubmit: SubmitHandler<FormValues> = (data) => {
setValue(data.customEventName);
@@ -68,10 +69,26 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
<p className="text-emphasis">{t("your_full_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
<p className="text-emphasis">{t("organizer_first_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
<p className="text-emphasis">{t("scheduler_full_name")}</p>
</div>
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
<p className="text-emphasis">{t("scheduler_first_name")}</p>
</div>
)}
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
<p className="text-emphasis">{t("scheduler_last_name")}</p>
</div>
)}
<div className="mb-1 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
<p className="text-emphasis">{t("location_info")}</p>
@@ -101,12 +118,13 @@ interface CustomEventTypeModalProps {
close: () => void;
setValue: (value: string) => void;
event: EventNameObjectType;
isNameFieldSplit: boolean;
}
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
const { t } = useLocale();
const { defaultValue, placeHolder, close, setValue, event } = props;
const { defaultValue, placeHolder, close, setValue, event, isNameFieldSplit } = props;
const methods = useForm<FormValues>({
defaultValues: {
@@ -128,6 +146,7 @@ const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
setValue={setValue}
placeHolder={placeHolder}
defaultValue={defaultValue}
isNameFieldSplit={isNameFieldSplit}
/>
</FormProvider>
<DialogFooter>
@@ -65,6 +65,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
bookingFields[name] = `${name} input`;
});
const nameBookingField = formMethods.getValues().bookingFields.find((field) => field.name === "name");
const isSplit = (nameBookingField && nameBookingField.variant === "firstAndLastName") ?? false;
const eventNameObject: EventNameObjectType = {
attendeeName: t("scheduler"),
eventType: formMethods.getValues("title"),
@@ -564,6 +567,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setValue={(val: string) => formMethods.setValue("eventName", val, { shouldDirty: true })}
defaultValue={formMethods.getValues("eventName")}
placeHolder={eventNamePlaceholder}
isNameFieldSplit={isSplit}
event={eventNameObject}
/>
)}
+3 -45
View File
@@ -3,7 +3,6 @@ import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
@@ -13,6 +12,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
@@ -28,51 +29,8 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
const notInstalledApps =
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
formMethods.setValue(
"metadata",
{
...formMethods.getValues("metadata"),
apps: _allAppsData,
},
{ shouldDirty: true }
);
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
return function (key) {
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
if (key) {
return appData[key as keyof typeof appData];
}
return appData;
};
};
const eventTypeFormMetadata = formMethods.getValues("metadata");
const getAppDataSetter = (
appId: EventTypeAppsList,
appCategories: string[],
credentialId?: number
): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
[appId]: {
...appData,
[key]: value,
credentialId,
appCategories,
},
});
};
};
const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData();
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType,
@@ -323,7 +323,7 @@ function EventTypeSingleLayout({
<div
className={classNames(
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md",
formMethods.watch("hidden") ? "px-2" : "",
formMethods.watch("hidden") ? "pl-2" : "",
"lg:flex"
)}>
{formMethods.watch("hidden") && (
+58
View File
@@ -0,0 +1,58 @@
import type { FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
const useAppsData = () => {
const formMethods = useFormContext<FormValues>();
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
formMethods.setValue(
"metadata",
{
...formMethods.getValues("metadata"),
apps: _allAppsData,
},
{ shouldDirty: true }
);
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
return function (key) {
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
if (key) {
return appData[key as keyof typeof appData];
}
return appData;
};
};
const eventTypeFormMetadata = formMethods.getValues("metadata");
const getAppDataSetter = (
appId: EventTypeAppsList,
appCategories: string[],
credentialId?: number
): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
[appId]: {
...appData,
[key]: value,
credentialId,
appCategories,
},
});
};
};
return { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata };
};
export default useAppsData;
@@ -50,6 +50,7 @@ export type UserPageProps = {
considerUnpublished: boolean;
orgSlug?: string | null;
name?: string | null;
teamSlug?: string | null;
};
eventTypes: ({
descriptionAsSafeHTML: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.1.3",
"version": "4.1.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1
View File
@@ -53,6 +53,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
slug={data.slug}
variant={data.variant}
type={data.type}
dirName={data.dirName}
logo={data.logo}
categories={data.categories ?? [data.category]}
author={data.publisher}
@@ -0,0 +1,513 @@
import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { AppMeta } from "@calcom/types/App";
import { Form, Steps, showToast } from "@calcom/ui";
import { HttpError } from "@lib/core/http/error";
import PageWrapper from "@components/PageWrapper";
import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard";
import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard";
import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard";
import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard";
import { StepHeader } from "@components/apps/installation/StepHeader";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
selected: boolean;
};
export type TEventTypesForm = {
eventTypes: TEventType[];
};
const STEPS = [
AppOnboardingSteps.ACCOUNTS_STEP,
AppOnboardingSteps.EVENT_TYPES_STEP,
AppOnboardingSteps.CONFIGURE_STEP,
] as const;
const MAX_NUMBER_OF_STEPS = STEPS.length;
type StepType = (typeof STEPS)[number];
type StepObj = Record<
StepType,
{
getTitle: (appName: string) => string;
getDescription: (appName: string) => string;
stepNumber: number;
}
>;
type OnboardingPageProps = {
appMetadata: AppMeta;
step: StepType;
teams: TeamsProp;
personalAccount: PersonalAccountProps;
eventTypes?: TEventType[];
userName: string;
credentialId?: number;
};
const OnboardingPage = ({
step,
teams,
personalAccount,
appMetadata,
eventTypes,
userName,
credentialId,
}: OnboardingPageProps) => {
const { t } = useLocale();
const pathname = usePathname();
const router = useRouter();
const STEPS_MAP: StepObj = {
[AppOnboardingSteps.ACCOUNTS_STEP]: {
getTitle: () => `${t("select_account_header")}`,
getDescription: (appName) => `${t("select_account_description", { appName })}`,
stepNumber: 1,
},
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
getTitle: () => `${t("select_event_types_header")}`,
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
stepNumber: 2,
},
[AppOnboardingSteps.CONFIGURE_STEP]: {
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
getDescription: () => `${t("configure_app_description")}`,
stepNumber: 3,
},
} as const;
const [configureStep, setConfigureStep] = useState(false);
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
const currentStep: AppOnboardingSteps = useMemo(() => {
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
return AppOnboardingSteps.CONFIGURE_STEP;
}
return step;
}, [step, configureStep]);
const stepObj = STEPS_MAP[currentStep];
const utils = trpc.useContext();
const formPortalRef = useRef<HTMLDivElement>(null);
const formMethods = useForm<TEventTypesForm>({
defaultValues: {
eventTypes,
},
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
}, [eventTypes]);
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async (data) => {
showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${t(err.message)}`;
}
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
message = t("unexpected_error_try_again");
}
showToast(message ? t(message) : t(err.message), "error");
},
});
const handleSelectAccount = async (teamId?: number) => {
try {
setIsSelectingAccount(true);
if (appMetadata.isOAuth) {
const state = JSON.stringify({
appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId),
teamId,
});
const res = await fetch(
`/api/integrations/${
appMetadata.slug == "stripe" ? "stripepayment" : appMetadata.slug
}/add?state=${state}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
const oAuthUrl = (await res.json())?.url;
router.push(oAuthUrl);
return;
} else {
await fetch(`/api/integrations/${appMetadata.slug}/add${teamId ? `?teamId=${teamId}` : ""}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
router.push(
getAppOnboardingUrl({
slug: appMetadata.slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
teamId,
})
);
}
} catch (error) {
setIsSelectingAccount(false);
router.push(`/apps`);
}
};
const handleSetUpLater = () => {
router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`);
};
return (
<div
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
data-testid="onboarding">
<Head>
<title>
{t("install")} {appMetadata?.name ?? ""}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
<Form
form={formMethods}
id="outer-event-type-form"
handleSubmit={async (values) => {
const mutationPromises = values?.eventTypes
.filter((eventType) => eventType.selected)
.map((value: TEventType) => {
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
)
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
return updateMutation.mutateAsync({
id: value.id,
metadata: value.metadata,
});
});
try {
await Promise.all(mutationPromises);
router.push("/event-types");
} catch (err) {
console.error(err);
}
}}>
<StepHeader
title={stepObj.getTitle(appMetadata.name)}
subtitle={stepObj.getDescription(appMetadata.name)}>
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
</StepHeader>
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
<AccountsStepCard
teams={teams}
personalAccount={personalAccount}
onSelect={handleSelectAccount}
loading={isSelectingAccount}
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
eventTypes &&
Boolean(eventTypes?.length) && (
<EventTypesStepCard
setConfigureStep={setConfigureStep}
userName={userName}
handleSetUpLater={handleSetUpLater}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
/>
)}
</Form>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
};
// Redirect Error map to give context on edge cases, this is for the devs, never shown to users
const ERROR_MESSAGES = {
appNotFound: "App not found",
userNotAuthed: "User is not logged in",
userNotFound: "User from session not found",
appNotExtendsEventType: "App does not extend EventTypes",
userNotInTeam: "User is not in provided team",
} as const;
const getUser = async (userId: number) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
avatar: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
team: {
members: {
some: {
userId,
role: {
in: ["ADMIN", "OWNER"],
},
},
},
},
},
select: {
team: {
select: {
id: true,
name: true,
logo: true,
},
},
},
},
},
});
if (!user) {
throw new Error(ERROR_MESSAGES.userNotFound);
}
return user;
};
const getAppBySlug = async (appSlug: string) => {
const app = await prisma.app.findUnique({
where: { slug: appSlug, enabled: true },
select: { slug: true, keys: true, enabled: true, dirName: true },
});
if (!app) throw new Error(ERROR_MESSAGES.appNotFound);
return app;
};
const getEventTypes = async (userId: number, teamId?: number) => {
const eventTypes = (
await prisma.eventType.findMany({
select: {
id: true,
description: true,
durationLimits: true,
metadata: true,
length: true,
title: true,
position: true,
recurringEvent: true,
requiresConfirmation: true,
team: { select: { slug: true } },
schedulingType: true,
teamId: true,
users: { select: { username: true } },
seatsPerTimeSlot: true,
slug: true,
},
where: teamId ? { teamId } : { userId, teamId: null },
})
).sort((eventTypeA, eventTypeB) => {
return eventTypeB.position - eventTypeA.position;
});
if (eventTypes.length === 0) {
return [];
}
return eventTypes.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
selected: false,
}));
};
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
const appInstalls = await prisma.credential.findMany({
where: {
OR: [
{
appId: appSlug,
userId: userId,
},
teamIds && Boolean(teamIds.length)
? {
appId: appSlug,
teamId: { in: teamIds },
}
: {},
],
},
});
return appInstalls;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
try {
let eventTypes: TEventType[] | null = null;
const { req, res, query, params } = context;
const stepsEnum = z.enum(STEPS);
const parsedAppSlug = z.coerce.string().parse(query?.slug);
const parsedStepParam = z.coerce.string().parse(params?.step);
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
const _ = stepsEnum.parse(parsedStepParam);
const session = await getServerSession({ req, res });
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const hasEventTypes = appMetadata?.extendsFeature === "EventType";
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
if (!hasEventTypes) {
throw new Error(ERROR_MESSAGES.appNotExtendsEventType);
}
const user = await getUser(session.user.id);
const userAcceptedTeams = user.teams.map((team) => ({ ...team.team }));
const hasTeams = Boolean(userAcceptedTeams.length);
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userAcceptedTeams.map(({ id }) => id)
);
if (parsedTeamIdParam) {
const isUserMemberOfTeam = userAcceptedTeams.some((team) => team.id === parsedTeamIdParam);
if (!isUserMemberOfTeam) {
throw new Error(ERROR_MESSAGES.userNotInTeam);
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (eventTypes.length === 0) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
}
const personalAccount = {
id: user.id,
name: user.name,
avatar: user.avatar,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userAcceptedTeams.map((team) => ({
...team,
alreadyInstalled: appInstalls.some(
(install) => Boolean(install.teamId) && install.teamId === team.id
),
}))
: [];
let credentialId = null;
if (parsedTeamIdParam) {
credentialId =
appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
} else {
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
}
return {
props: {
...(await serverSideTranslations(locale, ["common"])),
app,
appMetadata,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypes,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
} as OnboardingPageProps,
};
} catch (err) {
if (err instanceof z.ZodError) {
return { redirect: { permanent: false, destination: "/apps" } };
}
if (err instanceof Error) {
switch (err.message) {
case ERROR_MESSAGES.userNotAuthed:
return { redirect: { permanent: false, destination: "/auth/login" } };
case ERROR_MESSAGES.userNotFound:
return { redirect: { permanent: false, destination: "/auth/login" } };
default:
return { redirect: { permanent: false, destination: "/apps" } };
}
}
}
};
OnboardingPage.PageWrapper = PageWrapper;
export default OnboardingPage;
+19 -17
View File
@@ -538,23 +538,25 @@ export default function Signup({
</Link>
</div>
<div className="text-subtle ">
<Trans i18nKey="signing_up_terms">
By proceeding, you agree to our{" "}
<Link
className="text-emphasis hover:underline"
href={`${WEBSITE_URL}/terms`}
target="_blank">
Terms
</Link>{" "}
and{" "}
<Link
className="text-emphasis hover:underline"
href={`${WEBSITE_URL}/privacy`}
target="_blank">
Privacy Policy
</Link>
.
</Trans>
<Trans
i18nKey="signing_up_terms"
components={[
<Link
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_URL}/terms`}
target="_blank">
Terms
</Link>,
<Link
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_URL}/privacy`}
target="_blank">
Privacy Policy.
</Link>,
]}
/>
</div>
</div>
</div>
@@ -1,28 +1,20 @@
import { loginUser } from "../../fixtures/regularBookings";
import { test } from "../../lib/fixtures";
const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
test.describe("Check analytics Apps", () => {
test.beforeEach(async ({ page, users }) => {
await loginUser(users);
await page.goto("/apps/");
});
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test("Check analytics Apps", async ({ appsPage, page }) => {
test.describe("Check analytics Apps ", () => {
test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/");
await appsPage.goToAppsCategory("analytics");
await appsPage.installApp("fathom");
await appsPage.goBackToAppsPage();
await appsPage.installApp("matomo");
await appsPage.goBackToAppsPage();
await appsPage.installApp("plausible");
await appsPage.goBackToAppsPage();
await appsPage.installApp("ga4");
await appsPage.goBackToAppsPage();
await appsPage.installApp("gtm");
await appsPage.goBackToAppsPage();
await appsPage.installApp("metapixel");
await appsPage.goBackToAppsPage();
for (const app of ALL_APPS) {
await appsPage.installAppSkipConfigure(app);
await appsPage.goBackToAppsPage();
}
await page.goto("/event-types");
await appsPage.goToEventType("30 min");
await appsPage.goToAppsTab();
@@ -32,4 +24,20 @@ test.describe("Check analytics Apps", () => {
}
await appsPage.verifyAppsInfo(6);
});
test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
const eventTypes = await user.getUserEventsAsOwner();
const eventTypesIds = eventTypes.map((item) => item.id);
for (const app of ALL_APPS) {
await page.goto("/apps/categories/analytics");
await appsPage.installApp(app, eventTypesIds);
}
for (const id of eventTypesIds) {
await appsPage.verifyAppsInfoNew(ALL_APPS, id);
}
});
});
+3 -1
View File
@@ -247,7 +247,9 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
expect(await linkElement.getAttribute("href")).toBe(testUrl);
});
test("Can remove location from multiple locations that are saved", async ({ page }) => {
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.skip("Can remove location from multiple locations that are saved", async ({ page }) => {
await gotoFirstEventType(page);
// Add Attendee Phone Number location
+43 -2
View File
@@ -1,14 +1,48 @@
import { expect, type Page } from "@playwright/test";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
export function createAppsFixture(page: Page) {
return {
goToAppsCategory: async (category: string) => {
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
await page.goto("apps/categories/analytics");
},
installApp: async (app: string) => {
installAppSkipConfigure: async (app: string) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
await page.getByTestId("install-app-button").click();
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
if (shouldRedirectToAppOnboarding(appMetadata)) {
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
await page.click('[data-testid="set-up-later"]');
}
},
installApp: async (app: string, eventTypeIds: number[]) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
(await page.waitForSelector('[data-testid="install-app-button"]')).click();
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
if (shouldRedirectToAppOnboarding(appMetadata)) {
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
await page.click(`[data-testid="save-event-types"]`);
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
if (app === "gtm") {
await page.waitForLoadState("domcontentloaded");
for (let index = 0; index < eventTypeIds.length; index++) {
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
}
}
await page.click(`[data-testid="configure-step-save"]`);
}
},
goBackToAppsPage: async () => {
await page.getByTestId("add-apps").click();
@@ -20,10 +54,17 @@ export function createAppsFixture(page: Page) {
await page.getByTestId("vertical-tab-apps").click();
},
activeApp: async (app: string) => {
await page.getByTestId(`${app}-app-switch`).click();
await page.locator(`[data-testid='${app}-app-switch']`).click();
},
verifyAppsInfo: async (activeApps: number) => {
await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible();
},
verifyAppsInfoNew: async (apps: string[], eventTypeId: number) => {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await page.waitForLoadState("domcontentloaded");
for (const app of apps) {
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
}
},
};
}
+78 -13
View File
@@ -1,4 +1,5 @@
import type { Page, WorkerInfo } from "@playwright/test";
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import type { Team } from "@prisma/client";
import { Prisma as PrismaType } from "@prisma/client";
@@ -34,6 +35,30 @@ const userIncludes = PrismaType.validator<PrismaType.UserInclude>()({
routingForms: true,
});
type InstallStripeParamsSkipTrue = {
eventTypeIds?: number[];
skip: true;
};
type InstallStripeParamsSkipFalse = {
skip: false;
eventTypeIds: number[];
};
type InstallStripeParamsUnion = InstallStripeParamsSkipTrue | InstallStripeParamsSkipFalse;
type InstallStripeTeamPramas = InstallStripeParamsUnion & {
page: Page;
teamId: number;
};
type InstallStripePersonalPramas = InstallStripeParamsUnion & {
page: Page;
};
type InstallStripeParams = InstallStripeParamsUnion & {
redirectUrl: string;
buttonSelector: string;
page: Page;
};
const userWithEventTypes = PrismaType.validator<PrismaType.UserArgs>()({
include: userIncludes,
});
@@ -650,6 +675,12 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
userId: user.id,
},
}),
getUserEventsAsOwner: async () =>
prisma.eventType.findMany({
where: {
userId: user.id,
},
}),
getFirstTeamEvent: async (teamId: number) => {
return prisma.eventType.findFirstOrThrow({
where: {
@@ -657,12 +688,15 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
},
});
},
getPaymentCredential: async () => getPaymentCredential(store.page),
setupEventWithPrice: async (eventType: Pick<Prisma.EventType, "id">, slug: string) =>
setupEventWithPrice(eventType, slug, store.page),
bookAndPayEvent: async (eventType: Pick<Prisma.EventType, "slug">) =>
bookAndPayEvent(user, eventType, store.page),
makePaymentUsingStripe: async () => makePaymentUsingStripe(store.page),
installStripePersonal: async (params: InstallStripeParamsUnion) =>
installStripePersonal({ page: store.page, ...params }),
installStripeTeam: async (params: InstallStripeParamsUnion & { teamId: number }) =>
installStripeTeam({ page: store.page, ...params }),
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
debug: async (message: string | Record<string, JSONValue>) => {
await prisma.user.update({
@@ -908,18 +942,49 @@ export async function makePaymentUsingStripe(page: Page) {
await page.click('button:has-text("Pay now")');
}
export async function getPaymentCredential(page: Page) {
const installStripePersonal = async (params: InstallStripePersonalPramas) => {
const redirectUrl = `apps/installation/event-types?slug=stripe`;
const buttonSelector = '[data-testid="install-app-button-personal"]';
await installStripe({ redirectUrl, buttonSelector, ...params });
};
const installStripeTeam = async ({ teamId, ...params }: InstallStripeTeamPramas) => {
const redirectUrl = `apps/installation/event-types?slug=stripe&teamId=${teamId}`;
const buttonSelector = `[data-testid="install-app-button-team${teamId}"]`;
await installStripe({ redirectUrl, buttonSelector, ...params });
};
const installStripe = async ({
page,
skip,
eventTypeIds,
redirectUrl,
buttonSelector,
}: InstallStripeParams) => {
await page.goto("/apps/stripe");
/** We start the Stripe flow */
await Promise.all([
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
page.click('[data-testid="install-app-button"]'),
]);
await page.click('[data-testid="install-app-button"]');
await page.click(buttonSelector);
await Promise.all([
page.waitForURL("/apps/installed/payment?hl=stripe"),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
}
await page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*");
/** We skip filling Stripe forms (testing mode only) */
await page.click('[id="skip-account-app"]');
await page.waitForURL(redirectUrl);
if (skip) {
await page.click('[data-testid="set-up-later"]');
return;
}
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
await page.click(`[data-testid="save-event-types"]`);
for (let index = 0; index < eventTypeIds.length; index++) {
await page.locator('[data-testid="stripe-price-input"]').nth(index).fill(`1${index}`);
}
await page.click(`[data-testid="configure-step-save"]`);
await page.waitForURL(`event-types`);
for (let index = 0; index < eventTypeIds.length; index++) {
await page.goto(`event-types/${eventTypeIds[index]}?tabName=apps`);
await expect(page.getByTestId(`stripe-app-switch`)).toBeChecked();
await expect(page.getByTestId(`stripe-price-input`)).toHaveValue(`1${index}`);
}
};
+2 -2
View File
@@ -55,8 +55,8 @@ test.describe("hash my url", () => {
// Ensure that private URL is enabled after modifying the event type.
// Additionally, if the slug is changed, ensure that the private URL is updated accordingly.
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
await page.locator("[data-testid=event-title]").fill("somethingrandom");
await page.locator("[data-testid=event-slug]").fill("somethingrandom");
await page.locator("[data-testid=event-title]").first().fill("somethingrandom");
await page.locator("[data-testid=event-slug]").first().fill("somethingrandom");
await page.locator("[data-testid=update-eventtype]").click();
await page.getByTestId("toast-success").waitFor();
await page.waitForLoadState("networkidle");
+245 -29
View File
@@ -20,7 +20,7 @@ const IS_STRIPE_ENABLED = !!(
process.env.PAYMENT_FEE_PERCENTAGE
);
test.describe("Stripe integration", () => {
test.describe("Stripe integration skip true", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
@@ -28,22 +28,20 @@ test.describe("Stripe integration", () => {
test("Can add Stripe integration", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await expect(page.locator(`h3:has-text("Stripe")`)).toBeVisible();
await page.getByRole("list").getByRole("button").click();
await expect(page.getByRole("button", { name: "Remove App" })).toBeVisible();
});
});
test("when enabling Stripe, credentialId is included", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.setupEventWithPrice(eventType, "stripe");
@@ -68,7 +66,6 @@ test.describe("Stripe integration", () => {
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("when enabling Stripe, team credentialId is included", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@@ -85,25 +82,10 @@ test.describe("Stripe integration", () => {
});
await owner.apiLogin();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamEvent = await owner.getFirstTeamEvent(team.id);
await page.goto("/apps/stripe");
/** We start the Stripe flow */
await Promise.all([
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
page.click('[data-testid="install-app-button"]'),
page.click('[data-testid="anything else"]'),
]);
await Promise.all([
page.waitForURL("/apps/installed/payment?hl=stripe"),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
await owner.installStripeTeam({ skip: true, teamId: team.id });
await owner.setupEventWithPrice(teamEvent, "stripe");
// Need to wait for the DB to be updated with the metadata
@@ -126,14 +108,13 @@ test.describe("Stripe integration", () => {
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await user.bookAndPayEvent(eventType);
// success
@@ -146,7 +127,7 @@ test.describe("Stripe integration", () => {
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
// booking process without payment
@@ -170,7 +151,7 @@ test.describe("Stripe integration", () => {
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await user.bookAndPayEvent(eventType);
@@ -193,7 +174,7 @@ test.describe("Stripe integration", () => {
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await user.bookAndPayEvent(eventType);
@@ -213,7 +194,7 @@ test.describe("Stripe integration", () => {
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await user.bookAndPayEvent(eventType);
await user.confirmPendingPayment();
@@ -247,7 +228,7 @@ test.describe("Stripe integration", () => {
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
// Edit currency inside event type page
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
@@ -291,3 +272,238 @@ test.describe("Stripe integration", () => {
});
});
});
test.describe("Stripe integration with the new app install flow skip flase", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
test("when enabling Stripe, credentialId is included skip false", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed");
const eventTypes = await user.getUserEventsAsOwner();
const eventTypeIds = eventTypes.map((item) => item.id);
// await installStripe(page, "personal", false, eventTypeIds);
await user.installStripePersonal({ skip: false, eventTypeIds });
const eventTypeMetadatas = await prisma.eventType.findMany({
where: {
id: {
in: eventTypeIds,
},
},
select: {
metadata: true,
},
});
for (const eventTypeMetadata of eventTypeMetadatas) {
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
}
});
test("when enabling Stripe, team credentialId is included skip false", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
await owner.apiLogin();
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
await owner.installStripeTeam({ skip: false, teamId: team.id, eventTypeIds: [teamEvent.id] });
// Check event type metadata to see if credentialId is included
const eventTypeMetadata = await prisma.eventType.findFirst({
where: {
id: teamEvent.id,
},
select: {
metadata: true,
},
});
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("Can book a paid booking skip false", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] });
await user.bookAndPayEvent(eventType);
// success
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("Pending payment booking should not be confirmed by default skip false", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] });
// booking process without payment
await page.goto(`${user.username}/${eventType?.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
await page.fill('[name="email"]', "test@example.com");
await Promise.all([page.waitForURL("/payment/*"), page.press('[name="email"]', "Enter")]);
await page.goto(`/bookings/upcoming`);
await expect(page.getByText("Unconfirmed")).toBeVisible();
await expect(page.getByText("Pending payment").last()).toBeVisible();
});
test("Paid booking should be able to be rescheduled skip false", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] });
await user.bookAndPayEvent(eventType);
// Rescheduling the event
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
await selectFirstAvailableTimeSlotNextMonth(page);
await Promise.all([
page.waitForURL("/payment/*"),
page.click('[data-testid="confirm-reschedule-button"]'),
]);
await user.makePaymentUsingStripe();
});
test("Paid booking should be able to be cancelled skip false", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] });
await user.bookAndPayEvent(eventType);
await page.click('[data-testid="cancel"]');
await page.click('[data-testid="confirm_cancel"]');
await expect(await page.locator('[data-testid="cancelled-headline"]').first()).toBeVisible();
});
test.describe("When event is paid and confirmed skip false", () => {
let user: Awaited<ReturnType<Fixtures["users"]["create"]>>;
let eventType: Prisma.EventType;
test.beforeEach(async ({ page, users }) => {
user = await users.create();
eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/installed");
await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] });
await user.bookAndPayEvent(eventType);
await user.confirmPendingPayment();
});
test("Payment should confirm pending payment booking skip false", async ({ page, users }) => {
await page.goto("/bookings/upcoming");
const paidBadge = page.locator('[data-testid="paid_badge"]').first();
await expect(paidBadge).toBeVisible();
expect(await paidBadge.innerText()).toBe("Paid");
});
test("Paid and confirmed booking should be able to be rescheduled skip false", async ({
page,
users,
}) => {
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.click('[data-testid="confirm-reschedule-button"]');
await expect(page.getByText("This meeting is scheduled")).toBeVisible();
});
todo("Payment should trigger a BOOKING_PAID webhook");
});
test.describe("Change stripe presented currency skip false", () => {
test("Should be able to change currency skip false", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await page.goto("/apps/stripe");
/** We start the Stripe flow */
await page.click('[data-testid="install-app-button"]');
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*");
/** We skip filling Stripe forms (testing mode only) */
await page.click('[id="skip-account-app"]');
await page.waitForURL(`apps/installation/event-types?slug=stripe`);
await page.click(`[data-testid="select-event-type-${eventType.id}"]`);
await page.click(`[data-testid="save-event-types"]`);
await page.locator('[data-testid="stripe-price-input"]').fill(`200`);
// Select currency in dropdown
await page.getByTestId("stripe-currency-select").click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();
await page.click(`[data-testid="configure-step-save"]`);
await page.waitForURL(`event-types`);
// Book event
await page.goto(`${user.username}/${eventType?.slug}`);
// Confirm MXN currency it's displayed use expect
await expect(await page.getByText("MX$200.00")).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
await page.fill('[name="email"]', "stripe@example.com");
// Confirm booking
await page.click('[data-testid="confirm-book-button"]');
// wait for url to be payment
await page.waitForURL("/payment/*");
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
});
});
});
+4 -1
View File
@@ -13,7 +13,10 @@ testBothFutureAndLegacyRoutes.describe("user can login & logout succesfully", as
test.afterAll(async ({ users }) => {
await users.deleteAll();
});
test("login flow user & logout using dashboard", async ({ page, users }) => {
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.skip("login flow user & logout using dashboard", async ({ page, users }) => {
// log in trail user
await test.step("Log in", async () => {
const user = await users.create();
+6 -2
View File
@@ -99,7 +99,9 @@ test.describe("Update Profile", () => {
expect(await emailInputUpdated.inputValue()).toEqual(user.email);
});
test("Can update a users email (verification enabled)", async ({ page, users, prisma, features }) => {
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.skip("Can update a users email (verification enabled)", async ({ page, users, prisma, features }) => {
const emailVerificationEnabled = features.get("email-verification");
// eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test
if (!emailVerificationEnabled?.enabled) test.skip();
@@ -372,7 +374,9 @@ test.describe("Update Profile", () => {
expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(false);
});
test("Can resend verification link if the secondary email is unverified", async ({
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
// eslint-disable-next-line playwright/no-skipped-test
test.skip("Can resend verification link if the secondary email is unverified", async ({
page,
users,
prisma,
+2 -2
View File
@@ -119,7 +119,7 @@ test.describe("Reschedule Tests", async () => {
test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed");
const user = await users.create();
await user.apiLogin();
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
@@ -160,7 +160,7 @@ test.describe("Reschedule Tests", async () => {
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
const user = await users.create();
await user.apiLogin();
await user.getPaymentCredential();
await user.installStripePersonal({ skip: true });
await users.logout();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
@@ -687,6 +687,7 @@
"default_duration": "المدة الافتراضية",
"default_duration_no_options": "يرجى اختيار المدد المتاحة أولاً",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "الدقائق",
"round_robin": "الترتيب الدوري",
"round_robin_description": "نقل الاجتماعات بشكل دوري بين أعضاء الفريق المتعددين.",
@@ -655,6 +655,7 @@
"default_duration": "Výchozí doba trvání",
"default_duration_no_options": "Nejprve vyberte dostupné doby trvání",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "min",
"round_robin": "Plánování Round Robin",
"round_robin_description": "Schůzky v řadě mezi několika členy týmu.",
@@ -554,6 +554,7 @@
"default_duration": "Standard varighed",
"default_duration_no_options": "Vælg venligst tilgængelige varigheder først",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minutter",
"round_robin": "Round Robin",
"round_robin_description": "Cyklusmøder mellem flere teammedlemmer.",
+12 -2
View File
@@ -2263,7 +2263,7 @@
"create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!",
"other_payment_app_enabled": "You can only enable one payment app per event type",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted but their usernames would be changed to allow them to exist outside the organization</li><li>User event-types created after the user was in the organization will be deleted</li><li>Migrated user event-types will not be deleted</li></ul>",
"admin_delete_organization_title": "Delete {{organizationName}}?",
"published": "Published",
"unpublished": "Unpublished",
@@ -2385,6 +2385,9 @@
"add_email": "Add Email",
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
"confirm_email": "Confirm your email",
"scheduler_first_name": "The first name of the person booking",
"scheduler_last_name": "The last name of the person booking",
"organizer_first_name": "Your first name",
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
"send_event_details_to": "Send event details to",
"schedule_tz_without_end_date": "Schedule timezone without end date",
@@ -2405,6 +2408,13 @@
"disconnect_account_hint": "Disconnecting your connected account will change the way you log in. You will only be able to login to your account using email + password",
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
"make_a_call": "Make a Call",
"select_account_header": "Select Account",
"select_account_description": "Install {{appName}} on your personal account or on a team account.",
"select_event_types_header": "Select Event Types",
"select_event_types_description": "On which event type do you want to install {{appName}}?",
"configure_app_header": "Configure {{appName}}",
"configure_app_description": "Finalise the App setup. You can change these settings later.",
"already_installed": "already installed",
"ooo_reasons_unspecified": "Unspecified",
"ooo_reasons_vacation": "Vacation",
"ooo_reasons_travel": "Travel",
@@ -2442,7 +2452,7 @@
"lowest_rated_members": "Members with lowest rated meetings",
"csat_score": "CSAT Score",
"lockedSMS": "Locked SMS",
"signing_up_terms": "By proceeding, you agree to our <1>Terms</1> and <2>Privacy Policy</2>.",
"signing_up_terms": "By proceeding, you agree to our <0>Terms</0> and <1>Privacy Policy</1>.",
"always_show_x_days": "Always {{x}} days available",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
"updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later",
@@ -654,6 +654,7 @@
"default_duration": "Duración predeterminada",
"default_duration_no_options": "Seleccione primero las duraciones disponibles",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minutos",
"round_robin": "Petición Firmada por Turnos",
"round_robin_description": "Ciclo de reuniones entre varios miembros del equipo.",
@@ -655,6 +655,7 @@
"default_duration": "デフォルトの時間",
"default_duration_no_options": "最初に空いている時間を選択してください",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "分",
"round_robin": "ラウンドロビン",
"round_robin_description": "複数のチームメンバー間のミーティングを定期化する。",
@@ -655,6 +655,7 @@
"default_duration": "기본 기간",
"default_duration_no_options": "사용 가능한 기간을 먼저 선택하세요",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "분",
"round_robin": "라운드 로빈",
"round_robin_description": "여러 팀 구성원 간의 주기적인 회의",
@@ -655,6 +655,7 @@
"default_duration": "Standaardduur",
"default_duration_no_options": "Kies eerst de beschikbare duur",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minuten",
"round_robin": "Round Robin",
"round_robin_description": "Afspraken wisselen tussen meerdere teamleden.",
@@ -545,6 +545,7 @@
"default_duration": "Standard varighet",
"default_duration_no_options": "Vennligst velg tilgjengelige varigheter først",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minutter",
"round_robin": "Round Robin",
"round_robin_description": "Varier møter mellom flere teammedlemmer.",
@@ -964,7 +965,7 @@
"how_long_before": "Hvor lenge før hendelsen starter?",
"day_timeUnit": "dager",
"hour_timeUnit": "timer",
"minute_timeUnit": "min",
"minute_timeUnit": "minutter",
"new_workflow_heading": "Opprett din første arbeidsflyt",
"new_workflow_description": "Arbeidsflyter lar deg automatisere sending av påminnelser og varsler.",
"active_on": "Aktiv på",
@@ -655,6 +655,7 @@
"default_duration": "Domyślny czas trwania",
"default_duration_no_options": "Proszę najpierw wybrać dostępne czasy trwania",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minuty",
"round_robin": "Round Robin",
"round_robin_description": "Cykl spotkań między wieloma członkami zespołu.",
@@ -655,6 +655,7 @@
"default_duration": "Durată implicită",
"default_duration_no_options": "Alege mai întâi duratele disponibile",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minute",
"round_robin": "Round Robin",
"round_robin_description": "Întâlniri ciclice între mai mulţi membri ai echipei.",
@@ -656,6 +656,7 @@
"default_duration": "Продолжительность по умолчанию",
"default_duration_no_options": "Пожалуйста, выберите сначала доступную продолжительность",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "мин.",
"round_robin": "По кругу",
"round_robin_description": "Цикл встреч между несколькими членами команды.",
@@ -655,6 +655,7 @@
"default_duration": "Podrazumevano trajanje",
"default_duration_no_options": "Najpre izaberite dostupna trajanja",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minuti",
"round_robin": "Round Robin",
"round_robin_description": "Ciklusirajte sastanke između više članova tima.",
@@ -655,6 +655,7 @@
"default_duration": "Standardvaraktighet",
"default_duration_no_options": "Välj först tillgänglig varaktighet",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Minuter",
"round_robin": "Tur och ordning",
"round_robin_description": "Variera möten mellan olika teammedlemmar.",
@@ -655,6 +655,7 @@
"default_duration": "Varsayılan süre",
"default_duration_no_options": "Lütfen önce uygun süreleri seçin",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Dakika",
"round_robin": "Round Robin",
"round_robin_description": "Toplantıları birden fazla ekip üyesi arasında döndürün.",
@@ -687,6 +687,7 @@
"default_duration": "Тривалість за замовчуванням",
"default_duration_no_options": "Спочатку виберіть доступні варіанти тривалості",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Хвилини",
"round_robin": "Ротація",
"round_robin_description": "Кілька учасників команди призначаються для нарад циклічно й по черзі.",
@@ -656,6 +656,7 @@
"default_duration": "Khoảng thời gian mặc định",
"default_duration_no_options": "Vui lòng chọn trước tiên những khoảng thời gian khả dụng",
"multiple_duration_mins": "{{count}}$t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "Phút",
"round_robin": "Round Robin",
"round_robin_description": "Luân phiên những cuộc họp giữa các thành viên trong nhóm.",
@@ -683,6 +683,7 @@
"default_duration": "默认时长",
"default_duration_no_options": "请先选择可用时长",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "分钟",
"round_robin": "轮流模式",
"round_robin_description": "和多个团队成员之间轮流举行会议。",
@@ -661,6 +661,7 @@
"default_duration": "預設時間長度",
"default_duration_no_options": "請先選擇可預約的時間長度",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"minutes": "分鐘",
"round_robin": "循環制",
"round_robin_description": "與多位團隊成員進行週期會議。",
+8
View File
@@ -328,6 +328,14 @@ function generateFiles() {
lazyImport: true,
})
);
browserOutput.push(
...getExportedObject("EventTypeSettingsMap", {
importConfig: {
fileToBeImported: "components/EventTypeAppSettingsInterface.tsx",
},
lazyImport: true,
})
);
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
@@ -8,7 +8,7 @@ export function DynamicComponent<T extends Record<string, React.ComponentType<an
const dirName = slug === "stripe" ? "stripepayment" : slug;
// There can be apps with no matching component
if (!componentMap[slug]) return null;
if (!componentMap[dirName]) return null;
const Component = componentMap[dirName];
@@ -0,0 +1,9 @@
import { EventTypeSettingsMap } from "@calcom/app-store/apps.browser.generated";
import type { EventTypeAppSettingsComponentProps } from "../types";
import { DynamicComponent } from "./DynamicComponent";
export const EventTypeAppSettings = (props: EventTypeAppSettingsComponentProps) => {
const { slug, ...rest } = props;
return <DynamicComponent slug={slug} componentMap={EventTypeSettingsMap} {...rest} />;
};
@@ -7,6 +7,9 @@ export function decodeOAuthState(req: NextApiRequest) {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
if (state.appOnboardingRedirectUrl) {
state.appOnboardingRedirectUrl = decodeURIComponent(state.appOnboardingRedirectUrl);
}
return state;
}
@@ -0,0 +1,57 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
const setDefaultConferencingApp = async (userId: number, appSlug: string) => {
const eventTypes = await getBulkEventTypes(userId);
const eventTypeIds = eventTypes.eventTypes.map((item) => item.id);
const foundApp = getAppFromSlug(appSlug);
const appType = foundApp?.appData?.location?.type;
if (!appType) {
return;
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
credentials: true,
},
});
const currentMetadata = userMetadata.parse(user?.metadata);
const credentialId = user?.credentials.find((item) => item.appId == appSlug)?.id;
//Update the default conferencing app for the user.
await prisma.user.update({
where: {
id: userId,
},
data: {
metadata: {
...currentMetadata,
defaultConferencingApp: {
appSlug,
},
},
},
});
await prisma.eventType.updateMany({
where: {
id: {
in: eventTypeIds,
},
userId,
},
data: {
locations: [{ type: appType, credentialId }] as LocationObject[],
},
});
};
export default setDefaultConferencingApp;
+11 -1
View File
@@ -44,13 +44,22 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
const mutation = useMutation<
AddAppMutationData,
Error,
{ type?: App["type"]; variant?: string; slug?: string; isOmniInstall?: boolean; teamId?: number } | ""
| {
type?: App["type"];
variant?: string;
slug?: string;
isOmniInstall?: boolean;
teamId?: number;
defaultInstall?: boolean;
}
| ""
>({
...options,
mutationFn: async (variables) => {
let type: string | null | undefined;
let isOmniInstall;
const teamId = variables && variables.teamId ? variables.teamId : undefined;
const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined;
if (variables === "") {
type = _type;
} else {
@@ -76,6 +85,7 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
fromApp: true,
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
...(teamId && { teamId }),
...(defaultInstall && { defaultInstall }),
};
const stateStr = encodeURIComponent(JSON.stringify(state));
@@ -1,20 +1,15 @@
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
import type { appDataSchema } from "../zod";
import { PaypalPaymentOptions as paymentOptions } from "../zod";
type Option = { value: string; label: string };
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
app,
@@ -22,38 +17,19 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventTypeFormMetadata,
}) {
const searchParams = useSearchParams();
const { t } = useLocale();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
// make sure a currency is selected
useEffect(() => {
if (!currency && requirePayment) {
setAppData("currency", selectedCurrency.value);
}
}, [currency, selectedCurrency, setAppData, requirePayment]);
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
@@ -65,77 +41,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
description={<>Add bitcoin lightning payments to your events</>}
disableSwitch={shouldDisableSwitch}
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
label="Price"
labelSrOnly
addOnLeading={<SatSymbol className="h-4 w-4" />}
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value));
if (currency) {
setAppData("currency", currency);
}
}}
value={price && price > 0 ? price : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
@@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
import { PaypalPaymentOptions as paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
eventType,
getAppData,
setAppData,
}) => {
const { t } = useLocale();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const [requirePayment] = useState(getAppData("enabled"));
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
// make sure a currency is selected
useEffect(() => {
if (!currency && requirePayment) {
setAppData("currency", selectedCurrency.value);
}
}, [currency, selectedCurrency, setAppData, requirePayment]);
return (
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
label="Price"
labelSrOnly
addOnLeading={<SatSymbol className="h-4 w-4" />}
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value));
if (currency) {
setAppData("currency", currency);
}
}}
value={price && price > 0 ? price : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
);
};
export default EventTypeAppSettingsInterface;
+2 -1
View File
@@ -14,5 +14,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-app-card",
"dirName": "alby"
"dirName": "alby",
"isOAuth": false
}
+2 -1
View File
@@ -12,5 +12,6 @@
"description": "The joyful productivity app\r\r",
"__createdUsingCli": true,
"dependencies": ["google-calendar"],
"dirName": "amie"
"dirName": "amie",
"isOAuth": false
}
@@ -17,6 +17,7 @@ export const metadata = {
url: "https://cal.com/",
email: "help@cal.com",
dirName: "applecalendar",
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -42,3 +42,16 @@ export const EventTypeAddonMap = {
import("./templates/event-type-app-card/components/EventTypeAppCardInterface")
),
};
export const EventTypeSettingsMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")),
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")),
paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")),
plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")),
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")),
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")),
};
+2 -1
View File
@@ -20,5 +20,6 @@
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?around.co\\/[a-zA-Z0-9]*",
"organizerInputPlaceholder": "https://www.around.co/rick"
}
}
},
"isOAuth": false
}
@@ -5,6 +5,7 @@ import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -86,5 +87,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const state = decodeOAuthState(req);
if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") {
return res.redirect(state.appOnboardingRedirectUrl);
}
res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }));
}
@@ -1,76 +1,29 @@
import { useState, useEffect } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { trpc } from "@calcom/trpc/react";
import { Select } from "@calcom/ui";
import useIsAppEnabled from "../../_utils/useIsAppEnabled";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
const [enabled, setEnabled] = useState(getAppData("enabled"));
const [projects, setProjects] = useState();
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
const { data } = trpc.viewer.appBasecamp3.projects.useQuery();
const setProject = trpc.viewer.appBasecamp3.projectMutation.useMutation();
useEffect(
function refactorMeWithoutEffect() {
setSelectedProject({
value: data?.projects.currentProject,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name,
});
setProjects(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?.projects?.map((project: any) => {
return {
value: project.id,
label: project.name,
};
})
);
},
[data]
);
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
app={app}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
<div className="mt-2 text-sm">
<div className="flex gap-3">
<div className="items-center">
<p className="py-2">Link a Basecamp project to this event:</p>
</div>
<Select
placeholder="Select project"
options={projects}
isLoading={!projects}
className="md:min-w-[120px]"
onChange={(project) => {
if (project) {
setProject.mutate({ projectId: project?.value.toString() });
setSelectedProject(project);
}
}}
value={selectedProject}
/>
</div>
<div className="mt-2">
Please note that as of now you can only link <span className="italic">one</span> of your projects to
cal.com
</div>
</div>
<EventTypeAppSettingsInterface
slug={app.slug}
eventType={eventType}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
@@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { trpc } from "@calcom/trpc/react";
import { Select } from "@calcom/ui";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({}) => {
const [projects, setProjects] = useState();
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
const { data } = trpc.viewer.appBasecamp3.projects.useQuery();
const setProject = trpc.viewer.appBasecamp3.projectMutation.useMutation();
useEffect(
function refactorMeWithoutEffect() {
setSelectedProject({
value: data?.projects.currentProject,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name,
});
setProjects(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?.projects?.map((project: any) => {
return {
value: project.id,
label: project.name,
};
})
);
},
[data]
);
return (
<div className="mt-2 text-sm">
<div className="flex gap-3">
<div className="items-center">
<p className="py-2">Link a Basecamp project to this event:</p>
</div>
<Select
placeholder="Select project"
options={projects}
isLoading={!projects}
className="md:min-w-[120px]"
onChange={(project) => {
if (project) {
setProject.mutate({ projectId: project?.value.toString() });
setSelectedProject(project);
}
}}
value={selectedProject}
/>
</div>
<div className="mt-2">
Please note that as of now you can only link <span className="italic">one</span> of your projects to
cal.com
</div>
</div>
);
};
export default EventTypeAppSettingsInterface;
+2 -1
View File
@@ -13,5 +13,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-app-card",
"dirName": "basecamp3"
"dirName": "basecamp3",
"isOAuth": true
}
+1
View File
@@ -14,6 +14,7 @@
"__createdUsingCli": true,
"__template": "basic",
"dirName": "cal-ai",
"isOAuth": false,
"paid": {
"priceInUsd": 8,
"priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP",
@@ -17,6 +17,7 @@ export const metadata = {
url: "https://cal.com/",
email: "ali@cal.com",
dirName: "caldavcalendar",
isOAuth: false,
} as AppMeta;
export default metadata;
+2 -1
View File
@@ -19,5 +19,6 @@
"organizerInputPlaceholder": "https://party.campfire.to/your-team",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?party.campfire.to\\/[a-zA-Z0-9]*"
}
}
},
"isOAuth": false
}
+2 -1
View File
@@ -11,5 +11,6 @@
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.",
"__createdUsingCli": true
"__createdUsingCli": true,
"isOAuth": false
}
+2 -1
View File
@@ -13,5 +13,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app",
"dependencies": ["google-calendar"]
"dependencies": ["google-calendar"],
"isOAuth": false
}
@@ -26,6 +26,7 @@ export const metadata = {
},
key: { apikey: process.env.DAILY_API_KEY },
dirName: "dailyvideo",
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -260,10 +260,10 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const roomId = res.id;
const allTranscripts = await fetcher(`/transcript?roomId=${roomId}`).then(getTranscripts.parse);
if (!allTranscripts.data.length) return [];
const allTranscriptsIds = allTranscripts.data.map((transcript) => transcript.transcriptId);
const allTranscriptsAccessLink = await processTranscriptsInBatches(allTranscriptsIds);
const accessLinks = await Promise.all(allTranscriptsAccessLink);
return Promise.resolve(accessLinks);
+2 -1
View File
@@ -21,5 +21,6 @@
"description": "Copy your server invite link and start scheduling calls in Discord! Discord is a VoIP and instant messaging social platform. Users have the ability to communicate with voice calls, video calls, text messaging, media and files in private chats or as part of communities.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
"__template": "event-type-location-video-static",
"isOAuth": false
}
+2 -1
View File
@@ -22,5 +22,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static",
"dirName": "eightxeight"
"dirName": "eightxeight",
"isOAuth": false
}
+2 -1
View File
@@ -22,5 +22,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static",
"dirName": "element-call"
"dirName": "element-call",
"isOAuth": false
}
@@ -18,6 +18,7 @@ export const metadata = {
url: "https://cal.com/",
email: "help@cal.com",
dirName: "exchange2013calendar",
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -18,6 +18,7 @@ export const metadata = {
url: "https://cal.com/",
email: "help@cal.com",
dirName: "exchange2016calendar",
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -12,5 +12,6 @@
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Fetch Microsoft Exchange calendars and availabilities using Exchange Web Services (EWS).",
"__createdUsingCli": true
"__createdUsingCli": true,
"isOAuth": false
}
+2 -1
View File
@@ -21,5 +21,6 @@
"urlRegExp": "^https?:\\/\\/facetime\\.apple\\.com\\/join.+$"
}
},
"isTemplate": false
"isTemplate": false,
"isOAuth": false
}
@@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
@@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
dataTestid={app.slug}
name="Tracking ID"
value={trackingId}
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
@@ -0,0 +1,25 @@
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
getAppData,
setAppData,
disabled,
slug,
}) => {
const trackingId = getAppData("trackingId");
return (
<TextField
dataTestid={slug}
name="Tracking ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
/>
);
};
export default EventTypeAppSettingsInterface;
+2 -1
View File
@@ -23,5 +23,6 @@
}
},
"description": "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.",
"__createdUsingCli": true
"__createdUsingCli": true,
"isOAuth": false
}
@@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
@@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
dataTestid={app.slug}
name="Tracking ID"
value={trackingId}
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
@@ -0,0 +1,23 @@
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
getAppData,
setAppData,
disabled,
}) => {
const trackingId = getAppData("trackingId");
return (
<TextField
name="Tracking ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
/>
);
};
export default EventTypeAppSettingsInterface;
+2 -1
View File
@@ -24,5 +24,6 @@
]
}
},
"__createdUsingCli": true
"__createdUsingCli": true,
"isOAuth": false
}
+1
View File
@@ -17,6 +17,7 @@ export const metadata = {
extendsFeature: "EventType",
email: "help@cal.com",
dirName: "giphy",
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -1,15 +1,14 @@
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const thankYouPage = getAppData("thankYouPage");
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
const { t } = useLocale();
@@ -23,15 +22,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={showGifSelection}
teamId={eventType.team?.id || undefined}>
{showGifSelection && (
<SelectGifInput
defaultValue={thankYouPage}
disabled={disabled}
onChange={(url: string) => {
setAppData("thankYouPage", url);
}}
/>
)}
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
@@ -0,0 +1,23 @@
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import SelectGifInput from "./SelectGifInput";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
getAppData,
setAppData,
disabled,
}) => {
const thankYouPage = getAppData("thankYouPage");
return (
<SelectGifInput
defaultValue={thankYouPage}
disabled={disabled}
onChange={(url: string) => {
setAppData("thankYouPage", url);
}}
/>
);
};
export default EventTypeAppSettingsInterface;
@@ -18,6 +18,7 @@ export const metadata = {
url: "https://cal.com/",
email: "help@cal.com",
dirName: "googlecalendar",
isOAuth: true,
} as AppMeta;
export default metadata;
@@ -27,6 +27,7 @@ export const metadata = {
},
dirName: "googlevideo",
dependencies: ["google-calendar"],
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
@@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
dataTestid={app.slug}
name="Tracking ID"
value={trackingId}
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
@@ -0,0 +1,24 @@
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { TextField } from "@calcom/ui";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
getAppData,
setAppData,
disabled,
}) => {
const trackingId = getAppData("trackingId");
return (
<TextField
name="Tracking ID"
data-testid="gtm-tracking-id-input"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
/>
);
};
export default EventTypeAppSettingsInterface;
+2 -1
View File
@@ -21,5 +21,6 @@
},
"isTemplate": false,
"__createdUsingCli": true,
"__template": "booking-pages-tag"
"__template": "booking-pages-tag",
"isOAuth": false
}
+1
View File
@@ -17,6 +17,7 @@ export const metadata = {
title: "HubSpot CRM",
email: "help@cal.com",
dirName: "hubspot",
isOAuth: true,
} as AppMeta;
export default metadata;
@@ -28,6 +28,7 @@ export const metadata = {
key: { apikey: randomString(12) },
dirName: "huddle01video",
concurrentMeetings: true,
isOAuth: false,
} as AppMeta;
export default metadata;
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
const log = logger.getSubLogger({ prefix: [`[[intercom/api/callback]`] });
@@ -85,6 +86,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
req
);
const state = decodeOAuthState(req);
if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") {
return res.redirect(state.appOnboardingRedirectUrl);
}
res.redirect(
getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/automation?hl=intercom`) ??
getInstalledAppPath({ variant: "automation", slug: "intercom" })
+2 -1
View File
@@ -13,5 +13,6 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "intercom"
"dirName": "intercom",
"isOAuth": true
}
+6
View File
@@ -5,8 +5,11 @@ import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import setDefaultConferencingApp from "../../_utils/setDefaultConferencingApp";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const state = decodeOAuthState(req);
const userId = req.session?.user.id;
if (!userId) {
return res.status(404).json({ message: "No user found" });
@@ -53,6 +56,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
{ access_token: responseBody.access_token },
req
);
if (state?.defaultInstall) {
setDefaultConferencingApp(userId, "jelly");
}
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "jelly" }));
}
@@ -25,6 +25,7 @@ export const metadata = {
},
dirName: "jitsivideo",
concurrentMeetings: true,
isOAuth: false,
} as AppMeta;
export default metadata;

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