Feat: v2 App Store / Installed Apps (#4220)

* First commit

* Additional changes to meet designs

* Fixing app details disconnect button
App Store category filtering fixed

* Further changes for the fix

* Progress on various fronts

* Fixing e2e tests

* second try fixing e2e tests

* Yet again trying to fix e2e tests

* Missing action for non-credential flow

* Adding missing Add button

* Final tweaks and fixes

* Missing button in empty screen

* fix border radius

* EmptyScreen for calendar

* Dynamic installed app return page with highlights + other fixes

* Removing unneeded code

* App Store V2 Improvements over feat/v2-installed-apps (#4379)

* Removing unneeded text

* Fixing app details disconnect button and number of apps (#4305)

* V2 Settings Shell - Add Teams Section & UI fixes (#4347)

* Add teams to sidebar and fix UI

* Clean up

* V2 Multi-select (Team Select) (#4324)

* --init

* design improved

* further fine tuning

* more fixes

* removed extra JSX tag

* added story

* NIT

* revert to use of CheckedTeamSelect

* Removes comments

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: toggle alligment (#4361)

* fix: add checked tranform for switch (#4357)

* fixed input size on mobile, fixed settings (#4360)

* fixing type errors

* Mobile fixes

* Tests fixes

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>

* Feat/team owner booking (#3999)

* WIP: testing queries

* feat: add badge

* fix: get only id

* refactor: get bookings query

* WIP: display attendees added

* fix: add type

* Adds skeleton loader to workflows (#4402)

* fix event types skeleton loader

* fix margin in event types

* fix skeleton loader in bookings

* add skeleton loader for workflow list

* add skeleton loader for editing page

* fix border radius of skeleton loader

* fix mobile view workflow list

* add mobile view for skeleton list

* make multi select full with in mobile view

* mobile view for edit skeleton loader

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

* V2 settings teams (Profil, Members, Appearance View) (#4350)

* add team profile

* first version for team members page

* finish up design of member list item

* fix dialog buttons

* add missing seats and upgrading information

* add v2 dialog for changing role

* finish basic version of member's schedule

* remove modalContainer

* design fixes team profile page

* show only team info to non admins

* allow all member to check availabilities

* make time available heading sticky

* add dropdown for mobile view

* create team appearance view

* finish appearance page

* use settings layout and add danger zone for member

* add fallback logo

* Add teams to sidebar and fix UI

* add team invitations

* Clean up

* code clean up

* add impersontation and disable autofocus on calendar

* improve team info

* refactor teaminvitelist code and fix leaving a team

* add team pages to settings shell

* add link to create new team

* small fixes

* clean up comments

* V2 Multi-select (Team Select) (#4324)

* --init

* design improved

* further fine tuning

* more fixes

* removed extra JSX tag

* added story

* NIT

* revert to use of CheckedTeamSelect

* Removes comments

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: toggle alligment (#4361)

* fix: add checked tranform for switch (#4357)

* fixed input size on mobile, fixed settings (#4360)

* fix image uploader button in safari

* code clean up

* fixing type errors

* Moved v2 team components to features

Adds deprecation notices

* Update SettingsLayout.tsx

* Migrated to features and build fixes

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>

* add rainbowkit form to v2 event-types (#4349)

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

* v1.9.5

* Fix/daterangepicker css (#4418)

* added styles from external stylesheet

* fixed a border color

* codacy improvements

* more codacy fix

* codacy why u be so dumb

* dynamic import rainbow (only if installed) (#4409)

* Tidyup app cards (#4428)

* Moving API to trpc query

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: hexcowboy <accounts@cowboy.dev>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Abstracting one level the redirectUrl for apps

* Moving enum to a better/working place

* Another try

* Missing code

* Correcting categories look in apps page

* ui tweaks

* Breadcrumb wrong font-weight

* Toast is v1 component and copy updated

* Fix TS error

* Fix TS error

* Update AppsLayout.tsx

* Update InstalledAppsLayout.tsx

* translations and disconnect button with icon

* Reverting file

* Test e2e fix on app-store

* Missing testid in button

* Fix to click categories that are visible

* Going directly to category page as a patch

* Fixing stripe e2e test

* Disconnect is now empty with icon

* Disabled stripe test for now as the navigation is completely changed

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: hexcowboy <accounts@cowboy.dev>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Leo Giovanetti
2022-09-15 19:53:09 +00:00
committed by GitHub
co-authored by Peer Richelsen Syed Ali Shahbaz Udit Takkar CarinaWolli zomars kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Joe Au-Yeung Joe Au-Yeung kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Carina Wollendorfer hexcowboy sean-brydon kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Hariom Balhara Alex van Andel
parent 1a776a7320
commit d55ffe8d9d
68 changed files with 1284 additions and 231 deletions
+35 -38
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import React, { useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
@@ -13,6 +14,8 @@ import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import Badge from "@calcom/ui/v2/core/Badge";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
const Component = ({
name,
type,
@@ -32,8 +35,15 @@ const Component = ({
isProOnly,
}: Parameters<typeof App>[0]) => {
const { t } = useLocale();
const router = useRouter();
const { data: user } = trpc.useQuery(["viewer.me"]);
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
router.replace("/apps/installed");
};
const mutation = useAddAppMutation(null, {
onSuccess: () => {
showToast("App successfully installed", "success");
@@ -48,34 +58,14 @@ const Component = ({
currency: "USD",
useGrouping: false,
}).format(price);
const [installedAppCount, setInstalledAppCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function getInstalledApp(appCredentialType: string) {
const queryParam = new URLSearchParams();
queryParam.set("app-credential-type", appCredentialType);
try {
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((data) => {
setIsLoading(false);
return data;
});
if (result.status === 200) {
const res = await result.json();
setInstalledAppCount(res.count);
}
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}
getInstalledApp(type);
}, [type]);
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
onSuccess(data) {
setExistingCredentials(data);
},
});
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
return (
@@ -100,12 +90,13 @@ const Component = ({
</div>
<div className="mt-4 sm:mt-0 sm:text-right">
{!isLoading ? (
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
{!appCredentials.isLoading ? (
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
{installedAppCount > 0
? t("active_install", { count: installedAppCount })
{existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length })
: t("globally_install")}
</Button>
{!isGlobal && (
@@ -130,10 +121,16 @@ const Component = ({
/>
)}
</div>
) : installedAppCount > 0 ? (
<Button color="secondary" disabled title="App already installed">
{t("installed")}
</Button>
) : existingCredentials.length > 0 ? (
<DisconnectIntegration
id={existingCredentials[0]}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
) : (
<InstallAppButton
type={type}
@@ -154,7 +151,7 @@ const Component = ({
);
}}
/>
)
))
) : (
<SkeletonButton width="24" height="10" />
)}
@@ -0,0 +1,94 @@
import React from "react";
import Select from "react-select";
import { OptionProps } from "react-select";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui/Icon";
import { QueryCell } from "@lib/QueryCell";
interface AdditionalCalendarSelectorProps {
isLoading?: boolean;
}
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App["type"] }>) => {
const { t } = useLocale();
const { data } = optionProps;
return data.slug !== "add-new" ? (
<InstallAppButton
type={data.type}
render={(installProps) => {
return (
<Button {...installProps} className="w-full" color="minimal">
{data.image && (
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
)}
<p>{data.label}</p>
</Button>
);
}}
/>
) : (
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
<p>{t("add_new_calendar")}...</p>
</Button>
);
};
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
return (
<QueryCell
query={query}
success={({ data }) => {
const options = data.items.map((item) => ({
label: item.name,
slug: item.slug,
image: item.logo,
type: item.type,
}));
options.push({
label: "Add new calendars",
slug: "add-new",
image: "",
type: "new_other",
});
return (
<Select
name="additionalCalendar"
placeholder={t("install_another")}
options={options}
styles={{
placeholder: (defaultStyles) => {
return {
...defaultStyles,
color: "#3E3E3E",
marginLeft: "3px",
};
},
control: (defaultStyles) => {
return {
...defaultStyles,
borderRadius: "6px",
};
},
}}
isSearchable={false}
className="min-w-52 block w-full flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
isLoading={isLoading}
components={{ Option: ImageOption }}
/>
);
}}
/>
);
};
export default AdditionalCalendarSelector;
+36 -42
View File
@@ -1,22 +1,26 @@
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import { App as AppType } from "@calcom/types/App";
import Badge from "@calcom/ui/Badge";
import { Icon } from "@calcom/ui/Icon";
import { showToast } from "@calcom/ui/v2";
import { Button, SkeletonButton, Shell } from "@calcom/ui/v2";
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
const Component = ({
name,
type,
logo,
slug,
variant,
body,
categories,
author,
@@ -35,13 +39,14 @@ const Component = ({
const { t } = useLocale();
const { data: user } = trpc.useQuery(["viewer.me"]);
const hasImages = images && images.length > 0;
const router = useRouter();
const mutation = useAddAppMutation(null, {
onSuccess: () => {
showToast("App successfully installed", "success");
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
@@ -50,34 +55,14 @@ const Component = ({
currency: "USD",
useGrouping: false,
}).format(price);
const [installedAppCount, setInstalledAppCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function getInstalledApp(appCredentialType: string) {
const queryParam = new URLSearchParams();
queryParam.set("app-credential-type", appCredentialType);
try {
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((data) => {
setIsLoading(false);
return data;
});
if (result.status === 200) {
const res = await result.json();
setInstalledAppCount(res.count);
}
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}
getInstalledApp(type);
}, [type]);
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
onSuccess(data) {
setExistingCredentials(data);
},
});
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
return (
@@ -125,11 +110,14 @@ const Component = ({
</h2>
</header>
</div>
{!isLoading ? (
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
{!appCredentials.isLoading ? (
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
{installedAppCount > 0 ? t("active_install", { count: installedAppCount }) : t("default")}
{existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length })
: t("default")}
</Button>
{!isGlobal && (
<InstallAppButton
@@ -139,7 +127,7 @@ const Component = ({
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type });
mutation.mutate({ type, variant, slug });
},
loading: mutation.isLoading,
};
@@ -160,10 +148,14 @@ const Component = ({
/>
)}
</div>
) : installedAppCount > 0 ? (
<Button color="secondary" disabled title="App already installed">
{t("installed")}
</Button>
) : existingCredentials.length > 0 ? (
<DisconnectIntegration
label={t("disconnect")}
credentialId={existingCredentials[0]}
onSuccess={() => {
router.replace("/apps/installed");
}}
/>
) : (
<InstallAppButton
type={type}
@@ -172,7 +164,7 @@ const Component = ({
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type });
mutation.mutate({ type, variant, slug });
},
loading: mutation.isLoading,
};
@@ -190,7 +182,7 @@ const Component = ({
);
}}
/>
)
))
) : (
<SkeletonButton className="h-10 w-24" />
)}
@@ -302,6 +294,8 @@ export default function App(props: {
type: AppType["type"];
isGlobal?: AppType["isGlobal"];
logo: string;
slug: string;
variant: string;
body: React.ReactNode;
categories: string[];
author: string;
@@ -0,0 +1,325 @@
import { Fragment } from "react";
import { useMutation } from "react-query";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { Alert, EmptyScreen } from "@calcom/ui/v2";
import { List } from "@calcom/ui/v2/core/List";
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
import Switch from "@calcom/ui/v2/core/Switch";
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
import { QueryCell } from "@lib/QueryCell";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import AdditionalCalendarSelector from "@components/v2/apps/AdditionalCalendarSelector";
import DestinationCalendarSelector from "@components/v2/apps/DestinationCalendarSelector";
import IntegrationListItem from "@components/v2/apps/IntegrationListItem";
type Props = {
onChanged: () => unknown | Promise<unknown>;
fromOnboarding?: boolean;
destinationCalendarId?: string;
};
function CalendarSwitch(props: {
type: string;
externalId: string;
title: string;
defaultSelected: boolean;
destination?: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const mutation = useMutation<
unknown,
unknown,
{
isOn: boolean;
}
>(
async ({ isOn }) => {
const body = {
integration: props.type,
externalId: props.externalId,
};
if (isOn) {
const res = await fetch("/api/availability/calendar", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
} else {
const res = await fetch("/api/availability/calendar", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Something went wrong");
}
}
},
{
async onSettled() {
await utils.invalidateQueries(["viewer.integrations"]);
},
onError() {
showToast(`Something went wrong when toggling "${props.title}""`, "error");
},
}
);
return (
<div className="flex py-1">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
{!!props.destination && (
<span className="ml-4 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800">
<Icon.FiArrowLeft className="h-4 w-4" />
{t("adding_events_to")}
</span>
)}
</div>
);
}
function CalendarList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
return (
<QueryCell
query={query}
success={({ data }) => (
<List>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
actions={
<InstallAppButton
type={item.type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps}>
{t("connect")}
</Button>
)}
onChanged={() => props.onChanged()}
/>
}
/>
))}
</List>
)}
/>
);
}
function ConnectedCalendarsList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
const { fromOnboarding } = props;
return (
<QueryCell
query={query}
empty={() => null}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
return (
<List className="flex flex-col gap-6">
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
slug={item.integration.slug}
title={item.integration.title}
logo={item.integration.logo}
description={item.primary?.externalId || "No external Id"}
actions={
<div className="w-32">
<DisconnectIntegration
credentialId={item.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-gray-300" }}
/>
</div>
}>
{!fromOnboarding && (
<>
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-2 p-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
type={item.integration.type}
defaultSelected={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
/>
))}
</ul>
</>
)}
</IntegrationListItem>
) : (
<Alert
severity="warning"
title={t("calendar_error")}
message={item.error?.message}
actions={
<div className="w-32">
<DisconnectIntegration
credentialId={item.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-gray-300" }}
/>
</div>
}
/>
)}
</Fragment>
))}
</List>
);
}}
/>
);
}
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
const { t } = useLocale();
const { heading = true, fromOnboarding } = props;
const utils = trpc.useContext();
const onChanged = () =>
Promise.allSettled([
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
exact: true,
}),
utils.invalidateQueries(["viewer.connectedCalendars"]),
]);
const query = trpc.useQuery(["viewer.connectedCalendars"]);
const installedCalendars = trpc.useQuery([
"viewer.integrations",
{ variant: "calendar", onlyInstalled: true },
]);
const mutation = trpc.useMutation("viewer.setDestinationCalendar", {
onSuccess: () => {
utils.invalidateQueries(["viewer.connectedCalendars"]);
},
});
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
return (
<>
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
<>
{heading && (
<div className="flex flex-col gap-6 rounded-md border border-gray-200 p-7">
<ShellSubHeading
title={t("calendar")}
subtitle={t("installed_app_calendar_description")}
className="flex items-center"
actions={
<div className="flex flex-col xl:flex-row xl:space-x-5">
{!!data.connectedCalendars.length && (
<div className=" flex items-center">
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
</div>
)}
</div>
}
/>
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
<div className="flex w-full items-center gap-4">
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
<Icon.FiCalendar className="h-8 w-8" strokeWidth="1" />
<Icon.FiPlus
className="absolute left-4 top-1/2 ml-0.5 mt-[1px] h-2 w-2 text-black"
strokeWidth="4"
/>
</div>
<div className="w-6/12">
<h1 className="text-sm font-semibold">{t("create_events_on")}</h1>
<p className="text-sm font-normal">{t("set_calendar")}</p>
</div>
<div className="flex w-6/12 justify-end">
<DestinationCalendarSelector
onChange={mutation.mutate}
hidePlaceholder
isLoading={mutation.isLoading}
value={data.destinationCalendar?.externalId}
/>
</div>
</div>
</div>
<ConnectedCalendarsList
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
/>
</div>
)}
</>
) : fromOnboarding ? (
<>
{!!query.data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-4"
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
) : (
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}
description={t(`no_category_apps_description_calendar`)}
buttonRaw={
<Button color="secondary" href="/apps/categories/calendar">
{t(`connect_calendar_apps`)}
</Button>
}
/>
)}
</>
);
}}
/>
);
}
@@ -0,0 +1,152 @@
import classNames from "classnames";
import React, { useEffect, useState } from "react";
import Select from "react-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { DestinationCalendar } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
interface Props {
onChange: (value: { externalId: string; integration: string }) => void;
isLoading?: boolean;
hidePlaceholder?: boolean;
/** The external Id of the connected calendar */
value: string | undefined;
destinationCalendar?: DestinationCalendar | null;
}
const DestinationCalendarSelector = ({
onChange,
isLoading,
value,
hidePlaceholder,
destinationCalendar,
}: Props): JSX.Element | null => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"]);
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
// Extra styles to show prefixed text in react-select
const content = (hidePlaceholder = false) => {
if (!hidePlaceholder) {
return {
alignItems: "center",
display: "flex",
":before": {
content: `'${t("select_destination_calendar")}:'`,
display: "block",
marginRight: 8,
},
};
}
return {};
};
useEffect(() => {
const selected = query.data?.connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.find((cal) => cal.externalId === value);
if (selected) {
setSelectedOption({
value: `${selected.integration}:${selected.externalId}`,
label: `<span class="font-semibold">${selected.name}</span> (${selected.externalId})` || "",
});
}
}, [query.data?.connectedCalendars, value]);
if (!query.data?.connectedCalendars.length) {
return null;
}
const options =
query.data.connectedCalendars.map((selectedCalendar) => ({
key: selectedCalendar.credentialId,
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
options: (selectedCalendar.calendars ?? [])
.filter((cal) => cal.readOnly === false)
.map((cal) => ({
label: `<span class="font-semibold">${cal.name}</span> (${cal.externalId})` || "",
value: `${cal.integration}:${cal.externalId}`,
})),
})) ?? [];
const defaultCalendarSelectedString = destinationCalendar?.externalId
? `(${
destinationCalendar.externalId.length > 15
? destinationCalendar.externalId.substring(0, 15) + "..."
: destinationCalendar.externalId
})`
: "";
return (
<div
className="relative flex items-center"
title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
<Select
name="primarySelectedCalendar"
formatOptionLabel={function (data) {
return <span dangerouslySetInnerHTML={{ __html: data.label }} />;
}}
placeholder={
!hidePlaceholder ? (
`${t("select_destination_calendar")}`
) : (
<span className="whitespace-nowrap">
{t("default_calendar_selected")} {defaultCalendarSelectedString}
</span>
)
}
options={options}
styles={{
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
option: (defaultStyles, state) => ({
...defaultStyles,
backgroundColor: state.isSelected
? state.isFocused
? "var(--brand-color)"
: "var(--brand-color)"
: state.isFocused
? "var(--brand-color-dark-mode)"
: "var(--brand-text-color)",
}),
control: (defaultStyles) => {
return {
...defaultStyles,
borderRadius: "6px",
"@media only screen and (min-width: 640px)": {
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
width: "100%",
},
};
},
}}
isSearchable={false}
className={classNames(
"block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 text-sm",
!hidePlaceholder && "font-medium"
)}
onChange={(option) => {
setSelectedOption(option);
if (!option) {
return;
}
/* Split only the first `:`, since Apple uses the full URL as externalId */
const [integration, externalId] = option.value.split(/:(.+)/);
onChange({
integration,
externalId,
});
}}
isLoading={isLoading}
value={selectedOption}
components={{
IndicatorSeparator: () => null,
}}
/>
</div>
);
};
export default DestinationCalendarSelector;
@@ -0,0 +1,53 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List";
import classNames from "@lib/classNames";
function IntegrationListItem(props: {
imageSrc?: string;
slug: string;
name?: string;
title?: string;
description: string;
actions?: ReactNode;
children?: ReactNode;
logo: string;
destination?: boolean;
}): JSX.Element {
const router = useRouter();
const { hl } = router.query;
const [highlight, setHighlight] = useState(hl === props.slug);
const title = props.name || props.title;
useEffect(() => {
const timer = setTimeout(() => setHighlight(false), 3000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<ListItem
expanded={!!props.children}
className={classNames(
"my-0 flex-col rounded-none border-0 transition-colors duration-500",
highlight ? "bg-yellow-100" : ""
)}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
{props.logo && <img className="h-11 w-11" src={props.logo} alt={title} />}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
</div>
<div>{props.actions}</div>
</div>
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
</ListItem>
);
}
export default IntegrationListItem;
+4 -5
View File
@@ -20,11 +20,10 @@ const V2_WHITELIST = [
"/success",
"/auth/login",
];
const V2_BLACKLIST = [
//
"/apps/routing_forms",
"/apps/installed",
];
// For pages
// - which has V1 versions being modified as V2
const V2_BLACKLIST = ["/apps/routing_forms/"];
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
-37
View File
@@ -1,37 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
req.session = await getSession({ req });
if (req.method === "GET" && req.session && req.session.user.id && req.query) {
const { "app-credential-type": appCredentialType } = req.query;
if (!appCredentialType && Array.isArray(appCredentialType)) {
return res.status(400);
}
const userId = req.session.user.id;
try {
const installedApp = await prisma.credential.findMany({
where: {
type: appCredentialType as string,
userId: userId,
},
});
if (installedApp && !!installedApp.length) {
res.json({ count: installedApp.length });
} else {
res.status(404);
}
} catch (error) {
console.log(error);
res.status(500);
}
} else {
res.status(400);
}
res.end();
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
@@ -64,7 +65,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await handler(req, res);
} else {
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
redirectUrl = handler.redirectUrl;
redirectUrl = handler.redirectUrl || getInstalledAppPath(handler);
res.json({ url: redirectUrl });
}
return res.status(200);
+2
View File
@@ -35,6 +35,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
<App
name={data.name}
isGlobal={data.isGlobal}
slug={data.slug}
variant={data.variant}
type={data.type}
logo={data.logo}
categories={data.categories ?? [data.category]}
@@ -7,22 +7,23 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Icon } from "@calcom/ui/Icon";
import { SkeletonText } from "@calcom/ui/v2";
import Shell from "@calcom/ui/v2/core/Shell";
import AppCard from "@calcom/ui/v2/core/apps/AppCard";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const { category } = router.query;
return (
<>
<Shell isPublic large>
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-semibold md:px-8 lg:px-0 lg:pt-0">
<Link href="/apps">
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
<Icon.FiArrowLeft className="h-4 w-4" />
{t("app_store")}{" "}
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
</a>
</Link>
{category && (
+3 -2
View File
@@ -5,9 +5,10 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { SkeletonText } from "@calcom/ui/v2";
export default function Apps({ categories }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
return (
<Shell isPublic large>
@@ -15,7 +16,7 @@ export default function Apps({ categories }: InferGetStaticPropsType<typeof getS
<Link href="/apps">
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
<Icon.FiArrowLeft className="h-4 w-4" />
{t("app_store")}{" "}
{isLocaleReady ? t("app_store") : <SkeletonText className="h-6 w-24" />}{" "}
</a>
</Link>
</div>
+3 -6
View File
@@ -3,7 +3,6 @@ import { InferGetStaticPropsType, NextPageContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getSession } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/client";
import AllApps from "@calcom/ui/v2/core/apps/AllApps";
import AppStoreCategories from "@calcom/ui/v2/core/apps/Categories";
@@ -32,11 +31,9 @@ export const getServerSideProps = async (context: NextPageContext) => {
appStore = await getAppRegistry();
}
const categoryQuery = await prisma.app.findMany({
select: {
categories: true,
},
});
const categoryQuery = appStore.map(({ categories }) => ({
categories: categories || [],
}));
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
@@ -0,0 +1,204 @@
import { useRouter } from "next/router";
import z from "zod";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstalledAppVariants } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { App } from "@calcom/types/App";
import { Icon } from "@calcom/ui/Icon";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { Alert } from "@calcom/ui/v2/core/Alert";
import Button from "@calcom/ui/v2/core/Button";
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
import { List } from "@calcom/ui/v2/core/List";
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
import InstalledAppsLayout from "@calcom/ui/v2/core/layouts/InstalledAppsLayout";
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
import { QueryCell } from "@lib/QueryCell";
import { CalendarListContainer } from "@components/v2/apps/CalendarListContainer";
import IntegrationListItem from "@components/v2/apps/IntegrationListItem";
function ConnectOrDisconnectIntegrationButton(props: {
credentialIds: number[];
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
}) {
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
};
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectIntegration
credentialId={credentialId}
trashIcon
onSuccess={handleOpenChange}
buttonProps={{ className: "border border-gray-300" }}
/>
);
}
return (
<DisconnectIntegration
credentialId={credentialId}
trashIcon
onSuccess={handleOpenChange}
buttonProps={{ className: "border border-gray-300" }}
/>
);
}
if (!props.installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
/** We don't need to "Connect", just show that it's installed */
if (props.isGlobal) {
return (
<div className="truncate px-3 py-2">
<h3 className="text-sm font-medium text-gray-700">{t("default")}</h3>
</div>
);
}
return (
<InstallAppButton
type={props.type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
interface IntegrationsContainerProps {
variant?: "calendar" | "conferencing" | "payment";
exclude?: App["variant"][];
}
const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integrations"> }) => {
return (
<List>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
actions={
<div className="flex w-32 justify-center">
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
/>
</div>
}
/>
))}
</List>
);
};
const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { variant, exclude, onlyInstalled: true }]);
const emptyIcon = {
calendar: Icon.FiCalendar,
conferencing: Icon.FiVideo,
payment: Icon.FiCreditCard,
other: Icon.FiGrid,
};
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
return (
<>
{data.items.length > 0 ? (
<div className="rounded-md border border-gray-200 p-7">
<ShellSubHeading
title={t(variant || "other")}
subtitle={t(`installed_app_${variant || "other"}_description`)}
className="mb-6"
actions={
<Button
href={
variant
? `/apps/categories/${variant === "conferencing" ? "video" : variant}`
: "/apps"
}
color="secondary"
StartIcon={Icon.FiPlus}>
{t("add")}
</Button>
}
/>
<IntegrationsList data={data} />
</div>
) : (
<EmptyScreen
Icon={emptyIcon[variant || "other"]}
headline={t("no_category_apps", {
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
})}
description={t(`no_category_apps_description_${variant || "other"}`)}
buttonRaw={
<Button color="secondary" href={variant ? `/apps/categories/${variant}` : "/apps"}>
{t(`connect_${variant || "other"}_apps`)}
</Button>
}
/>
)}
</>
);
}}
/>
);
};
const querySchema = z.object({
category: z.nativeEnum(InstalledAppVariants),
});
export default function InstalledApps() {
const { t } = useLocale();
const router = useRouter();
const { category } = router.isReady
? querySchema.parse(router.query)
: { category: InstalledAppVariants.calendar as const };
return (
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
{(category === InstalledAppVariants.payment || category === InstalledAppVariants.conferencing) && (
<IntegrationsContainer variant={category} />
)}
{category === InstalledAppVariants.other && (
<IntegrationsContainer
exclude={
Object.keys(InstalledAppVariants).filter(
(variant) => variant !== InstalledAppVariants.other
) as App["variant"][]
}
/>
)}
{category === InstalledAppVariants.calendar && <CalendarListContainer />}
</InstalledAppsLayout>
);
}
@@ -0,0 +1,9 @@
function RedirectPage() {
return;
}
export async function getServerSideProps() {
return { redirect: { permanent: false, destination: "/apps/installed/calendar" } };
}
export default RedirectPage;
+1 -1
View File
@@ -315,7 +315,7 @@ export async function getPaymentCredential(page: Page) {
]);
await Promise.all([
page.waitForNavigation({ url: "/apps/installed" }),
page.waitForNavigation({ url: "/apps/installed/payment?hl=stripe" }),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
@@ -11,6 +11,11 @@ const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_PRIVATE_KEY
);
// TODO: No longer up to date, rewrite needed.
// eslint-disable-next-line playwright/no-skipped-test
test.skip();
test.describe("Stripe integration", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
@@ -26,7 +31,7 @@ test.describe("Stripe integration", () => {
/** If Stripe is added correctly we should see the "Disconnect" button */
await expect(
page.locator(`li:has-text("Stripe") >> [data-testid="stripe_payment-integration-disconnect-button"]`)
).toContainText("Disconnect");
).toContainText("");
// Cleanup
await users.deleteAll();
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="100%" height="100%">
<defs>
<radialGradient id="c" r="1"/>
<filter id="d" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency=".8" numOctaves="2" seed="2" stitchTiles="stitch" x="0%" y="0%" width="100%" height="100%" result="turbulence"/>
<feComponentTransfer x="0%" y="0%" width="100%" height="100%" in="colormatrix" result="componentTransfer">
<feFuncR type="linear" slope="3"/>
<feFuncG type="linear" slope="3"/>
<feFuncB type="linear" slope="3"/>
</feComponentTransfer>
<feColorMatrix x="0%" y="0%" width="100%" height="100%" in="componentTransfer" result="colormatrix2" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 15 -7"/>
</filter>
</defs>
<rect width="100%" height="100%" fill="url(#c)"/>
<rect width="100%" height="100%" fill="transparent" filter="url(#d)" opacity=".3" style="mix-blend-mode:normal"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -735,6 +735,7 @@
"connect_additional_calendar": "Connect additional calendar",
"conferencing": "Conferencing",
"calendar": "Calendar",
"payments": "Payments",
"not_installed": "Not installed",
"error_password_mismatch": "Passwords don't match.",
"error_required_field": "This field is required.",
@@ -775,6 +776,15 @@
"trending_apps": "Trending Apps",
"explore_apps": "{{category}} apps",
"installed_apps": "Installed Apps",
"no_category_apps": "No {{category}} apps",
"no_category_apps_description_calendar": "Add a calendar app to check for conflicts to prevent double bookings",
"no_category_apps_description_conferencing": "Try adding a conference app to intergrate video call with your clients",
"no_category_apps_description_payment": "Add a payment app to ease transaction between you and your clients",
"no_category_apps_description_other": "Add any other type of app to do all sorts of things",
"installed_app_calendar_description": "Set the calendar(s) to check for conflicts to prevent double bookings.",
"installed_app_conferencing_description": "Add your favourite video conferencing apps for your meetings",
"installed_app_payment_description": "Configure which payment processing services to use when charging your clients.",
"installed_app_other_description": "All your installed apps from other categories.",
"empty_installed_apps_headline": "No apps installed",
"empty_installed_apps_description": "Apps enable you to enhance your workflow and improve your scheduling life significantly.",
"empty_installed_apps_button": "Explore the App Store",
@@ -813,6 +823,8 @@
"set_to_default": "Set to Default",
"new_schedule_btn": "New schedule",
"add_new_schedule": "Add a new schedule",
"add_new_calendar": "Add a new calendar",
"set_calendar": "Set where to add new events to when you're booked.",
"delete_schedule": "Delete schedule",
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
@@ -999,6 +1011,8 @@
"remove_app": "Remove App",
"yes_remove_app": "Yes, remove app",
"are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?",
"app_removed_successfully": "App removed successfully",
"error_removing_app": "Error removing app",
"web_conference": "Web conference",
"number_for_sms_reminders": "Phone number (for SMS reminders)",
"requires_confirmation": "Requires confirmation",
@@ -1154,6 +1168,10 @@
"set_availability_getting_started_subtitle_1": "Define ranges of time when you are available",
"set_availability_getting_started_subtitle_2": "You can customise all of this later in the availability page.",
"connect_calendars_from_app_store": "You can add more calendars from the app store",
"connect_conference_apps": "Connect conference apps",
"connect_calendar_apps": "Connect calendar apps",
"connect_payment_apps": "Connect payment apps",
"connect_other_apps": "Connect other apps",
"current_step_of_total": "Step {{currentStep}} of {{maxSteps}}",
"add_variable": "Add variable",
"custom_phone_number": "Custom phone number",
+1 -1
View File
@@ -6,10 +6,10 @@ import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/installed",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
@@ -0,0 +1,20 @@
import z from "zod";
import { InstalledAppVariants } from "../utils";
const variantSchema = z.nativeEnum(InstalledAppVariants);
export default function getInstalledAppPath(
{ variant, slug }: { variant?: string; slug?: string },
locationSearch = ""
): string {
if (!variant) return `/apps/installed${locationSearch}`;
const parsedVariant = variantSchema.safeParse(variant);
if (!parsedVariant.success) return `/apps/installed${locationSearch}`;
if (!slug) return `/apps/installed/${variant}${locationSearch}`;
return `/apps/installed/${variant}?hl=${slug}${locationSearch && locationSearch.slice(1)}`;
}
+30 -20
View File
@@ -4,30 +4,40 @@ import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { App } from "@calcom/types/App";
import getInstalledAppPath from "./getInstalledAppPath";
function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeof useMutation>[2]) {
const mutation = useMutation<unknown, Error, { type?: App["type"] } | "">(async (variables) => {
let type: string | null | undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
const state: IntegrationOAuthCallbackState = {
returnTo: WEBAPP_URL + "/apps/installed" + location.search,
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const mutation = useMutation<unknown, Error, { type?: App["type"]; variant?: string; slug?: string } | "">(
async (variables) => {
let type: string | null | undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
const state: IntegrationOAuthCallbackState = {
returnTo:
WEBAPP_URL +
getInstalledAppPath(
{ variant: variables && variables.variant, slug: variables && variables.slug },
location.search
),
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
const json = await res.json();
window.location.href = json.url;
}, options);
const json = await res.json();
window.location.href = json.url;
},
options
);
return mutation;
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { CalendarService } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -40,7 +41,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ message: "Could not add this caldav account" });
}
return res.status(200).json({ url: "/apps/installed" });
return res
.status(200)
.json({ url: getInstalledAppPath({ variant: "calendar", slug: "apple-calendar" }) });
}
if (req.method === "GET") {
+3 -1
View File
@@ -3,12 +3,14 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const slug = appConfig.slug;
const variant = appConfig.variant;
const appType = appConfig.type;
await checkInstalled(slug, session.user.id);
@@ -19,7 +21,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
key: {},
});
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant, slug }) };
}
export default defaultResponder(getHandler);
+4 -1
View File
@@ -4,6 +4,7 @@ import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { CalendarService } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -43,7 +44,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ message: "Could not add this caldav account" });
}
return res.status(200).json({ url: "/apps/installed" });
return res
.status(200)
.json({ url: getInstalledAppPath({ variant: "calendar", slug: "caldav-calendar" }) });
}
if (req.method === "GET") {
+1 -1
View File
@@ -6,10 +6,10 @@ import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/installed",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
@@ -7,6 +7,7 @@ import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
@@ -32,7 +33,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return res.status(500).json({ message: "Could not add Close.com app" });
}
return res.status(200).json({ url: "/apps/installed" });
return res.status(200).json({ url: getInstalledAppPath({ variant: "other", slug: "closecom" }) });
}
export default defaultResponder(getHandler);
+2 -1
View File
@@ -25,6 +25,7 @@ export const InstallAppButton = (
props: {
isProOnly?: App["isProOnly"];
type: App["type"];
wrapperClassName?: string;
} & InstallAppButtonProps
) => {
const { isLoading, data: user } = trpc.useQuery(["viewer.me"]);
@@ -55,7 +56,7 @@ export const InstallAppButton = (
}
return (
<div ref={proProtectionElementRef}>
<div ref={proProtectionElementRef} className={props.wrapperClassName}>
<InstallAppButtonWithoutPlanCheck {...props} />
</div>
);
@@ -6,6 +6,7 @@ import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
@@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { CalendarService } from "../lib";
const bodySchema = z
@@ -49,7 +50,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
return res.status(500).json({ message: "Could not add this exchange account" });
}
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant: "calendar", slug: "exchange2013-calendar" }) };
}
async function getHandler() {
@@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { CalendarService } from "../lib";
const bodySchema = z
@@ -49,7 +50,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
return res.status(500).json({ message: "Could not add this exchange account" });
}
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant: "calendar", slug: "exchange2016-calendar" }) };
}
async function getHandler() {
+3 -1
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
/**
* This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
@@ -40,5 +42,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500);
}
return res.status(200).json({ url: "/apps/installed" });
return res.status(200).json({ url: getInstalledAppPath({ variant: "other", slug: "giphy" }) });
}
@@ -7,6 +7,7 @@ import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
let client_id = "";
let client_secret = "";
@@ -48,5 +49,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const state = decodeOAuthState(req);
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
);
}
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
let client_id = "";
let client_secret = "";
@@ -55,5 +56,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const state = decodeOAuthState(req);
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other_calendar", slug: "hubspot" })
);
}
+3 -1
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
/**
* This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
@@ -40,5 +42,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500);
}
// need to return a json object with the response status
return res.status(200).json({ url: "/apps/installed" });
return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "huddle01" }) });
}
+3 -1
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
/**
* This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
@@ -39,5 +41,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
return res.status(500);
}
return res.status(200).json({ url: "/apps/installed" });
return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "jitsi" }) });
}
@@ -7,6 +7,7 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { LARK_HOST } from "../common";
import { getAppAccessToken } from "../lib/AppAccessToken";
import type { LarkAuthCredentials } from "../types/LarkCalendar";
@@ -88,7 +89,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
});
}
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "lark-calendar" })
);
} catch (error) {
log.error("handle callback error", error);
res.redirect(state?.returnTo ?? "/apps/installed");
+1
View File
@@ -6,6 +6,7 @@ import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
@@ -6,6 +6,7 @@ import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
@@ -74,5 +75,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const state = decodeOAuthState(req);
return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
return res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" })
);
}
@@ -6,6 +6,7 @@ import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
const scopes = ["OnlineMeetings.ReadWrite"];
@@ -101,5 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const state = decodeOAuthState(req);
return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
return res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "msteams" })
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
@@ -19,7 +20,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
key: {},
});
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant: "other", slug: "ping" }) };
}
export default defaultResponder(getHandler);
+1 -1
View File
@@ -7,9 +7,9 @@ const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
slug: appConfig.slug,
variant: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/installed",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
+1
View File
@@ -7,6 +7,7 @@ const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
slug: appConfig.slug,
variant: appConfig.variant,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
+2 -1
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
@@ -19,7 +20,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
key: {},
});
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant: "conferencing", slug: "riverside" }) };
}
export default defaultResponder(getHandler);
@@ -6,6 +6,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { getSlackAppKeys } from "../lib/utils";
const callbackQuerySchema = z.object({
@@ -52,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
res.redirect("/apps/installed");
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "slack" }));
}
export default defaultHandler({
@@ -4,6 +4,7 @@ import { stringify } from "querystring";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import stripe, { StripeData } from "../lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -39,5 +40,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
res.redirect("/apps/installed");
res.redirect(getInstalledAppPath({ variant: "payment", slug: "stripe" }));
}
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
let client_id = "";
let client_secret = "";
@@ -74,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
res.redirect("/apps/installed");
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "tandem" }));
}
+1
View File
@@ -7,6 +7,7 @@ const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
slug: appConfig.slug,
variant: appConfig.variant,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/typeform/how-to-use",
+7
View File
@@ -25,6 +25,13 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
export enum InstalledAppVariants {
"conferencing" = "conferencing",
"calendar" = "calendar",
"payment" = "payment",
"other" = "other",
}
export const ALL_APPS = Object.values(ALL_APPS_MAP);
type OptionTypeBase = {
+3 -1
View File
@@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
/**
* This is will generate a user token for a client_user_id`
* @param req
@@ -34,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
},
});
return res.redirect("/apps/installed");
return res.redirect(getInstalledAppPath({ variant: "other", slug: "vital-automation" }));
} catch (e) {
return res.status(500);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
@@ -19,7 +20,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
key: {},
});
return { url: "/apps/installed" };
return { url: getInstalledAppPath({ variant: "conferencing", slug: "whereby" }) };
}
export default defaultResponder(getHandler);
+3 -1
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
/**
* This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
@@ -40,5 +42,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500);
}
return res.status(200).json({ url: "/apps/installed" });
return res.status(200).json({ url: getInstalledAppPath({ variant: "other", slug: "wipe-my-cal" }) });
}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { getZoomAppKeys } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -83,5 +84,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
res.redirect("/apps/installed");
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "zoom" }));
}
@@ -19,9 +19,10 @@ type LicenseRequiredProps = {
const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) => {
const session = useSession();
const Component = as || Fragment;
const hasValidLicense = session.data ? session.data.hasValidLicense : null;
return (
<Component {...rest}>
{session.data?.hasValidLicense ? (
{hasValidLicense === null || hasValidLicense ? (
children
) : (
<EmptyScreen
+15 -2
View File
@@ -721,19 +721,23 @@ const loggedInViewerRouter = createProtectedRouter()
.query("integrations", {
input: z.object({
variant: z.string().optional(),
exclude: z.array(z.string()).optional(),
onlyInstalled: z.boolean().optional(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const { variant, onlyInstalled } = input;
const { variant, exclude, onlyInstalled } = input;
const { credentials } = user;
let apps = getApps(credentials).map(
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => ({
...app,
credentialIds: credentials.filter((c) => c.type === app.type).map((c) => c.id),
})
);
if (exclude) {
// exclusion filter
apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true));
}
if (variant) {
// `flatMap()` these work like `.filter()` but infers the types correctly
apps = apps
@@ -766,6 +770,15 @@ const loggedInViewerRouter = createProtectedRouter()
return app;
},
})
.query("appCredentialsByType", {
input: z.object({
appType: z.string(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id);
},
})
.query("stripeCustomer", {
async resolve({ ctx }) {
const {
+2 -1
View File
@@ -6,10 +6,11 @@ import { Credential } from "@calcom/prisma/client";
export type AppDeclarativeHandler = {
appType: string;
slug: string;
variant: string;
supportsMultipleInstalls: false;
handlerType: "add";
createCredential: (arg: { user: Session["user"]; appType: string; slug: string }) => Promise<Credential>;
supportsMultipleInstalls: boolean;
redirectUrl: string;
redirectUrl?: string;
};
export type AppHandler = AppDeclarativeHandler | NextApiHandler;
+1 -1
View File
@@ -112,7 +112,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
)}
<div>
{props.title && <DialogHeader title={props.title} />}
{props.description && <p className="mb-6 text-sm text-gray-500">Optional Description</p>}
{props.description && <p className="mb-6 text-sm text-gray-500">{props.description}</p>}
</div>
</div>
)}
+25 -7
View File
@@ -376,7 +376,7 @@ function UserDropdown({ small }: { small?: boolean }) {
);
}
type NavigationItemType = {
export type NavigationItemType = {
name: string;
href: string;
icon?: SVGComponent;
@@ -417,16 +417,32 @@ const navigation: NavigationItemType[] = [
icon: Icon.FiGrid,
isCurrent: ({ router, item }) => {
const path = router.asPath.split("?")[0];
return !!item.child?.some((child) => path === child.href);
// During Server rendering path is /v2/apps but on client it becomes /apps(weird..)
return (
(path.startsWith(item.href) || path.startsWith("/v2" + item.href)) && !path.includes("routing_forms/")
);
},
child: [
{
name: "app_store",
href: "/apps",
isCurrent: ({ router, item }) => {
const path = router.asPath.split("?")[0];
// During Server rendering path is /v2/apps but on client it becomes /apps(weird..)
return (
(path.startsWith(item.href) || path.startsWith("/v2" + item.href)) &&
!path.includes("routing_forms/") &&
!path.includes("/installed")
);
},
},
{
name: "installed_apps",
href: "/apps/installed",
href: "/apps/installed/calendar",
isCurrent: ({ router }) => {
const path = router.asPath;
return path.startsWith("/apps/installed/") || path.startsWith("/v2/apps/installed/");
},
},
],
},
@@ -538,7 +554,6 @@ const NavigationItem: React.FC<{
</Link>
{item.child &&
isCurrent({ router, isChild, item }) &&
router.asPath.startsWith(item.href) &&
item.child.map((item) => <NavigationItem key={item.name} item={item} isChild />)}
</Fragment>
);
@@ -648,7 +663,10 @@ function DeploymentInfo() {
function SideBarContainer() {
const { status } = useSession();
const router = useRouter();
if (status !== "authenticated") return null;
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
// This improves the experience of refresh on app store pages(when logged in) which are SSG.
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
if (status !== "loading" && status !== "authenticated") return null;
if (router.route.startsWith("/v2/settings/")) return null;
return <SideBar />;
}
@@ -735,12 +753,12 @@ export function ShellMain(props: LayoutProps) {
<div className="hidden w-full ltr:mr-4 rtl:ml-4 sm:block">
{props.heading && (
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-black">
{!isLocaleReady ? null : props.heading}
{!isLocaleReady ? <SkeletonText invisible /> : props.heading}
</h1>
)}
{props.subtitle && (
<p className="hidden text-sm text-neutral-500 sm:block">
{!isLocaleReady ? null : props.subtitle}
{!isLocaleReady ? <SkeletonText invisible /> : props.subtitle}
</p>
)}
</div>
+14 -6
View File
@@ -86,8 +86,8 @@ export default function AllApps({ apps }: AllAppsPropsType) {
router.replace(router.asPath.split("?")[0], undefined, { shallow: true });
}}
className={classNames(
selectedCategory === null ? "bg-gray-200" : "bg-gray-50",
"rounded-md px-4 py-2.5 text-sm font-medium text-gray-900 hover:cursor-pointer hover:bg-gray-200"
selectedCategory === null ? "bg-gray-900 text-gray-50" : "bg-gray-50 text-gray-900",
"rounded-md px-4 py-2.5 text-sm font-medium hover:cursor-pointer hover:bg-gray-900 hover:text-gray-50"
)}>
{t("all_apps")}
</li>
@@ -104,8 +104,8 @@ export default function AllApps({ apps }: AllAppsPropsType) {
}
}}
className={classNames(
selectedCategory === cat ? "bg-gray-200" : "bg-gray-50",
"rounded-md px-4 py-2.5 text-sm font-medium text-gray-900 hover:cursor-pointer hover:bg-gray-200"
selectedCategory === cat ? "bg-gray-900 text-gray-50" : "bg-gray-50 text-gray-900",
"rounded-md px-4 py-2.5 text-sm font-medium hover:cursor-pointer hover:bg-gray-900 hover:text-gray-50"
)}>
{cat[0].toUpperCase() + cat.slice(1)}
</li>
@@ -120,9 +120,17 @@ export default function AllApps({ apps }: AllAppsPropsType) {
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4" ref={appsContainerRef}>
<div
className="grid gap-3 lg:grid-cols-4 [@media(max-width:1270px)]:grid-cols-3 [@media(max-width:730px)]:grid-cols-2 [@media(max-width:500px)]:grid-cols-1"
ref={appsContainerRef}>
{apps
.filter((app) => (selectedCategory !== null ? app.category === selectedCategory : true))
.filter((app) =>
selectedCategory !== null
? app.categories
? app.categories.includes(selectedCategory)
: app.category === selectedCategory
: true
)
.map((app) => (
<AppCard key={app.name} app={app} credentials={app.credentials} />
))}
+13 -4
View File
@@ -9,6 +9,8 @@ import { App } from "@calcom/types/App";
import { Icon } from "@calcom/ui/Icon";
import Button from "@calcom/ui/v2/core/Button";
import { SkeletonText } from "../skeleton";
interface AppCardProps {
app: App;
credentials?: Credential[];
@@ -16,7 +18,7 @@ interface AppCardProps {
export default function AppCard({ app, credentials }: AppCardProps) {
const { t } = useLocale();
const { isLocaleReady } = useLocale();
const mutation = useAddAppMutation(null, {
onSuccess: () => {
showToast(t("app_successfully_installed"), "success");
@@ -64,16 +66,21 @@ export default function AppCard({ app, credentials }: AppCardProps) {
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
wrapperClassName="[@media(max-width:260px)]:w-full"
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type: app.type });
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug });
},
};
}
return (
<Button color="secondary" StartIcon={Icon.FiPlus} {...props}>
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon={Icon.FiPlus}
{...props}>
{t("install")}
</Button>
);
@@ -85,11 +92,12 @@ export default function AppCard({ app, credentials }: AppCardProps) {
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
wrapperClassName="[@media(max-width:260px)]:w-full"
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type: app.type });
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug });
},
};
}
@@ -97,6 +105,7 @@ export default function AppCard({ app, credentials }: AppCardProps) {
<Button
StartIcon={Icon.FiPlus}
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
data-testid="install-app-button"
{...props}>
{t("install")}
+11 -5
View File
@@ -3,6 +3,7 @@ import { ArrowRight } from "react-feather";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText } from "../skeleton";
import Slider from "./Slider";
export default function AppStoreCategories({
@@ -13,7 +14,7 @@ export default function AppStoreCategories({
count: number;
}[];
}) {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
return (
<div className="mb-16">
<Slider
@@ -33,11 +34,16 @@ export default function AppStoreCategories({
<Link key={category.name} href={"/apps/categories/" + category.name}>
<a
data-testid={`app-store-category-${category.name}`}
className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
<div className="self-center">
<h3 className="font-medium capitalize">{category.name}</h3>
className="relative flex rounded-md"
style={{ background: "radial-gradient(farthest-side at top right, #a2abbe 0%, #E3E3E3 100%)" }}>
<div className="w-full self-center bg-[url('/noise.svg')] bg-cover bg-center bg-no-repeat px-6 py-4">
{isLocaleReady ? (
<h3 className="font-medium capitalize">{category.name}</h3>
) : (
<SkeletonText invisible />
)}
<p className="text-sm text-gray-500">
{t("number_apps", { count: category.count })}{" "}
{isLocaleReady ? t("number_apps", { count: category.count }) : <SkeletonText invisible />}{" "}
<ArrowRight className="inline-block h-4 w-4" />
</p>
</div>
+12 -5
View File
@@ -3,8 +3,11 @@ import "@glidejs/glide/dist/css/glide.core.min.css";
import "@glidejs/glide/dist/css/glide.theme.min.css";
import { useEffect, useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/Icon";
import { SkeletonText } from "../skeleton";
const Slider = <T extends string | unknown>({
title = "",
className = "",
@@ -22,7 +25,7 @@ const Slider = <T extends string | unknown>({
}) => {
const glide = useRef(null);
const slider = useRef<Glide.Properties | null>(null);
const { isLocaleReady } = useLocale();
useEffect(() => {
if (glide.current) {
slider.current = new Glide(glide.current, {
@@ -45,10 +48,14 @@ const Slider = <T extends string | unknown>({
</style>
<div className="glide" ref={glide}>
<div className="flex cursor-default">
{title && (
<div>
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">{title}</h2>
</div>
{isLocaleReady ? (
title && (
<div>
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">{title}</h2>
</div>
)
) : (
<SkeletonText className="h-4 w-24" />
)}
<div className="glide__arrows ml-auto" data-glide-el="controls">
<button data-glide-dir="<" className="mr-4">
+2 -3
View File
@@ -1,9 +1,8 @@
import React, { ComponentProps } from "react";
import HorizontalTabs from "@calcom/ui/v2/core/navigation/tabs/HorizontalTabs";
import Shell from "../Shell";
import { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
import HorizontalTabs from "../navigation/tabs/HorizontalTabs";
const tabs: HorizontalTabItemProps[] = [
{
@@ -0,0 +1,62 @@
import React, { ComponentProps } from "react";
import { InstalledAppVariants } from "@calcom/app-store/utils";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "../../../Icon";
import Shell from "../Shell";
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
import HorizontalTabs from "../navigation/tabs/HorizontalTabs";
import type { VerticalTabItemProps } from "../navigation/tabs/VerticalTabItem";
import VerticalTabs from "../navigation/tabs/VerticalTabs";
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "calendar",
href: "/apps/installed/calendar",
icon: Icon.FiCalendar,
},
{
name: "conferencing",
href: "/apps/installed/conferencing",
icon: Icon.FiVideo,
},
{
name: "payment",
href: "/apps/installed/payment",
icon: Icon.FiCreditCard,
},
{
name: "other",
href: "/apps/installed/other",
icon: Icon.FiGrid,
},
];
export default function InstalledAppsLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
const query = trpc.useQuery([
"viewer.integrations",
{ variant: InstalledAppVariants.payment, onlyInstalled: true },
]);
let actualTabs = tabs;
if (query.data?.items.length === 0) {
actualTabs = tabs.filter((tab) => tab.name !== InstalledAppVariants.payment);
}
return (
<Shell {...rest}>
<div className="mt-10 flex flex-col p-2 md:p-0 xl:flex-row">
<div className="hidden xl:block">
<VerticalTabs tabs={actualTabs} sticky />
</div>
<div className="block xl:hidden">
<HorizontalTabs tabNameKey="tabName" tabs={actualTabs} />
</div>
<main className="w-full xl:mx-5 xl:w-4/5 xl:pr-5">{children}</main>
</div>
</Shell>
);
}
export const getLayout = (page: React.ReactElement) => <InstalledAppsLayout>{page}</InstalledAppsLayout>;
@@ -6,6 +6,8 @@ import { MouseEventHandler } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText } from "../../skeleton";
export type HorizontalTabItemProps<T extends string = "tabName"> = {
name: string;
disabled?: boolean;
@@ -28,7 +30,7 @@ const HorizontalTabItem = function <T extends string>({
tabNameKey?: T;
}) {
const router = useRouter();
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
let newHref = "";
let isCurrent;
const _tabNameKey = tabNameKey || "tabName";
@@ -36,7 +38,7 @@ const HorizontalTabItem = function <T extends string>({
if (href) {
newHref = href;
isCurrent = router.asPath === href;
isCurrent = router.asPath.startsWith(href);
} else if (tabName) {
newHref = "";
isCurrent = router.query[_tabNameKey] === tabName;
@@ -65,7 +67,7 @@ const HorizontalTabItem = function <T extends string>({
props.className
)}
aria-current={isCurrent ? "page" : undefined}>
{t(name)}
{isLocaleReady ? t(name) : <SkeletonText className="h-4 w-24" />}
</a>
</Link>
);
@@ -50,7 +50,7 @@ const VerticalTabItem = function <T extends string>({
const _tabNameKey = tabNameKey || "tabName";
if (href) {
newHref = href;
isCurrent = router.asPath === href;
isCurrent = router.asPath.startsWith(href);
} else if (tabName) {
newHref = "";
isCurrent = router.query[_tabNameKey] === tabName;
+7 -3
View File
@@ -14,12 +14,16 @@ const SkeletonAvatar: React.FC<SkeletonBaseProps> = ({ className }) => {
return <div className={classNames(`mt-1 rounded-full bg-gray-200 ltr:mr-2 rtl:ml-2`, className)} />;
};
const SkeletonText: React.FC<SkeletonBaseProps> = ({ className = "" }) => {
const SkeletonText: React.FC<SkeletonBaseProps & { invisible?: boolean }> = ({
className = "",
invisible = false,
}) => {
return (
<span
className={classNames(
`font-size-0 dark:white-300 inline-block animate-pulse rounded-md bg-gray-300 empty:before:inline-block empty:before:content-['']`,
className
className,
invisible ? "invisible" : ""
)}
/>
);
@@ -28,7 +32,7 @@ const SkeletonText: React.FC<SkeletonBaseProps> = ({ className = "" }) => {
const SkeletonButton: React.FC<SkeletonBaseProps> = ({ className }) => {
return (
<SkeletonContainer>
<div className={classNames(`bg-gray-200`, className)} />
<div className={classNames(`rounded-md bg-gray-200`, className)} />
</SkeletonContainer>
);
};
@@ -12,12 +12,14 @@ export default function DisconnectIntegration({
label,
trashIcon,
isGlobal,
onSuccess,
buttonProps,
}: {
credentialId: number;
label?: string;
trashIcon?: boolean;
isGlobal?: boolean;
onSuccess?: () => void;
buttonProps?: ButtonProps;
}) {
const { t } = useLocale();
@@ -25,11 +27,12 @@ export default function DisconnectIntegration({
const mutation = trpc.useMutation("viewer.deleteCredential", {
onSuccess: () => {
showToast("Integration deleted successfully", "success");
showToast(t("app_removed_successfully"), "success");
setModalOpen(false);
onSuccess && onSuccess();
},
onError: () => {
showToast("Error deleting app", "error");
showToast(t("error_removing_app"), "error");
setModalOpen(false);
},
});
@@ -41,16 +44,17 @@ export default function DisconnectIntegration({
<Button
color={buttonProps?.color || "destructive"}
StartIcon={trashIcon ? Icon.FiTrash : undefined}
size={trashIcon && !label ? "icon" : "base"}
disabled={isGlobal}
{...buttonProps}>
{label}
{label && label}
</Button>
</DialogTrigger>
<DialogContent
title="Remove app"
description="Are you sure you want to remove this app?"
title={t("remove_app")}
description={t("are_you_sure_you_want_to_remove_this_app")}
type="confirmation"
actionText="Yes, remove app"
actionText={t("yes_remove_app")}
Icon={Icon.FiAlertCircle}
actionOnClick={() => mutation.mutate({ id: credentialId })}
/>