App Store Templates (#5289)

* Start by moving what we have to _templates

* WIP

* WIP

* Add create/edit/delete template commands

* Type fixes cli

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Hariom Balhara 2023-01-19 04:00:25 +05:30 committed by GitHub
parent 27e8d73b82
commit d4c5a906b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
185 changed files with 2272 additions and 1317 deletions

View File

@ -12,7 +12,7 @@ import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/consta
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { App as AppType } from "@calcom/types/App";
import { Button, Icon, showToast, SkeletonButton, SkeletonText, HeadSeo } from "@calcom/ui";
import { Button, Icon, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
const Component = ({
name,
@ -34,6 +34,7 @@ const Component = ({
privacy,
isProOnly,
images,
isTemplate,
}: Parameters<typeof App>[0]) => {
const { t } = useLocale();
const hasImages = images && images.length > 0;
@ -106,6 +107,11 @@ const Component = ({
</Link>{" "}
{t("published_by", { author })}
</h2>
{isTemplate && (
<Badge variant="red" className="mt-4">
Template - Available in Dev Environment only for testing
</Badge>
)}
</header>
</div>
{!appCredentials.isLoading ? (
@ -310,6 +316,7 @@ export default function App(props: {
licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"];
images?: string[];
isTemplate?: boolean;
}) {
const { t } = useLocale();

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
import { Badge, Icon, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
import classNames from "@lib/classNames";
@ -19,6 +19,7 @@ function IntegrationListItem(props: {
destination?: boolean;
separate?: boolean;
invalidCredential?: boolean;
isTemplate?: boolean;
}): JSX.Element {
const { t } = useLocale();
const router = useRouter();
@ -50,8 +51,13 @@ function IntegrationListItem(props: {
<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">
<ListItemTitle component="h3" className="flex ">
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
{props.isTemplate && (
<Badge variant="red" className="ml-4">
Template
</Badge>
)}
</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
{/* Alert error that key stopped working. */}

View File

@ -1,43 +1,17 @@
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
import { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import { EventTypeAppsList } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, Icon } from "@calcom/ui";
type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
function AppCardWrapper({
app,
eventType,
getAppData,
setAppData,
}: {
app: RouterOutputs["viewer"]["apps"][number];
eventType: EventType;
getAppData: GetAppData;
setAppData: SetAppData;
}) {
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
if (!Component) {
throw new Error('No component found for "' + dirName + '"');
}
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<Component key={app.slug} app={app} eventType={eventType} />
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
}
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { t } = useLocale();
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
@ -97,7 +71,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
/>
) : null}
{installedApps?.map((app) => (
<AppCardWrapper
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
@ -113,7 +87,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<AppCardWrapper
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}

View File

@ -35,6 +35,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly}
images={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
@ -70,21 +71,31 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (!singleApp) return { notFound: true };
const appDirname = app.dirName;
const isTemplate = singleApp.isTemplate;
const appDirname = path.join(isTemplate ? "templates" : "", app.dirName);
const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/DESCRIPTION.md`);
const postFilePath = path.join(README_PATH);
let source = "";
try {
/* If the app doesn't have a README we fallback to the package description */
source = fs.readFileSync(postFilePath).toString();
source = source.replace(/{DESCRIPTION}/g, singleApp.description);
} catch (error) {
/* If the app doesn't have a README we fallback to the package description */
console.log(`No DESCRIPTION.md provided for: ${appDirname}`);
source = singleApp.description;
}
const { content, data } = matter(source);
if (data.items) {
data.items = data.items.map((item: string) => {
if (!item.includes("/api/app-store")) {
// Make relative paths absolute
return `/api/app-store/${appDirname}/${item}`;
}
return item;
});
}
return {
props: {
source: { content, data },

View File

@ -115,6 +115,7 @@ const IntegrationsList = ({ data }: IntegrationsListProps) => {
logo={item.logo}
description={item.description}
separate={true}
isTemplate={item.isTemplate}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<div className="flex w-16 justify-end">

View File

@ -15,6 +15,12 @@
"app-store:build": "yarn app-store-cli build",
"app-store:watch": "yarn app-store-cli watch",
"app-store": "yarn app-store-cli cli",
"create-app": "yarn app-store create",
"edit-app": "yarn app-store edit",
"delete-app": "yarn app-store delete",
"create-app-template": "yarn app-store create-template",
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",

View File

@ -0,0 +1,9 @@
## How to build an App using the CLI
Refer to https://developer.cal.com/guides/how-to-build-an-app
## TODO
- Merge app-store:watch and app-store commands; introduce app-store --watch
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Use AppDeclarativeHandler across all apps. Whatever isn't supported in it, support that.

View File

@ -7,10 +7,10 @@
"node": ">=10"
},
"scripts": {
"build": "ts-node --transpile-only src/app-store.ts",
"build": "ts-node --transpile-only src/build.ts",
"cli": "ts-node --transpile-only src/cli.tsx",
"watch": "ts-node --transpile-only src/app-store.ts --watch",
"generate": "ts-node --transpile-only src/app-store.ts",
"watch": "ts-node --transpile-only src/build.ts --watch",
"generate": "ts-node --transpile-only src/build.ts",
"post-install": "yarn build"
},
"files": [

View File

@ -1,37 +0,0 @@
## Steps to create an app
- Create a folder in packages/app-store/{APP_NAME} = {APP}
- Fill it with a sample app
- Modify {APP}/\_metadata.ts with the data provided
## Approach
- appType is derived from App Name(a slugify operation that makes a string that can be used as a director name, a variable name for imports and a URL path).
- appType is then used to create the app directory. It becomes `config.type` of config.json. config.type is the value used to create an entry in App table and retrieve any apps or credentials. It also becomes App.dirName
- dirnames that don't start with \_ are considered apps in packages/app-store and based on those apps .generated.ts\* files are created. This allows pre-cli apps to keep on working.
- app directory is populated with app-store/\_baseApp with newly updated config.json and package.json
- `packages/prisma/seed-app-store.config.json` is updated with new app.
NOTE: After app-store-cli is live, Credential.appId and Credential.type would be same for new apps. For old apps they would remain different. Credential.type would be used to identify credentials in integrations call and Credential.appId/App.slug would be used to identify apps.
If we rename all existing apps to their slug names, we can remove type and then use just appId to refer to things everywhere. This can be done later on.
## TODO
- Improvements
- Edit command Improvements
- Prefill fields in edit command -> It allows only that content to change which user wants to change.
- Don't override icon.svg
- Merge app-store:watch and app-store commands; introduce app-store --watch
- Allow inputs in non interactive way as well - That would allow easily copy pasting commands.
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Require assets path relative to app dir.
## Roadmap
- Avoid delete and edit on apps created outside of cli
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Improvements in shared code across app
- Use baseApp/api/add.ts for all apps with configuration of credentials creation and redirection URL.
- Delete creation side effects if App creation fails - Might make debugging difficult
- This is so that web app doesn't break because of additional app folders or faulty db-seed

View File

@ -0,0 +1,49 @@
import React, { FC } from "react";
import { SupportedCommands } from "src/types";
import Create from "./commandViews/Create";
import CreateTemplate from "./commandViews/Create";
import Delete from "./commandViews/Delete";
import DeleteTemplate from "./commandViews/DeleteTemplate";
import Edit from "./commandViews/Edit";
import EditTemplate from "./commandViews/EditTemplate";
export const App: FC<{
template: string;
command: SupportedCommands;
slug?: string;
}> = ({ command, template, slug }) => {
if (command === "create") {
return <Create template={template} />;
}
if (command === "edit") {
return <Edit slug={slug} />;
}
if (command === "edit-template") {
return <EditTemplate slug={slug} />;
}
if (command === "delete") {
if (!slug) {
throw new Error('Slug is required for "delete" command');
}
return <Delete slug={slug} />;
}
if (command === "create-template") {
return <CreateTemplate template={template} />;
}
if (command === "delete-template") {
if (!slug) {
throw new Error('Slug is required for "delete-template" command');
}
return <DeleteTemplate slug={slug} />;
}
return null;
};
export default App;

View File

@ -1,463 +0,0 @@
import fs from "fs";
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import path from "path";
import React, { FC, useEffect, useState } from "react";
import execSync from "./execSync";
const slugify = (str: string) => {
// It is to be a valid dir name, a valid JS variable name and a valid URL path
return str.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
};
function getSlugFromAppName(appName: string | null): string | null {
if (!appName) {
return appName;
}
return slugify(appName);
}
function getAppDirPath(slug: any) {
return path.join(appStoreDir, `${slug}`);
}
const appStoreDir = path.resolve(__dirname, "..", "..", "app-store");
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
function absolutePath(appRelativePath) {
return path.join(appStoreDir, appRelativePath);
}
const updatePackageJson = ({ slug, appDescription, appDirPath }) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const BaseAppFork = {
create: function* ({
category,
subCategory,
editMode = false,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
}) {
const appDirPath = getAppDirPath(slug);
let message = !editMode ? "Forking base app" : "Updating app";
yield message;
if (!editMode) {
execSync(`mkdir -p ${appDirPath}`);
execSync(`cp -r ${absolutePath("_baseApp/*")} ${appDirPath}`);
}
updatePackageJson({ slug, appDirPath, appDescription });
const categoryToVariantMap = {
video: "conferencing",
};
const dataFromCategory =
category === "video"
? {
appData: {
location: {
type: `integrations:${slug}_video`,
label: `${appName}`,
},
},
}
: {};
const dataFromSubCategory =
category === "video" && subCategory === "static"
? {
appData: {
...dataFromCategory.appData,
location: {
...dataFromCategory.appData.location,
linkType: "static",
organizerInputPlaceholder: "https://anything.anything",
urlRegExp: "",
},
},
}
: {};
let config = {
"/*": "Don't modify slug - If required, do it using cli edit command",
name: appName,
// Plan to remove it. DB already has it and name of dir is also the same.
slug: slug,
type: `${slug}_${category}`,
imageSrc: `/api/app-store/${slug}/icon.svg`,
logo: `/api/app-store/${slug}/icon.svg`,
url: `https://cal.com/apps/${slug}`,
variant: categoryToVariantMap[category] || category,
categories: [category],
publisher: publisherName,
email: publisherEmail,
description: appDescription,
extendsFeature: extendsFeature,
// TODO: Use this to avoid edit and delete on the apps created outside of cli
__createdUsingCli: true,
...dataFromCategory,
...dataFromSubCategory,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
fs.writeFileSync(
`${appDirPath}/DESCRIPTION.md`,
fs
.readFileSync(`${appDirPath}/DESCRIPTION.md`)
.toString()
.replace(/_DESCRIPTION_/g, appDescription)
.replace(/_APP_DIR_/g, slug)
);
message = !editMode ? "Forked base app" : "Updated app";
yield message;
},
delete: function ({ slug }) {
const appDirPath = getAppDirPath(slug);
execSync(`rm -rf ${appDirPath}`);
},
};
const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: function ({ slug, category, noDbUpdate }) {
let configContent = "[]";
try {
if (fs.statSync(this.seedConfigPath)) {
configContent = fs.readFileSync(this.seedConfigPath).toString();
}
} catch (e) {}
const seedConfig = JSON.parse(configContent);
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [category],
slug: slug,
type: `${slug}_${category}`,
});
}
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`cd ${workspaceDir} && yarn db-seed`);
}
},
revert: async function ({ slug, noDbUpdate }) {
let seedConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
}
},
};
const generateAppFiles = () => {
execSync(`cd ${__dirname} && yarn ts-node --transpile-only src/app-store.ts`);
};
const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => {
// AppName
// Type of App - Other, Calendar, Video, Payment, Messaging, Web3
const [appInputData, setAppInputData] = useState({});
const [inputIndex, setInputIndex] = useState(0);
const fields = [
{ label: "App Title", name: "appName", type: "text", explainer: "Keep it very short" },
{
label: "App Description",
name: "appDescription",
type: "text",
explainer:
"A detailed description of your app. You can later modify DESCRIPTION.md to add slider and other components",
},
{
label: "Category of App",
name: "appCategory",
type: "select",
options: [
{ label: "Calendar", value: "calendar" },
{
label:
"Static Link - Video(Apps like Ping.gg/Riverside/Whereby which require you to provide a link to join your room)",
value: "video_static",
},
{ label: "Other - Video", value: "video_other" },
{ label: "Payment", value: "payment" },
{ label: "Messaging", value: "messaging" },
{ label: "Web3", value: "web3" },
{ label: "Automation", value: "automation" },
{ label: "Analytics", value: "analytics" },
{ label: "Other", value: "other" },
],
explainer: "This is how apps are categorized in App Store.",
},
{
label: "What kind of app would you consider it?",
name: "extendsFeature",
options: [
{ label: "User", value: "User" },
{
label: "Event Type(Available for configuration in Apps tab for all Event Types)",
value: "EventType",
},
],
},
{ label: "Publisher Name", name: "publisherName", type: "text", explainer: "Let users know who you are" },
{
label: "Publisher Email",
name: "publisherEmail",
type: "text",
explainer: "Let users know how they can contact you.",
},
];
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
const fieldValue = appInputData[fieldName] || "";
const appName = appInputData["appName"];
const rawCategory = appInputData["appCategory"] || "";
const appDescription = appInputData["appDescription"];
const publisherName = appInputData["publisherName"];
const publisherEmail = appInputData["publisherEmail"];
let extendsFeature = appInputData["extendsFeature"] || [];
if (rawCategory === "analytics") {
// Analytics only means EventType Analytics as of now
extendsFeature = "EventType";
}
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
const allFieldsFilled = inputIndex === fields.length;
const [progressUpdate, setProgressUpdate] = useState("");
const category = rawCategory.split("_")[0];
const subCategory = rawCategory.split("_")[1];
if (!editMode) {
slug = getSlugFromAppName(appName);
}
useEffect(() => {
// When all fields have been filled
if (allFieldsFilled) {
const it = BaseAppFork.create({
category,
subCategory,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
});
for (const item of it) {
setProgressUpdate(item);
}
Seed.update({ slug, category, noDbUpdate });
generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setStatus("done");
}
});
if (!slug && editMode) {
return <Text>--slug is required</Text>;
}
if (allFieldsFilled) {
return (
<Box flexDirection="column">
<Text>
{editMode
? `Editing app with slug ${slug}`
: `Creating app with name '${appName}' of type '${category}'`}
</Text>
<Text>{progressUpdate}</Text>
{status === "done" ? (
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
<Text bold italic>
Just wait for a few seconds for process to exit and then you are good to go. Your App code
exists at ${getAppDirPath(slug)}
Tip : Go and change the logo of your app by replacing {getAppDirPath(slug) + "/static/icon.svg"}
</Text>
<Text bold italic>
App Summary:
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text color="green">Slug: </Text>
<Text>{slug}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">App URL: </Text>
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Name: </Text>
<Text>{appName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Description: </Text>
<Text>{appDescription}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Category: </Text>
<Text>{category}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Name: </Text>
<Text>{publisherName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Email: </Text>
<Text>{publisherEmail}</Text>
</Box>
</Box>
</Box>
) : (
<Text>Please wait...</Text>
)}
<Text italic color="gray">
Note: You should not rename app directory manually. Use cli only to do that as it needs to be
updated in DB as well
</Text>
</Box>
);
}
// Hack: using field.name == "appTitle" to identify that app Name has been submitted and not being edited.
if (!editMode && field.name === "appTitle" && slug && fs.existsSync(getAppDirPath(slug))) {
return (
<>
<Text>App with slug {slug} already exists. If you want to edit it, use edit command</Text>
</>
);
}
return (
<Box flexDirection="column">
<Box flexDirection="column">
<Box>
<Text color="green">{`${fieldLabel}:`}</Text>
{field.type == "text" ? (
<TextInput
value={fieldValue}
onSubmit={(value) => {
if (!value) {
return;
}
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field.options}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}
/>
)}
</Box>
<Box>
<Text color="gray" italic>
{field.explainer}
</Text>
</Box>
</Box>
</Box>
);
};
const DeleteApp = ({ noDbUpdate, slug }) => {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [allowDeletion, setAllowDeletion] = useState(false);
const [state, setState] = useState({ done: null, description: null });
useEffect(() => {
if (allowDeletion) {
BaseAppFork.delete({ slug });
Seed.revert({ slug });
generateAppFiles();
setState({ description: `App with slug ${slug} has been deleted`, done: true });
}
}, [allowDeletion, slug]);
return (
<>
<Text>
Confirm the slug of the app that you want to delete. Note, that it would cleanup the app directory,
App table and Credential table
</Text>
{!state.done && (
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState({ description: `Deletion started`, done: true });
setAllowDeletion(true);
} else {
setState({ description: `Slug doesn't match - Should have been ${slug}`, done: true });
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}
/>
)}
<Text>{state.description}</Text>
</>
);
};
const App: FC<{ noDbUpdate?: boolean; command: "create" | "delete"; slug?: string }> = ({
command,
noDbUpdate,
slug,
}) => {
if (command === "create") {
return <CreateApp noDbUpdate={noDbUpdate} />;
}
if (command === "delete") {
return <DeleteApp slug={slug} noDbUpdate={noDbUpdate} />;
}
if (command === "edit") {
return <CreateApp slug={slug} editMode={true} noDbUpdate={noDbUpdate} />;
}
};
module.exports = App;
export default App;

View File

@ -1,278 +0,0 @@
import chokidar from "chokidar";
import fs from "fs";
import { debounce } from "lodash";
import path from "path";
import prettier from "prettier";
import { AppMeta } from "@calcom/types/App";
import prettierConfig from "../../config/prettier-preset";
import execSync from "./execSync";
function isFileThere(path) {
try {
fs.statSync(path);
return true;
} catch (e) {
return false;
}
}
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const formatOutput = (source: string) => prettier.format(source, prettierConfig);
const getVariableName = function (appName: string) {
return appName.replace("-", "_");
};
const getAppId = function (app: { name: string }) {
// Handle stripe separately as it's an old app with different dirName than slug/appId
return app.name === "stripepayment" ? "stripe" : app.name;
};
const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
type App = Partial<AppMeta> & {
name: string;
path: string;
};
function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (
!candidatePath.startsWith("_") &&
candidatePath !== "ee" &&
!candidatePath.includes("/") &&
!candidatePath.includes("\\")
) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
}
// Get dirname of app from full path
const dirName = path.relative(APP_STORE_PATH, candidatePath);
return isValidAppName(dirName) ? dirName : null;
}
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (dir === "ee") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (eeDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, eeDir)).isDirectory()) {
if (!getAppName(path.resolve(eeDir))) {
appDirs.push({
name: eeDir,
path: path.join(dir, eeDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
}
});
function forEachAppDir(callback: (arg: App) => void) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else {
app = {};
}
callback({
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
}
}
forEachAppDir((app) => {
const templateDestinationDir = path.join(APP_STORE_PATH, app.path, "extensions");
const templateDestinationFilePath = path.join(templateDestinationDir, "EventTypeAppCard.tsx");
const zodDestinationFilePath = path.join(APP_STORE_PATH, app.path, "zod.ts");
if (app.extendsFeature === "EventType" && !isFileThere(templateDestinationFilePath)) {
execSync(`mkdir -p ${templateDestinationDir}`);
execSync(`cp ../app-store/_templates/extensions/EventTypeAppCard.tsx ${templateDestinationFilePath}`);
execSync(`cp ../app-store/_templates/zod.ts ${zodDestinationFilePath}`);
}
});
function getObjectExporter(
objectName,
{
fileToBeImported,
importBuilder,
entryBuilder,
}: {
fileToBeImported: string;
importBuilder?: (arg: App) => string;
entryBuilder: (arg: App) => string;
}
) {
const output = [];
forEachAppDir((app) => {
if (
fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported)) &&
typeof importBuilder === "function"
) {
output.push(importBuilder(app));
}
});
output.push(`export const ${objectName} = {`);
forEachAppDir((app) => {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported))) {
output.push(entryBuilder(app));
}
});
output.push(`};`);
return output;
}
serverOutput.push(
...getObjectExporter("apiHandlers", {
fileToBeImported: "api/index.ts",
// Import path must have / even for windows and not \
entryBuilder: (app) => ` "${app.name}": import("./${app.path.replace(/\\/g, "/")}/api"),`,
})
);
metadataOutput.push(
...getObjectExporter("appStoreMetadata", {
fileToBeImported: "_metadata.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { metadata as ${getVariableName(app.name)}_meta } from "./${app.path.replace(
/\\/g,
"/"
)}/_metadata";`,
entryBuilder: (app) => ` "${app.name}":${getVariableName(app.name)}_meta,`,
})
);
schemasOutput.push(
...getObjectExporter("appDataSchemas", {
fileToBeImported: "zod.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { appDataSchema as ${getVariableName(app.name)}_schema } from "./${app.path.replace(
/\\/g,
"/"
)}/zod";`,
// Key must be appId as this is used by eventType metadata and lookup is by appId
entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_schema ,`,
})
);
appKeysSchemasOutput.push(
...getObjectExporter("appKeysSchemas", {
fileToBeImported: "zod.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { appKeysSchema as ${getVariableName(app.name)}_keys_schema } from "./${app.path.replace(
/\\/g,
"/"
)}/zod";`,
// Key must be appId as this is used by eventType metadata and lookup is by appId
entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_keys_schema ,`,
})
);
browserOutput.push(
...getObjectExporter("InstallAppButtonMap", {
fileToBeImported: "components/InstallAppButton.tsx",
entryBuilder: (app) =>
` ${app.name}: dynamic(() =>import("./${app.path}/components/InstallAppButton")),`,
})
);
// TODO: Make a component map creator that accepts ComponentName and does the rest.
// TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
browserOutput.push(
...getObjectExporter("AppSettingsComponentsMap", {
fileToBeImported: "components/AppSettings.tsx",
entryBuilder: (app) => ` ${app.name}: dynamic(() =>import("./${app.path}/components/AppSettings")),`,
})
);
browserOutput.push(
...getObjectExporter("EventTypeAddonMap", {
fileToBeImported: path.join("extensions", "EventTypeAppCard.tsx"),
entryBuilder: (app) =>
` ${app.name}: dynamic(() =>import("./${app.path}/extensions/EventTypeAppCard")),`,
})
);
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
Don't modify this file manually.
**/
`;
const filesToGenerate: [string, string[]][] = [
["apps.metadata.generated.ts", metadataOutput],
["apps.server.generated.ts", serverOutput],
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
});
console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`);
}
const debouncedGenerateFiles = debounce(generateFiles);
if (isInWatchMode) {
chokidar
.watch(APP_STORE_PATH)
.on("addDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Added ${appName}`);
debouncedGenerateFiles();
}
})
.on("change", (filePath) => {
if (filePath.endsWith("config.json")) {
console.log("Config file changed");
debouncedGenerateFiles();
}
})
.on("unlinkDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Removed ${appName}`);
debouncedGenerateFiles();
}
});
} else {
generateFiles();
}

View File

@ -0,0 +1,348 @@
import chokidar from "chokidar";
import fs from "fs";
import { debounce } from "lodash";
import path from "path";
import prettier from "prettier";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import prettierConfig from "@calcom/config/prettier-preset";
import { AppMeta } from "@calcom/types/App";
import { APP_STORE_PATH } from "./constants";
import { getAppName } from "./utils/getAppName";
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const formatOutput = (source: string) =>
prettier.format(source, {
parser: "babel",
...prettierConfig,
});
const getVariableName = function (appName: string) {
return appName.replace(/[-.]/g, "_");
};
const getAppId = function (app: { name: string }) {
// Handle stripe separately as it's an old app with different dirName than slug/appId
return app.name === "stripepayment" ? "stripe" : app.name;
};
type App = Partial<AppMeta> & {
name: string;
path: string;
};
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (dir === "ee" || dir === "templates") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (subDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, subDir)).isDirectory()) {
if (getAppName(subDir)) {
appDirs.push({
name: subDir,
path: path.join(dir, subDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
}
});
function forEachAppDir(callback: (arg: App) => void) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else {
app = {};
}
callback({
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
}
}
/**
* Windows has paths with backslashes, so we need to replace them with forward slashes
* .ts and .tsx files are imported without extensions
* If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part
*/
function getModulePath(path: string, moduleName: string) {
return (
`./${path.replace(/\\/g, "/")}/` +
moduleName.replace(/\/index\.ts|\/index\.tsx/, "").replace(/\.tsx$|\.ts$/, "")
);
}
type ImportConfig =
| {
fileToBeImported: string;
importName?: string;
}
| [
{
fileToBeImported: string;
importName?: string;
},
{
fileToBeImported: string;
importName: string;
}
];
/**
* If importConfig is an array, only 2 items are allowed. First one is the main one and second one is the fallback
*/
function getExportedObject(
objectName: string,
{
lazyImport = false,
importConfig,
entryObjectKeyGetter = (app) => app.name,
}: {
lazyImport?: boolean;
importConfig: ImportConfig;
entryObjectKeyGetter?: (arg: App, importName?: string) => string;
}
) {
const output: string[] = [];
const getLocalImportName = (
app: { name: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`;
const fileToBeImportedExists = (
app: { path: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => fs.existsSync(path.join(APP_STORE_PATH, app.path, chosenConfig.fileToBeImported));
addImportStatements();
createExportObject();
return output;
function addImportStatements() {
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) {
const importName = chosenConfig.importName;
if (!lazyImport) {
if (importName !== "default") {
// Import with local alias that will be used by createExportObject
output.push(
`import { ${importName} as ${getLocalImportName(app, chosenConfig)} } from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
} else {
// Default Import
output.push(
`import ${getLocalImportName(app, chosenConfig)} from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
}
}
}
});
}
function createExportObject() {
output.push(`export const ${objectName} = {`);
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig)) {
if (!lazyImport) {
const key = entryObjectKeyGetter(app);
output.push(`"${key}": ${getLocalImportName(app, chosenConfig)},`);
} else {
const key = entryObjectKeyGetter(app);
if (chosenConfig.fileToBeImported.endsWith(".tsx")) {
output.push(
`"${key}": dynamic(() => import("${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}")),`
);
} else {
output.push(`"${key}": import("${getModulePath(app.path, chosenConfig.fileToBeImported)}"),`);
}
}
}
});
output.push(`};`);
}
function getChosenImportConfig(importConfig: ImportConfig, app: { path: string }) {
let chosenConfig;
if (!(importConfig instanceof Array)) {
chosenConfig = importConfig;
} else {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, importConfig[0].fileToBeImported))) {
chosenConfig = importConfig[0];
} else {
chosenConfig = importConfig[1];
}
}
return chosenConfig;
}
}
serverOutput.push(
...getExportedObject("apiHandlers", {
importConfig: {
fileToBeImported: "api/index.ts",
},
lazyImport: true,
})
);
metadataOutput.push(
...getExportedObject("appStoreMetadata", {
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
})
);
schemasOutput.push(
...getExportedObject("appDataSchemas", {
// Import path must have / even for windows and not \
importConfig: {
fileToBeImported: "zod.ts",
importName: "appDataSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
appKeysSchemasOutput.push(
...getExportedObject("appKeysSchemas", {
importConfig: {
fileToBeImported: "zod.ts",
importName: "appKeysSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
browserOutput.push(
...getExportedObject("InstallAppButtonMap", {
importConfig: {
fileToBeImported: "components/InstallAppButton.tsx",
},
lazyImport: true,
})
);
// TODO: Make a component map creator that accepts ComponentName and does the rest.
// TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
browserOutput.push(
...getExportedObject("AppSettingsComponentsMap", {
importConfig: {
fileToBeImported: "components/AppSettingsInterface.tsx",
},
lazyImport: true,
})
);
browserOutput.push(
...getExportedObject("EventTypeAddonMap", {
importConfig: {
fileToBeImported: path.join("components", "EventTypeAppCardInterface.tsx"),
},
lazyImport: true,
})
);
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
Don't modify this file manually.
**/
`;
const filesToGenerate: [string, string[]][] = [
["apps.metadata.generated.ts", metadataOutput],
["apps.server.generated.ts", serverOutput],
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
});
console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`);
}
const debouncedGenerateFiles = debounce(generateFiles);
if (isInWatchMode) {
chokidar
.watch(APP_STORE_PATH)
.on("addDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Added ${appName}`);
debouncedGenerateFiles();
}
})
.on("change", (filePath) => {
if (filePath.endsWith("config.json")) {
console.log("Config file changed");
debouncedGenerateFiles();
}
})
.on("unlinkDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Removed ${appName}`);
debouncedGenerateFiles();
}
});
} else {
generateFiles();
}

View File

@ -3,23 +3,35 @@ import { render } from "ink";
import meow from "meow";
import React from "react";
import App from "./CliApp";
import App from "./App";
import { SupportedCommands } from "./types";
const cli = meow(
`
Usage
$ app-store create/delete/edit - Edit and Delete commands must be used on apps created using cli
$ 'app-store create' or 'app-store create-template' - Creates a new app or template
Options
[--template -t] Template to use.
Options
[--slug] Slug. This is the name of app dir for apps created with cli.
$ 'app-store edit' or 'app-store edit-template' - Edit the App or Template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
$ 'app-store delete' or 'app-store delete-template' - Deletes the app or template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
`,
{
flags: {
noDbUpdate: {
type: "boolean",
},
slug: {
type: "string",
alias: "s",
},
template: {
type: "string",
alias: "t",
},
},
allowUnknownFlags: false,
@ -30,20 +42,32 @@ if (cli.input.length !== 1) {
cli.showHelp();
}
const command = cli.input[0] as "create" | "delete" | "edit";
const supportedCommands = ["create", "delete", "edit"];
const command = cli.input[0] as SupportedCommands;
const supportedCommands = [
"create",
"delete",
"edit",
"create-template",
"delete-template",
"edit-template",
] as const;
if (!supportedCommands.includes(command)) {
cli.showHelp();
}
let slug;
let slug = null;
if (command === "delete" || command === "edit") {
if (
command === "delete" ||
command === "edit" ||
command === "delete-template" ||
command === "edit-template"
) {
slug = cli.flags.slug;
if (!slug) {
console.log("--slug is required");
cli.showHelp();
cli.showHelp(0);
}
}
render(<App slug={slug} command={command} noDbUpdate={cli.flags.noDbUpdate} />);
render(<App slug={slug} template={cli.flags.template || ""} command={command} />);

View File

@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Create(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="create-template" {...props} />;
}

View File

@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function CreateTemplate(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="create-template" {...props} />;
}

View File

@ -0,0 +1,7 @@
import React from "react";
import DeleteForm from "../components/DeleteForm";
export default function Delete({ slug }: { slug: string }) {
return <DeleteForm slug={slug} action="delete" />;
}

View File

@ -0,0 +1,7 @@
import React from "react";
import DeleteForm from "../components/DeleteForm";
export default function Delete({ slug }: { slug: string }) {
return <DeleteForm slug={slug} action="delete-template" />;
}

View File

@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Edit(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="edit" {...props} />;
}

View File

@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Edit(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="edit-template" {...props} />;
}

View File

@ -0,0 +1,353 @@
import fs from "fs";
import { Box, Newline, Text, useApp } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import React, { useEffect, useState } from "react";
import { AppMeta } from "@calcom/types/App";
import { getSlugFromAppName, BaseAppFork, Seed, generateAppFiles, getAppDirPath } from "../core";
import { getApp } from "../utils/getApp";
import Templates from "../utils/templates";
import Label from "./Label";
import { Message } from "./Message";
export const AppForm = ({
template: cliTemplate = "",
slug: givenSlug = "",
action,
}: {
template?: string;
slug?: string;
action: "create" | "edit" | "create-template" | "edit-template";
}) => {
cliTemplate = Templates.find((t) => t.value === cliTemplate)?.value || "";
const { exit } = useApp();
const isTemplate = action === "create-template" || action === "edit-template";
const isEditAction = action === "edit" || action === "edit-template";
let initialConfig = {
template: cliTemplate,
name: "",
description: "",
category: "",
publisher: "",
email: "",
};
const [app] = useState(() => getApp(givenSlug, isTemplate));
if ((givenSlug && action === "edit-template") || action === "edit")
try {
const config = JSON.parse(
fs.readFileSync(`${getAppDirPath(givenSlug, isTemplate)}/config.json`).toString()
) as AppMeta;
initialConfig = {
...config,
category: config.categories[0],
template: config.__template,
};
} catch (e) {}
const fields = [
{
label: "App Title",
name: "name",
type: "text",
explainer: "Keep it short and sweet like 'Google Meet'",
optional: false,
defaultValue: "",
},
{
label: "App Description",
name: "description",
type: "text",
explainer:
"A detailed description of your app. You can later modify DESCRIPTION.mdx to add markdown as well",
optional: false,
defaultValue: "",
},
// You can't edit the base template of an App or Template - You need to start fresh for that.
cliTemplate || isEditAction
? null
: {
label: "Choose a base Template",
name: "template",
type: "select",
options: Templates,
optional: false,
defaultValue: "",
},
{
optional: false,
label: "Category of App",
name: "category",
type: "select",
options: [
{ label: "Calendar", value: "calendar" },
{ label: "Video", value: "video" },
{ label: "Payment", value: "payment" },
{ label: "Messaging", value: "messaging" },
{ label: "Web3", value: "web3" },
{ label: "Automation", value: "automation" },
{ label: "Analytics", value: "analytics" },
{ label: "Other", value: "other" },
],
defaultValue: "",
explainer: "This is how apps are categorized in App Store.",
},
{
optional: true,
label: "Publisher Name",
name: "publisher",
type: "text",
explainer: "Let users know who you are",
defaultValue: "Your Name",
},
{
optional: true,
label: "Publisher Email",
name: "email",
type: "text",
explainer: "Let users know how they can contact you.",
defaultValue: "email@example.com",
},
].filter((f) => f);
const [appInputData, setAppInputData] = useState(initialConfig);
const [inputIndex, setInputIndex] = useState(0);
const [slugFinalized, setSlugFinalized] = useState(false);
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
let fieldValue = appInputData[fieldName as keyof typeof appInputData] || "";
let validationResult: Parameters<typeof Message>[0]["message"] | null = null;
const { name, category, description, publisher, email, template } = appInputData;
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
const formCompleted = inputIndex === fields.length;
if (field?.name === "appCategory") {
// Use template category as the default category
fieldValue = Templates.find((t) => t.value === appInputData["template"])?.category || "";
}
const slug = getSlugFromAppName(name) || givenSlug;
useEffect(() => {
// When all fields have been filled
(async () => {
if (formCompleted) {
await BaseAppFork.create({
category,
description,
name,
slug,
publisher,
email,
template,
editMode: isEditAction,
isTemplate,
oldSlug: givenSlug,
});
await Seed.update({ slug, category: category, oldSlug: givenSlug, isTemplate });
await generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setStatus("done");
}
})();
}, [formCompleted]);
if (action === "edit" || action === "edit-template") {
if (!slug) {
return <Text>--slug is required</Text>;
}
if (!app) {
return (
<Message
message={{
text: `App with slug ${givenSlug} not found`,
type: "error",
}}
/>
);
}
}
if (status === "done") {
// HACK: This is a hack to exit the process manually because due to some reason cli isn't automatically exiting
setTimeout(() => {
exit();
}, 500);
}
if (formCompleted) {
return (
<Box flexDirection="column">
{status !== "done" && (
<Message
key="progressHeading"
message={{
text: isEditAction
? `Editing app with slug ${slug}`
: `Creating ${
action === "create-template" ? "template" : "app"
} with name '${name}' categorized in '${category}' using template '${template}'`,
type: "info",
showInProgressIndicator: true,
}}
/>
)}
{status === "done" && (
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
<Text bold>
Just wait for a few seconds for process to exit and then you are good to go. Your{" "}
{isTemplate ? "Template" : "App"} code exists at {getAppDirPath(slug, isTemplate)}
</Text>
<Text>
Tip : Go and change the logo of your {isTemplate ? "template" : "app"} by replacing{" "}
{getAppDirPath(slug, isTemplate) + "/static/icon.svg"}
</Text>
<Newline />
<Text bold underline color="blue">
App Summary:
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text color="green">Slug: </Text>
<Text>{slug}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">{isTemplate ? "Template" : "App"} URL: </Text>
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Name: </Text>
<Text>{name}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Description: </Text>
<Text>{description}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Category: </Text>
<Text>{category}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Name: </Text>
<Text>{publisher}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Email: </Text>
<Text>{email}</Text>
</Box>
</Box>
</Box>
)}
<Text italic color="gray">
Note: You should not rename app directory manually. Use cli only to do that as it needs to be
updated in DB as well
</Text>
</Box>
);
}
if (slug && slug !== givenSlug && fs.existsSync(getAppDirPath(slug, isTemplate))) {
validationResult = {
text: `${
action === "create" ? "App" : "Template"
} with slug ${slug} already exists. If you want to edit it, use edit command`,
type: "error",
};
if (slugFinalized) {
return <Message message={validationResult} />;
}
}
const selectedOptionIndex =
field?.type === "select" ? field?.options?.findIndex((o) => o.value === fieldValue) : 0;
return (
<Box flexDirection="column">
<Box flexDirection="column">
{isEditAction ? (
<Message
message={{
text: `\nLet's edit your ${isTemplate ? "Template" : "App"}! We have prefilled the details.\n`,
}}
/>
) : (
<Message
message={{
text: `\nLet's create your ${
isTemplate ? "Template" : "App"
}! Start by providing the information that's asked\n`,
}}
/>
)}
<Box>
<Label>{`${fieldLabel}`}</Label>
{field?.type == "text" ? (
<TextInput
value={fieldValue}
placeholder={field?.defaultValue}
onSubmit={(value) => {
if (!value && !field.optional) {
return;
}
setSlugFinalized(true);
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field?.options}
itemComponent={(item) => {
const myItem = item as { value: string; label: string };
return (
<Box justifyContent="space-between">
<Box flexShrink={0} flexGrow={1}>
<Text color="blue">{myItem.value}: </Text>
</Box>
<Text>{item.label}</Text>
</Box>
);
}}
key={fieldName}
initialIndex={selectedOptionIndex === -1 ? 0 : selectedOptionIndex}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}
/>
)}
</Box>
<Box>
{validationResult ? (
<Message message={validationResult} />
) : (
<Text color="gray" italic>
{field?.explainer}
</Text>
)}
</Box>
</Box>
</Box>
);
};

View File

@ -0,0 +1,109 @@
import { Text } from "ink";
import TextInput from "ink-text-input";
import React, { useEffect, useState } from "react";
import { ImportantText } from "../components/ImportantText";
import { Message } from "../components/Message";
import { BaseAppFork, Seed, generateAppFiles } from "../core";
import { getApp } from "../utils/getApp";
export default function DeleteForm({ slug, action }: { slug: string; action: "delete" | "delete-template" }) {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [state, setState] = useState<
| "INITIALIZED"
| "DELETION_CONFIRMATION_FAILED"
| "DELETION_CONFIRMATION_SUCCESSFUL"
| "DELETION_COMPLETED"
| "APP_NOT_EXISTS"
>("INITIALIZED");
const isTemplate = action === "delete-template";
const app = getApp(slug, isTemplate);
useEffect(() => {
if (!app) {
setState("APP_NOT_EXISTS");
}
}, []);
useEffect(() => {
if (state === "DELETION_CONFIRMATION_SUCCESSFUL") {
(async () => {
await BaseAppFork.delete({ slug, isTemplate });
Seed.revert({ slug });
await generateAppFiles();
// successMsg({ text: `App with slug ${slug} has been deleted`, done: true });
setState("DELETION_COMPLETED");
})();
}
}, [slug, state]);
if (state === "INITIALIZED") {
return (
<>
<ImportantText>
Type below the slug of the {isTemplate ? "Template" : "App"} that you want to delete.
</ImportantText>
<Text color="gray" italic>
It would cleanup the app directory and App table and Credential table.
</Text>
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState("DELETION_CONFIRMATION_SUCCESSFUL");
} else {
setState("DELETION_CONFIRMATION_FAILED");
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}
/>
</>
);
}
if (state === "APP_NOT_EXISTS") {
return (
<Message
message={{
text: `${isTemplate ? "Template" : "App"} with slug ${slug} doesn't exist`,
type: "error",
}}
/>
);
}
if (state === "DELETION_CONFIRMATION_SUCCESSFUL") {
return (
<Message
message={{
text: `Deleting ${isTemplate ? "Template" : "App"}`,
type: "info",
showInProgressIndicator: true,
}}
/>
);
}
if (state === "DELETION_COMPLETED") {
return (
<Message
message={{
text: `${
isTemplate ? "Template" : "App"
} with slug "${slug}" has been deleted. You might need to restart your dev environment`,
type: "success",
}}
/>
);
}
if (state === "DELETION_CONFIRMATION_FAILED") {
return (
<Message
message={{
text: `Slug doesn't match - Should have been ${slug}`,
type: "error",
}}
/>
);
}
return null;
}

View File

@ -0,0 +1,6 @@
import { Text } from "ink";
import React from "react";
export function ImportantText({ children }: { children: React.ReactNode }) {
return <Text color="red">{children}</Text>;
}

View File

@ -0,0 +1,11 @@
import { Box, Text } from "ink";
import React from "react";
export default function Label({ children }: { children: React.ReactNode }) {
return (
<Box>
<Text underline>{children}</Text>
<Text>: </Text>
</Box>
);
}

View File

@ -0,0 +1,29 @@
import { Text } from "ink";
import React, { useEffect, useState } from "react";
export function Message({
message,
}: {
message: { text: string; type?: "info" | "error" | "success"; showInProgressIndicator?: boolean };
}) {
const color = message.type === "success" ? "green" : message.type === "error" ? "red" : "white";
const [progressText, setProgressText] = useState("...");
useEffect(() => {
if (message.showInProgressIndicator) {
const interval = setInterval(() => {
setProgressText((progressText) => {
return progressText.length > 3 ? "" : progressText + ".";
});
}, 1000);
return () => {
clearInterval(interval);
};
}
}, [message.showInProgressIndicator]);
return (
<Text color={color}>
{message.text}
{message.showInProgressIndicator && progressText}
</Text>
);
}

View File

@ -0,0 +1,4 @@
import path from "path";
export const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
export const TEMPLATES_PATH = path.join(APP_STORE_PATH, "templates");

View File

@ -0,0 +1,192 @@
import fs from "fs";
import path from "path";
import seedAppStoreConfig from "@calcom/prisma/seed-app-store.config.json";
import { APP_STORE_PATH, TEMPLATES_PATH } from "./constants";
import execSync from "./utils/execSync";
const slugify = (str: string) => {
// A valid dir name
// A valid URL path
// It is okay to not be a valid variable name. This is so that we can use hyphens which look better then underscores in URL and as directory name
return str.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
};
export function getSlugFromAppName(appName: string): string {
if (!appName) {
return appName;
}
return slugify(appName);
}
export function getAppDirPath(slug: string, isTemplate: boolean) {
if (!isTemplate) {
return path.join(APP_STORE_PATH, `${slug}`);
}
return path.join(TEMPLATES_PATH, `${slug}`);
}
function absolutePath(appRelativePath: string) {
return path.join(APP_STORE_PATH, appRelativePath);
}
const updatePackageJson = ({
slug,
appDescription,
appDirPath,
}: {
slug: string;
appDescription: string;
appDirPath: string;
}) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
export const BaseAppFork = {
create: async function ({
category,
editMode = false,
description,
name,
slug,
publisher,
email,
template,
isTemplate,
oldSlug,
}: {
category: string;
editMode?: boolean;
description: string;
name: string;
slug: string;
publisher: string;
email: string;
template: string;
isTemplate: boolean;
oldSlug?: string;
}) {
const appDirPath = getAppDirPath(slug, isTemplate);
if (!editMode) {
await execSync(`mkdir -p ${appDirPath}`);
await execSync(`cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}`);
} else {
if (!oldSlug) {
throw new Error("oldSlug is required when editMode is true");
}
if (oldSlug !== slug) {
// We need to rename only if they are different
const oldAppDirPath = getAppDirPath(oldSlug, isTemplate);
await execSync(`mv ${oldAppDirPath} ${appDirPath}`);
}
}
updatePackageJson({ slug, appDirPath, appDescription: description });
const categoryToVariantMap = {
video: "conferencing",
};
let config = {
name: name,
// Plan to remove it. DB already has it and name of dir is also the same.
slug: slug,
type: `${slug}_${category}`,
// TODO: Remove usage of imageSrc, it is being used in ConnectCalendars.tsx. After that delete imageSrc in all configs and from here
imageSrc: `icon.svg`,
logo: `icon.svg`,
variant: categoryToVariantMap[category as keyof typeof categoryToVariantMap] || category,
categories: [category],
publisher: publisher,
email: email,
description: description,
// TODO: Use this to avoid edit and delete on the apps created outside of cli
__createdUsingCli: true,
isTemplate,
// Store the template used to create an app
__template: template,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
fs.writeFileSync(
`${appDirPath}/DESCRIPTION.md`,
fs
.readFileSync(`${appDirPath}/DESCRIPTION.md`)
.toString()
.replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug)
);
},
delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
const appDirPath = getAppDirPath(slug, isTemplate);
await execSync(`rm -rf ${appDirPath}`);
},
};
export const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: async function ({
slug,
category,
oldSlug,
isTemplate,
}: {
slug: string;
category: string;
oldSlug: string;
isTemplate: boolean;
}) {
let configContent = "[]";
try {
if (fs.statSync(this.seedConfigPath)) {
configContent = fs.readFileSync(this.seedConfigPath).toString();
}
} catch (e) {}
let seedConfig: typeof seedAppStoreConfig = JSON.parse(configContent);
seedConfig = seedConfig.filter((app) => app.slug !== oldSlug);
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [category],
slug: slug,
type: `${slug}_${category}`,
isTemplate: isTemplate,
});
}
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
await execSync(`cd ${workspaceDir}/packages/prisma && yarn seed-app-store seed-templates`);
},
revert: async function ({ slug }: { slug: string }) {
let seedConfig: typeof seedAppStoreConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
await execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
},
};
export const generateAppFiles = async () => {
await execSync(`yarn ts-node --transpile-only src/build.ts`);
};

View File

@ -1,13 +0,0 @@
import child_process from "child_process";
const execSync = (cmd: string) => {
if (process.env.DEBUG === "1") {
console.log(`${process.cwd()}$: ${cmd}`);
}
const result = child_process.execSync(cmd).toString();
if (process.env.DEBUG === "1") {
console.log(result);
}
return cmd;
};
export default execSync;

7
packages/app-store-cli/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export type SupportedCommands =
| "create"
| "edit"
| "delete"
| "create-template"
| "delete-template"
| "edit-template";

View File

@ -0,0 +1,26 @@
import child_process from "child_process";
const execSync = async (cmd: string) => {
const silent = process.env.DEBUG === "1" ? false : true;
if (!silent) {
console.log(`${process.cwd()}$: ${cmd}`);
}
const result: string = await new Promise((resolve, reject) => {
child_process.exec(cmd, (err, stdout, stderr) => {
if (err) {
reject(err);
console.log(err);
}
if (stderr && !silent) {
console.log(stderr);
}
resolve(stdout);
});
});
if (!silent) {
console.log(result.toString());
}
return cmd;
};
export default execSync;

View File

@ -0,0 +1,26 @@
import fs from "fs";
import path from "path";
import { APP_STORE_PATH, TEMPLATES_PATH } from "../constants";
import { getAppName } from "./getAppName";
export const getApp = (slug: string, isTemplate: boolean) => {
const base = isTemplate ? TEMPLATES_PATH : APP_STORE_PATH;
const foundApp = fs
.readdirSync(base)
.filter((dir) => {
if (fs.statSync(path.join(base, dir)).isDirectory() && getAppName(dir)) {
return true;
}
return false;
})
.find((appName) => appName === slug);
if (foundApp) {
try {
return JSON.parse(fs.readFileSync(path.join(base, foundApp, "config.json")).toString());
} catch (e) {
return {};
}
}
return null;
};

View File

@ -0,0 +1,23 @@
import path from "path";
import { APP_STORE_PATH } from "../constants";
export function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (
!candidatePath.startsWith("_") &&
candidatePath !== "ee" &&
!candidatePath.includes("/") &&
!candidatePath.includes("\\")
) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
}
// Get dirname of app from full path
const dirName = path.relative(APP_STORE_PATH, candidatePath);
return isValidAppName(dirName) ? dirName : null;
}

View File

@ -0,0 +1,29 @@
import fs from "fs";
import path from "path";
import { TEMPLATES_PATH } from "../constants";
import { getAppName } from "./getAppName";
const Templates = fs
.readdirSync(TEMPLATES_PATH)
.filter((dir) => {
if (fs.statSync(path.join(TEMPLATES_PATH, dir)).isDirectory() && getAppName(dir)) {
return true;
}
return false;
})
.map((dir) => {
try {
const config = JSON.parse(fs.readFileSync(path.join(TEMPLATES_PATH, dir, "config.json")).toString());
return {
label: `${config.description}`,
value: dir,
category: config.categories[0],
};
} catch (e) {
// config.json might not exist
return null;
}
})
.filter((item) => !!item) as { label: string; value: string; category: string }[];
export default Templates;

View File

@ -1,12 +1,14 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"jsx": "react",
"esModuleInterop": true,
"outDir": "dist",
"noEmitOnError": false,
"target": "ES2020",
"baseUrl": "."
"baseUrl": ".",
"resolveJsonModule": true
},
"include": [
"next-env.d.ts",

View File

@ -1,13 +1,9 @@
import Script from "next/script";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { trackingApps } from "./eventTypeAnalytics";
export type AppScript = { attrs?: Record<string, string> } & (
| { src: undefined; content?: string }
| { src?: string; content: undefined }
);
import { appDataSchemas } from "./apps.schemas.generated";
export default function BookingPageTagManager({
eventType,
@ -16,16 +12,20 @@ export default function BookingPageTagManager({
}) {
return (
<>
{Object.entries(trackingApps).map(([appId, scriptConfig]) => {
const trackingId = getEventTypeAppData(eventType, appId as keyof typeof trackingApps)?.trackingId;
{Object.entries(appStoreMetadata).map(([appId, app]) => {
const tag = app.appData?.tag;
if (!tag) {
return null;
}
const trackingId = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas)?.trackingId;
if (!trackingId) {
return null;
}
const parseValue = <T extends string | undefined>(val: T): T =>
val ? (val.replace(/\{TRACKING_ID\}/g, trackingId) as T) : val;
return scriptConfig.scripts.map((script, index) => {
const parsedAttributes: NonNullable<AppScript["attrs"]> = {};
return tag.scripts.map((script, index) => {
const parsedAttributes: NonNullable<typeof tag.scripts[number]["attrs"]> = {};
const attrs = script.attrs || {};
Object.entries(attrs).forEach(([name, value]) => {
if (typeof value === "string") {

View File

@ -1,25 +1,18 @@
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma";
import { AppFrontendPayload as App } from "@calcom/types/App";
import { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
export async function getAppWithMetadata(app: { dirName: string }) {
let appMetadata: App | null = null;
try {
appMetadata = (await import(`./${app.dirName}/_metadata`)).default as App;
} catch (error) {
try {
appMetadata = (await import(`./ee/${app.dirName}/_metadata`)).default as App;
} catch (e) {
if (error instanceof Error) {
console.error(`No metadata found for: "${app.dirName}". Message:`, error.message);
}
return null;
}
}
const appMetadata: App | null = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata] as App;
if (!appMetadata) return null;
// Let's not leak api keys to the front end
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...metadata } = appMetadata;
if (metadata.logo && !metadata.logo.includes("/api/app-store/")) {
const appDirName = `${metadata.isTemplate ? "templates" : ""}/${app.dirName}`;
metadata.logo = `/api/app-store/${appDirName}/${metadata.logo}`;
}
return metadata;
}

View File

@ -1,9 +0,0 @@
---
description: _DESCRIPTION_
items:
- /api/app-store/_APP_DIR_/1.jpg
- /api/app-store/_APP_DIR_/2.jpg
- /api/app-store/_APP_DIR_/3.jpg
---
_DESCRIPTION_

View File

@ -1,10 +0,0 @@
import type { AppMeta } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "other",
...config,
} as AppMeta;
export default metadata;

View File

@ -1,3 +0,0 @@
{
"/*": "This file would be automatically updated by cli according to the inputs"
}

View File

@ -1,2 +0,0 @@
export * as api from "./api";
export { metadata } from "./_metadata";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 73.7" style="enable-background:new 0 0 122.88 73.7" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M8.34,0h106.2c4.59,0,8.34,3.77,8.34,8.34v57.02c0,4.56-3.77,8.34-8.34,8.34H8.34C3.77,73.7,0,69.95,0,65.36 V8.34C0,3.75,3.75,0,8.34,0L8.34,0z M18.5,21.47h5.98c3.86,0,6.48,0.18,7.84,0.53c1.36,0.35,2.4,0.93,3.11,1.74 c0.71,0.81,1.16,1.72,1.33,2.71c0.18,0.99,0.26,2.95,0.26,5.86v10.78c0,2.76-0.13,4.6-0.4,5.53c-0.26,0.93-0.71,1.66-1.36,2.18 c-0.64,0.53-1.44,0.89-2.39,1.11c-0.95,0.21-2.38,0.31-4.3,0.31H18.5V21.47L18.5,21.47z M26.49,26.73v20.23 c1.16,0,1.87-0.23,2.13-0.69c0.27-0.46,0.4-1.71,0.4-3.78V30.55c0-1.39-0.04-2.29-0.12-2.68c-0.1-0.39-0.29-0.67-0.61-0.86 C27.96,26.82,27.37,26.73,26.49,26.73L26.49,26.73z M40.68,21.47h13.34v6.16h-5.34v5.83h5v5.86h-5v6.77h5.87v6.15H40.68V21.47 L40.68,21.47z M82.22,21.47v30.76h-6.99V31.47l-2.79,20.76h-4.96l-2.95-20.29v20.29h-6.99V21.47h10.36 c0.31,1.85,0.62,4.03,0.97,6.54l1.1,7.83l1.83-14.37H82.22L82.22,21.47z M104.38,39.48c0,3.09-0.07,5.28-0.21,6.56 c-0.15,1.29-0.6,2.46-1.36,3.53c-0.77,1.06-1.8,1.88-3.11,2.45c-1.31,0.57-2.83,0.86-4.56,0.86c-1.65,0-3.13-0.27-4.44-0.81 c-1.32-0.54-2.37-1.34-3.17-2.42c-0.8-1.08-1.28-2.25-1.43-3.51c-0.15-1.27-0.23-3.48-0.23-6.66v-5.26c0-3.09,0.07-5.28,0.23-6.58 c0.14-1.28,0.59-2.46,1.36-3.52c0.77-1.06,1.8-1.88,3.11-2.45c1.3-0.57,2.82-0.86,4.56-0.86c1.65,0,3.12,0.27,4.43,0.81 c1.31,0.54,2.37,1.35,3.17,2.42c0.79,1.08,1.27,2.25,1.42,3.52c0.15,1.26,0.23,3.48,0.23,6.66V39.48L104.38,39.48z M96.39,29.38 c0-1.44-0.08-2.35-0.24-2.75c-0.15-0.4-0.48-0.6-0.98-0.6c-0.42,0-0.74,0.16-0.96,0.49c-0.23,0.32-0.34,1.27-0.34,2.86v14.35 c0,1.8,0.07,2.9,0.22,3.31c0.15,0.42,0.49,0.62,1.02,0.62c0.55,0,0.89-0.23,1.05-0.72c0.15-0.48,0.23-1.63,0.23-3.45V29.38 L96.39,29.38z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -4,10 +4,12 @@ export function DynamicComponent<T extends Record<string, any>>(props: {
wrapperClassName?: string;
}) {
const { componentMap, slug, ...rest } = props;
const dirName = slug === "stripe" ? "stripepayment" : slug;
// There can be apps with no matching component
if (!componentMap[slug]) return null;
const Component = componentMap[slug];
const Component = componentMap[dirName];
return (
<div className={props.wrapperClassName || ""}>

View File

@ -0,0 +1,23 @@
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
import { RouterOutputs } from "@calcom/trpc/react";
import { ErrorBoundary } from "@calcom/ui";
import { EventTypeAppCardComponentProps } from "../types";
import { DynamicComponent } from "./DynamicComponent";
export const EventTypeAppCard = (props: {
app: RouterOutputs["viewer"]["apps"][number];
eventType: EventTypeAppCardComponentProps["eventType"];
getAppData: GetAppData;
setAppData: SetAppData;
}) => {
const { app, getAppData, setAppData } = props;
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<DynamicComponent slug={app.slug} componentMap={EventTypeAddonMap} {...props} />
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
};

View File

@ -1,26 +0,0 @@
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: _package.name,
description: _package.description,
installed: true,
category: "video",
// If using static next public folder, can then be referenced from the base URL (/).
imageSrc: "/api/app-store/_example/icon.svg",
logo: "/api/app-store/_example/icon.svg",
publisher: "Cal.com",
rating: 5,
reviews: 69,
slug: "example_video",
title: "Example App",
trending: true,
type: "example_video",
url: "https://cal.com/",
variant: "conferencing",
verified: true,
email: "help@cal.com",
} as AppMeta;
export default metadata;

View File

@ -1,10 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
/**
* This is an example endpoint for an app, these will run under `/api/integrations/[...args]`
* @param req
* @param res
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200);
}

View File

@ -1 +0,0 @@
export { default as example } from "./example";

View File

@ -1,13 +0,0 @@
import { InstallAppButtonProps } from "../../types";
export default function InstallAppButton(props: InstallAppButtonProps) {
return (
<>
{props.render({
onClick() {
alert("You can put your install code in here!");
},
})}
</>
);
}

View File

@ -1 +0,0 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -1,3 +0,0 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";

View File

@ -1,36 +0,0 @@
import type { VideoApiAdapterFactory } from "@calcom/types/VideoApiAdapter";
/** This is a barebones factory function for a video integration */
const ExampleVideoApiAdapter: VideoApiAdapterFactory = (credential) => {
return {
getAvailability: async () => {
try {
return [];
} catch (err) {
console.error(err);
return [];
}
},
createMeeting: async (event) => {
return Promise.resolve({
type: "example_video",
id: "",
password: "",
url: "",
});
},
deleteMeeting: async (uid) => {
return Promise.resolve();
},
updateMeeting: async (bookingRef, event) => {
return Promise.resolve({
type: "example_video",
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
});
},
};
};
export default ExampleVideoApiAdapter;

View File

@ -1 +0,0 @@
export { default as VideoApiAdapter } from "./VideoApiAdapter";

View File

@ -1,6 +0,0 @@
<svg width="56" height="56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="56" rx="12" fill="#292929" />
<path
d="M18.628 36.781c2.846 0 4.946-1.096 6.183-2.59l-2.38-2.03c-.957 1.05-2.147 1.587-3.733 1.587-3.22 0-5.204-2.427-5.204-5.413 0-2.987 1.984-5.46 5.134-5.46 1.47 0 2.683.513 3.663 1.54l2.31-2.007c-1.47-1.75-3.313-2.567-5.973-2.567-5.04 0-8.517 3.803-8.517 8.493 0 4.667 3.663 8.447 8.517 8.447ZM31.69 36.781c2.17 0 3.267-.91 3.92-2.286v1.983h3.057V24.344H35.54v1.914c-.653-1.307-1.75-2.17-3.85-2.17-3.337 0-5.997 2.87-5.997 6.37s2.66 6.323 5.997 6.323Zm-2.847-6.346c0-1.89 1.354-3.5 3.36-3.5 2.077 0 3.407 1.633 3.407 3.523 0 1.89-1.33 3.477-3.407 3.477-2.006 0-3.36-1.657-3.36-3.5ZM41.472 36.478h3.15V19.444h-3.15v17.034Z"
fill="#fff" />
</svg>

Before

Width:  |  Height:  |  Size: 806 B

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,6 +11,5 @@
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "The joyful productivity app\r\r",
"extendsFeature": "User",
"__createdUsingCli": true
}

View File

@ -0,0 +1,18 @@
import { AppMeta } from "@calcom/types/App";
import { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: AppMeta;
};
export const appStoreMetadata = {} as AppStoreMetaData;
for (const [key, value] of Object.entries(rawAppStoreMetadata)) {
appStoreMetadata[key as keyof typeof appStoreMetadata] = {
appData: null,
__template: "",
...value,
} as AppStoreMetaData[keyof AppStoreMetaData];
}

View File

@ -30,15 +30,26 @@ export const InstallAppButtonMap = {
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
};
export const AppSettingsComponentsMap = {
weather_in_your_calendar: dynamic(() => import("./weather_in_your_calendar/components/AppSettings")),
zapier: dynamic(() => import("./zapier/components/AppSettings")),
"general-app-settings": dynamic(() =>
import("./templates/general-app-settings/components/AppSettingsInterface")
),
weather_in_your_calendar: dynamic(() =>
import("./weather_in_your_calendar/components/AppSettingsInterface")
),
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
};
export const EventTypeAddonMap = {
fathom: dynamic(() => import("./fathom/extensions/EventTypeAppCard")),
ga4: dynamic(() => import("./ga4/extensions/EventTypeAppCard")),
giphy: dynamic(() => import("./giphy/extensions/EventTypeAppCard")),
plausible: dynamic(() => import("./plausible/extensions/EventTypeAppCard")),
qr_code: dynamic(() => import("./qr_code/extensions/EventTypeAppCard")),
rainbow: dynamic(() => import("./rainbow/extensions/EventTypeAppCard")),
stripepayment: dynamic(() => import("./stripepayment/extensions/EventTypeAppCard")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
rainbow: dynamic(() => import("./rainbow/components/EventTypeAppCardInterface")),
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
"booking-pages-tag": dynamic(() =>
import("./templates/booking-pages-tag/components/EventTypeAppCardInterface")
),
"event-type-app-card": dynamic(() =>
import("./templates/event-type-app-card/components/EventTypeAppCardInterface")
),
};

View File

@ -2,46 +2,50 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appKeysSchema as dailyvideo_keys_schema } from "./dailyvideo/zod";
import { appKeysSchema as routing_forms_keys_schema } from "./ee/routing-forms/zod";
import { appKeysSchema as fathom_keys_schema } from "./fathom/zod";
import { appKeysSchema as ga4_keys_schema } from "./ga4/zod";
import { appKeysSchema as giphy_keys_schema } from "./giphy/zod";
import { appKeysSchema as googlecalendar_keys_schema } from "./googlecalendar/zod";
import { appKeysSchema as hubspot_keys_schema } from "./hubspot/zod";
import { appKeysSchema as larkcalendar_keys_schema } from "./larkcalendar/zod";
import { appKeysSchema as office365calendar_keys_schema } from "./office365calendar/zod";
import { appKeysSchema as office365video_keys_schema } from "./office365video/zod";
import { appKeysSchema as plausible_keys_schema } from "./plausible/zod";
import { appKeysSchema as qr_code_keys_schema } from "./qr_code/zod";
import { appKeysSchema as rainbow_keys_schema } from "./rainbow/zod";
import { appKeysSchema as salesforce_keys_schema } from "./salesforce/zod";
import { appKeysSchema as stripepayment_keys_schema } from "./stripepayment/zod";
import { appKeysSchema as tandemvideo_keys_schema } from "./tandemvideo/zod";
import { appKeysSchema as vital_keys_schema } from "./vital/zod";
import { appKeysSchema as wordpress_keys_schema } from "./wordpress/zod";
import { appKeysSchema as zapier_keys_schema } from "./zapier/zod";
import { appKeysSchema as zoomvideo_keys_schema } from "./zoomvideo/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appKeysSchema as routing_forms_zod_ts } from "./ee/routing-forms/zod";
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
import { appKeysSchema as ga4_zod_ts } from "./ga4/zod";
import { appKeysSchema as giphy_zod_ts } from "./giphy/zod";
import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
import { appKeysSchema as rainbow_zod_ts } from "./rainbow/zod";
import { appKeysSchema as salesforce_zod_ts } from "./salesforce/zod";
import { appKeysSchema as stripepayment_zod_ts } from "./stripepayment/zod";
import { appKeysSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
import { appKeysSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
import { appKeysSchema as vital_zod_ts } from "./vital/zod";
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
dailyvideo: dailyvideo_keys_schema,
"routing-forms": routing_forms_keys_schema,
fathom: fathom_keys_schema,
ga4: ga4_keys_schema,
giphy: giphy_keys_schema,
googlecalendar: googlecalendar_keys_schema,
hubspot: hubspot_keys_schema,
larkcalendar: larkcalendar_keys_schema,
office365calendar: office365calendar_keys_schema,
office365video: office365video_keys_schema,
plausible: plausible_keys_schema,
qr_code: qr_code_keys_schema,
rainbow: rainbow_keys_schema,
salesforce: salesforce_keys_schema,
stripe: stripepayment_keys_schema,
tandemvideo: tandemvideo_keys_schema,
vital: vital_keys_schema,
wordpress: wordpress_keys_schema,
zapier: zapier_keys_schema,
zoomvideo: zoomvideo_keys_schema,
dailyvideo: dailyvideo_zod_ts,
"routing-forms": routing_forms_zod_ts,
fathom: fathom_zod_ts,
ga4: ga4_zod_ts,
giphy: giphy_zod_ts,
googlecalendar: googlecalendar_zod_ts,
hubspot: hubspot_zod_ts,
larkcalendar: larkcalendar_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
plausible: plausible_zod_ts,
qr_code: qr_code_zod_ts,
rainbow: rainbow_zod_ts,
salesforce: salesforce_zod_ts,
stripe: stripepayment_zod_ts,
tandemvideo: tandemvideo_zod_ts,
"booking-pages-tag": booking_pages_tag_zod_ts,
"event-type-app-card": event_type_app_card_zod_ts,
vital: vital_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};

View File

@ -2,100 +2,112 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { metadata as amie_meta } from "./amie/_metadata";
import { metadata as applecalendar_meta } from "./applecalendar/_metadata";
import { metadata as around_meta } from "./around/_metadata";
import { metadata as caldavcalendar_meta } from "./caldavcalendar/_metadata";
import { metadata as campfire_meta } from "./campfire/_metadata";
import { metadata as closecom_meta } from "./closecom/_metadata";
import { metadata as dailyvideo_meta } from "./dailyvideo/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing-forms/_metadata";
import { metadata as exchange2013calendar_meta } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar_meta } from "./exchange2016calendar/_metadata";
import { metadata as exchangecalendar_meta } from "./exchangecalendar/_metadata";
import { metadata as fathom_meta } from "./fathom/_metadata";
import { metadata as ga4_meta } from "./ga4/_metadata";
import { metadata as giphy_meta } from "./giphy/_metadata";
import { metadata as googlecalendar_meta } from "./googlecalendar/_metadata";
import { metadata as googlevideo_meta } from "./googlevideo/_metadata";
import { metadata as hubspot_meta } from "./hubspot/_metadata";
import { metadata as huddle01video_meta } from "./huddle01video/_metadata";
import { metadata as jitsivideo_meta } from "./jitsivideo/_metadata";
import { metadata as larkcalendar_meta } from "./larkcalendar/_metadata";
import { metadata as n8n_meta } from "./n8n/_metadata";
import { metadata as office365calendar_meta } from "./office365calendar/_metadata";
import { metadata as office365video_meta } from "./office365video/_metadata";
import { metadata as ping_meta } from "./ping/_metadata";
import { metadata as pipedream_meta } from "./pipedream/_metadata";
import { metadata as plausible_meta } from "./plausible/_metadata";
import { metadata as qr_code_meta } from "./qr_code/_metadata";
import { metadata as rainbow_meta } from "./rainbow/_metadata";
import { metadata as raycast_meta } from "./raycast/_metadata";
import { metadata as riverside_meta } from "./riverside/_metadata";
import { metadata as salesforce_meta } from "./salesforce/_metadata";
import { metadata as sendgrid_meta } from "./sendgrid/_metadata";
import { metadata as signal_meta } from "./signal/_metadata";
import { metadata as sirius_video_meta } from "./sirius_video/_metadata";
import { metadata as stripepayment_meta } from "./stripepayment/_metadata";
import { metadata as tandemvideo_meta } from "./tandemvideo/_metadata";
import { metadata as telegram_meta } from "./telegram/_metadata";
import { metadata as typeform_meta } from "./typeform/_metadata";
import { metadata as vimcal_meta } from "./vimcal/_metadata";
import { metadata as vital_meta } from "./vital/_metadata";
import { metadata as weather_in_your_calendar_meta } from "./weather_in_your_calendar/_metadata";
import { metadata as whatsapp_meta } from "./whatsapp/_metadata";
import { metadata as whereby_meta } from "./whereby/_metadata";
import { metadata as wipemycalother_meta } from "./wipemycalother/_metadata";
import { metadata as wordpress_meta } from "./wordpress/_metadata";
import { metadata as zapier_meta } from "./zapier/_metadata";
import { metadata as zoomvideo_meta } from "./zoomvideo/_metadata";
import amie_config_json from "./amie/config.json";
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
import around_config_json from "./around/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import closecom_config_json from "./closecom/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import routing_forms_config_json from "./ee/routing-forms/config.json";
import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata";
import exchangecalendar_config_json from "./exchangecalendar/config.json";
import fathom_config_json from "./fathom/config.json";
import ga4_config_json from "./ga4/config.json";
import { metadata as giphy__metadata_ts } from "./giphy/_metadata";
import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata";
import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
import n8n_config_json from "./n8n/config.json";
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
import office365video_config_json from "./office365video/config.json";
import ping_config_json from "./ping/config.json";
import pipedream_config_json from "./pipedream/config.json";
import plausible_config_json from "./plausible/config.json";
import qr_code_config_json from "./qr_code/config.json";
import rainbow_config_json from "./rainbow/config.json";
import raycast_config_json from "./raycast/config.json";
import riverside_config_json from "./riverside/config.json";
import salesforce_config_json from "./salesforce/config.json";
import sendgrid_config_json from "./sendgrid/config.json";
import signal_config_json from "./signal/config.json";
import sirius_video_config_json from "./sirius_video/config.json";
import { metadata as stripepayment__metadata_ts } from "./stripepayment/_metadata";
import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
import telegram_config_json from "./telegram/config.json";
import basic_config_json from "./templates/basic/config.json";
import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json";
import event_type_app_card_config_json from "./templates/event-type-app-card/config.json";
import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json";
import general_app_settings_config_json from "./templates/general-app-settings/config.json";
import link_as_an_app_config_json from "./templates/link-as-an-app/config.json";
import typeform_config_json from "./typeform/config.json";
import vimcal_config_json from "./vimcal/config.json";
import { metadata as vital__metadata_ts } from "./vital/_metadata";
import weather_in_your_calendar_config_json from "./weather_in_your_calendar/config.json";
import whatsapp_config_json from "./whatsapp/config.json";
import whereby_config_json from "./whereby/config.json";
import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
import wordpress_config_json from "./wordpress/config.json";
import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
amie: amie_meta,
applecalendar: applecalendar_meta,
around: around_meta,
caldavcalendar: caldavcalendar_meta,
campfire: campfire_meta,
closecom: closecom_meta,
dailyvideo: dailyvideo_meta,
"routing-forms": routing_forms_meta,
exchange2013calendar: exchange2013calendar_meta,
exchange2016calendar: exchange2016calendar_meta,
exchangecalendar: exchangecalendar_meta,
fathom: fathom_meta,
ga4: ga4_meta,
giphy: giphy_meta,
googlecalendar: googlecalendar_meta,
googlevideo: googlevideo_meta,
hubspot: hubspot_meta,
huddle01video: huddle01video_meta,
jitsivideo: jitsivideo_meta,
larkcalendar: larkcalendar_meta,
n8n: n8n_meta,
office365calendar: office365calendar_meta,
office365video: office365video_meta,
ping: ping_meta,
pipedream: pipedream_meta,
plausible: plausible_meta,
qr_code: qr_code_meta,
rainbow: rainbow_meta,
raycast: raycast_meta,
riverside: riverside_meta,
salesforce: salesforce_meta,
sendgrid: sendgrid_meta,
signal: signal_meta,
sirius_video: sirius_video_meta,
stripepayment: stripepayment_meta,
tandemvideo: tandemvideo_meta,
telegram: telegram_meta,
typeform: typeform_meta,
vimcal: vimcal_meta,
vital: vital_meta,
weather_in_your_calendar: weather_in_your_calendar_meta,
whatsapp: whatsapp_meta,
whereby: whereby_meta,
wipemycalother: wipemycalother_meta,
wordpress: wordpress_meta,
zapier: zapier_meta,
zoomvideo: zoomvideo_meta,
amie: amie_config_json,
applecalendar: applecalendar__metadata_ts,
around: around_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
closecom: closecom_config_json,
dailyvideo: dailyvideo__metadata_ts,
"routing-forms": routing_forms_config_json,
exchange2013calendar: exchange2013calendar__metadata_ts,
exchange2016calendar: exchange2016calendar__metadata_ts,
exchangecalendar: exchangecalendar_config_json,
fathom: fathom_config_json,
ga4: ga4_config_json,
giphy: giphy__metadata_ts,
googlecalendar: googlecalendar__metadata_ts,
googlevideo: googlevideo__metadata_ts,
hubspot: hubspot__metadata_ts,
huddle01video: huddle01video__metadata_ts,
jitsivideo: jitsivideo__metadata_ts,
larkcalendar: larkcalendar__metadata_ts,
n8n: n8n_config_json,
office365calendar: office365calendar__metadata_ts,
office365video: office365video_config_json,
ping: ping_config_json,
pipedream: pipedream_config_json,
plausible: plausible_config_json,
qr_code: qr_code_config_json,
rainbow: rainbow_config_json,
raycast: raycast_config_json,
riverside: riverside_config_json,
salesforce: salesforce_config_json,
sendgrid: sendgrid_config_json,
signal: signal_config_json,
sirius_video: sirius_video_config_json,
stripepayment: stripepayment__metadata_ts,
tandemvideo: tandemvideo__metadata_ts,
telegram: telegram_config_json,
basic: basic_config_json,
"booking-pages-tag": booking_pages_tag_config_json,
"event-type-app-card": event_type_app_card_config_json,
"event-type-location-video-static": event_type_location_video_static_config_json,
"general-app-settings": general_app_settings_config_json,
"link-as-an-app": link_as_an_app_config_json,
typeform: typeform_config_json,
vimcal: vimcal_config_json,
vital: vital__metadata_ts,
weather_in_your_calendar: weather_in_your_calendar_config_json,
whatsapp: whatsapp_config_json,
whereby: whereby_config_json,
wipemycalother: wipemycalother__metadata_ts,
wordpress: wordpress_config_json,
zapier: zapier__metadata_ts,
zoomvideo: zoomvideo__metadata_ts,
};

View File

@ -2,46 +2,50 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appDataSchema as dailyvideo_schema } from "./dailyvideo/zod";
import { appDataSchema as routing_forms_schema } from "./ee/routing-forms/zod";
import { appDataSchema as fathom_schema } from "./fathom/zod";
import { appDataSchema as ga4_schema } from "./ga4/zod";
import { appDataSchema as giphy_schema } from "./giphy/zod";
import { appDataSchema as googlecalendar_schema } from "./googlecalendar/zod";
import { appDataSchema as hubspot_schema } from "./hubspot/zod";
import { appDataSchema as larkcalendar_schema } from "./larkcalendar/zod";
import { appDataSchema as office365calendar_schema } from "./office365calendar/zod";
import { appDataSchema as office365video_schema } from "./office365video/zod";
import { appDataSchema as plausible_schema } from "./plausible/zod";
import { appDataSchema as qr_code_schema } from "./qr_code/zod";
import { appDataSchema as rainbow_schema } from "./rainbow/zod";
import { appDataSchema as salesforce_schema } from "./salesforce/zod";
import { appDataSchema as stripepayment_schema } from "./stripepayment/zod";
import { appDataSchema as tandemvideo_schema } from "./tandemvideo/zod";
import { appDataSchema as vital_schema } from "./vital/zod";
import { appDataSchema as wordpress_schema } from "./wordpress/zod";
import { appDataSchema as zapier_schema } from "./zapier/zod";
import { appDataSchema as zoomvideo_schema } from "./zoomvideo/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appDataSchema as routing_forms_zod_ts } from "./ee/routing-forms/zod";
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
import { appDataSchema as ga4_zod_ts } from "./ga4/zod";
import { appDataSchema as giphy_zod_ts } from "./giphy/zod";
import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
import { appDataSchema as rainbow_zod_ts } from "./rainbow/zod";
import { appDataSchema as salesforce_zod_ts } from "./salesforce/zod";
import { appDataSchema as stripepayment_zod_ts } from "./stripepayment/zod";
import { appDataSchema as tandemvideo_zod_ts } from "./tandemvideo/zod";
import { appDataSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod";
import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod";
import { appDataSchema as vital_zod_ts } from "./vital/zod";
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
dailyvideo: dailyvideo_schema,
"routing-forms": routing_forms_schema,
fathom: fathom_schema,
ga4: ga4_schema,
giphy: giphy_schema,
googlecalendar: googlecalendar_schema,
hubspot: hubspot_schema,
larkcalendar: larkcalendar_schema,
office365calendar: office365calendar_schema,
office365video: office365video_schema,
plausible: plausible_schema,
qr_code: qr_code_schema,
rainbow: rainbow_schema,
salesforce: salesforce_schema,
stripe: stripepayment_schema,
tandemvideo: tandemvideo_schema,
vital: vital_schema,
wordpress: wordpress_schema,
zapier: zapier_schema,
zoomvideo: zoomvideo_schema,
dailyvideo: dailyvideo_zod_ts,
"routing-forms": routing_forms_zod_ts,
fathom: fathom_zod_ts,
ga4: ga4_zod_ts,
giphy: giphy_zod_ts,
googlecalendar: googlecalendar_zod_ts,
hubspot: hubspot_zod_ts,
larkcalendar: larkcalendar_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
plausible: plausible_zod_ts,
qr_code: qr_code_zod_ts,
rainbow: rainbow_zod_ts,
salesforce: salesforce_zod_ts,
stripe: stripepayment_zod_ts,
tandemvideo: tandemvideo_zod_ts,
"booking-pages-tag": booking_pages_tag_zod_ts,
"event-type-app-card": event_type_app_card_zod_ts,
vital: vital_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};

View File

@ -39,6 +39,12 @@ export const apiHandlers = {
stripepayment: import("./stripepayment/api"),
tandemvideo: import("./tandemvideo/api"),
telegram: import("./telegram/api"),
basic: import("./templates/basic/api"),
"booking-pages-tag": import("./templates/booking-pages-tag/api"),
"event-type-app-card": import("./templates/event-type-app-card/api"),
"event-type-location-video-static": import("./templates/event-type-location-video-static/api"),
"general-app-settings": import("./templates/general-app-settings/api"),
"link-as-an-app": import("./templates/link-as-an-app/api"),
typeform: import("./typeform/api"),
vimcal: import("./vimcal/api"),
vital: import("./vital/api"),

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -4,7 +4,6 @@ import { AppDeclarativeHandler } from "@calcom/types/AppHandler";
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,

View File

@ -1,53 +0,0 @@
import { AppScript } from "./BookingPageTagManager";
import { appDataSchemas } from "./apps.schemas.generated";
// TODO: This config might be imported from {APP}/eventTypeAnalytics.ts.
export const trackingApps: Partial<
Record<
keyof typeof appDataSchemas,
{
scripts: AppScript[];
}
>
> = {
fathom: {
scripts: [
{
src: "https://cdn.usefathom.com/script.js",
content: undefined,
attrs: {
"data-site": "{TRACKING_ID}",
},
},
],
},
plausible: {
scripts: [
{
src: "https://plausible.io/js/script.js",
content: undefined,
attrs: {
"data-domain": "{TRACKED_DOMAIN}",
},
},
],
},
ga4: {
scripts: [
{
src: "https://www.googletagmanager.com/gtag/js?id={TRACKING_ID}",
content: undefined,
attrs: {},
},
{
src: undefined,
content: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{TRACKING_ID}');
`,
},
],
},
};

View File

@ -4,3 +4,5 @@ import { z } from "zod";
export const eventTypeAppCardZod = z.object({
enabled: z.boolean().optional(),
});
export const appKeysSchema = z.object({});

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,6 +11,18 @@
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"extendsFeature": "EventType",
"appData": {
"tag": {
"scripts": [
{
"src": "https://cdn.usefathom.com/script.js",
"attrs": {
"data-site": "{TRACKING_ID}"
}
}
]
}
},
"description": "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.",
"__createdUsingCli": true
}

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -12,5 +12,16 @@
"email": "support@cal.com",
"description": "Google Analytics is a web analytics service offered by Google that tracks and reports website traffic, currently as a platform inside the Google Marketing Platform brand.",
"extendsFeature": "EventType",
"tag": {
"scripts": [
{
"src": "https://www.googletagmanager.com/gtag/js?id={TRACKING_ID}",
"attrs": {}
},
{
"content": "window.dataLayer = window.dataLayer || [];\n function gtag(){dataLayer.push(arguments);}\n gtag('js', new Date());\n gtag('config', '{TRACKING_ID}');"
}
]
},
"__createdUsingCli": true
}

View File

@ -1,11 +1,11 @@
import { BookingStatus } from "@prisma/client";
import type { TFunction } from "next-i18next";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import logger from "@calcom/lib/logger";
import { Ensure, Optional } from "@calcom/types/utils";
import type { EventLocationTypeFromAppMeta } from "../types/App";
import { appStoreMetadata } from "./apps.metadata.generated";
export type DefaultEventLocationType = {
default: true;
@ -142,6 +142,14 @@ const locationsFromApps: EventLocationTypeFromApp[] = [];
for (const [appName, meta] of Object.entries(appStoreMetadata)) {
const location = meta.appData?.location;
if (location) {
// TODO: This template variable replacement should happen once during app-store:build.
for (const [key, value] of Object.entries(location)) {
if (typeof value === "string") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
location[key] = value.replace(/{SLUG}/g, meta.slug).replace(/{TITLE}/g, meta.name);
}
}
const newLocation = {
...location,
messageForOrganizer: location.messageForOrganizer || `Set ${location.label} link`,

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,6 +11,5 @@
"publisher": "Pipedream, Inc.",
"email": "support@pipedream.com",
"description": "Connect APIs, remarkably fast. Stop writing boilerplate code, struggling with authentication and managing infrastructure. Start connecting APIs with code-level control when you need it — and no code when you don't",
"extendsFeature": "User",
"__createdUsingCli": true
}

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,6 +11,16 @@
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"extendsFeature": "EventType",
"tag": {
"scripts": [
{
"src": "https://plausible.io/js/script.js",
"attrs": {
"data-domain": "{TRACKED_DOMAIN}"
}
}
]
},
"description": "Simple, privacy-friendly Google Analytics alternative.",
"__createdUsingCli": true
}

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,
slug: appConfig.slug,
variant: appConfig.slug,

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,
slug: appConfig.slug,
variant: appConfig.variant,

View File

@ -11,6 +11,5 @@
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Salesforce (Sales Cloud) is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day.",
"extendsFeature": "User",
"__createdUsingCli": true
}

View File

@ -11,6 +11,5 @@
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform.",
"extendsFeature": "User",
"__createdUsingCli": true
}

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,7 +11,6 @@
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "Schedule a chat with your guests or have a Signal Video call.",
"extendsFeature": "User",
"__createdUsingCli": true,
"appData": {
"location": {

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -4,7 +4,6 @@ import { createDefaultInstallation } from "../../_utils/installation";
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,

View File

@ -11,7 +11,6 @@
"publisher": "Cal.com, Inc.",
"email": "support@cal.com",
"description": "Schedule a chat with your guests or have a Telegram Video call.",
"extendsFeature": "User",
"__createdUsingCli": true,
"appData": {
"location": {

View File

@ -0,0 +1,2 @@
## TODO:
Identify an ideal app among existing auth based apps and add it here

View File

@ -0,0 +1 @@
Calendar apps are not streamlined and thus a template for it needs some good amount of work

View File

@ -0,0 +1,8 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
---
{DESCRIPTION}

View File

@ -1,10 +1,9 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
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,

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