Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d3a84e27 | ||
|
|
33c1d49c52 | ||
|
|
889ff177c1 | ||
|
|
d8a6c375d0 | ||
|
|
655127d155 | ||
|
|
17c4a10e33 | ||
|
|
30f9f19a9f | ||
|
|
be7d45afe2 | ||
|
|
d2bec46d89 | ||
|
|
b90500778e | ||
|
|
bddc235c3c | ||
|
|
360b729d08 | ||
|
|
5d325495a9 | ||
|
|
dead2b843a | ||
|
|
1174cb0d8e | ||
|
|
58e6f18594 | ||
|
|
ffe6a46e66 | ||
|
|
37c02572de | ||
|
|
b82b0a33a4 | ||
|
|
b1bbbcee90 | ||
|
|
1a381695e5 | ||
|
|
8831ff13c7 | ||
|
|
64845c670d | ||
|
|
ba06c6c26a |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}`] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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") && (
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.1.3",
|
||||
"version": "4.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "與多位團隊成員進行週期會議。",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -14,5 +14,6 @@
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "alby"
|
||||
"dirName": "alby",
|
||||
"isOAuth": false
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -13,5 +13,6 @@
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "basecamp3"
|
||||
"dirName": "basecamp3",
|
||||
"isOAuth": true
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
"organizerInputPlaceholder": "https://party.campfire.to/your-team",
|
||||
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?party.campfire.to\\/[a-zA-Z0-9]*"
|
||||
}
|
||||
}
|
||||
},
|
||||
"isOAuth": false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-location-video-static",
|
||||
"dirName": "eightxeight"
|
||||
"dirName": "eightxeight",
|
||||
"isOAuth": false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -24,5 +24,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"__createdUsingCli": true
|
||||
"__createdUsingCli": true,
|
||||
"isOAuth": false
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,5 +21,6 @@
|
||||
},
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "booking-pages-tag"
|
||||
"__template": "booking-pages-tag",
|
||||
"isOAuth": false
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"dirName": "intercom"
|
||||
"dirName": "intercom",
|
||||
"isOAuth": true
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user