Merge branch 'main' into feat/calcom-auth
# Conflicts: # .gitmodules # apps/console # apps/web/pages/settings/my-account/conferencing.tsx # apps/website # yarn.lock
This commit is contained in:
commit
64fc828744
|
@ -80,3 +80,9 @@ apps/storybook/build-storybook.log
|
|||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
# Submodules
|
||||
.gitmodules
|
||||
apps/api
|
||||
apps/website
|
||||
apps/console
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
[submodule "apps/console"]
|
||||
path = apps/console
|
||||
url = https://github.com/calcom/console.git
|
||||
branch = main
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
url = https://github.com/calcom/api.git
|
||||
branch = main
|
||||
[submodule "apps/website"]
|
||||
path = apps/website
|
||||
url = https://github.com/calcom/website.git
|
||||
branch = main
|
||||
[submodule "apps/auth"]
|
||||
path = apps/auth
|
||||
url = https://github.com/calcom/auth.git
|
||||
branch = main
|
17
README.md
17
README.md
|
@ -305,6 +305,12 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||
|
||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
|
||||
<!-- RORADMAP -->
|
||||
|
||||
## Repo Activity
|
||||
|
||||
<img width="100%" src="https://repobeats.axiom.co/api/embed/6bfca2f20f39738048b6e70ca205efde46352c3d.svg" />
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
|
||||
## Contributing
|
||||
|
@ -405,17 +411,6 @@ following
|
|||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
### Obtaining Vital API Keys
|
||||
|
||||
1. Open [Vital](https://tryvital.io/) and click Get API Keys.
|
||||
1. Create a team with the team name you desire
|
||||
1. Head to the configuration section on the sidebar of the dashboard
|
||||
1. Click on API keys and you'll find your sandbox `api_key`.
|
||||
1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file.
|
||||
1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `<CALCOM BASE URL>/api/integrations/vital/webhook` as webhook for connected applications.
|
||||
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
||||
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
||||
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
|
1
apps/api
1
apps/api
|
@ -1 +0,0 @@
|
|||
Subproject commit 7aebdb8c966f472383cf55e8da31e9655102e775
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 8c0921a70213667e1411062ad37dd5c653904159
|
|
@ -0,0 +1,35 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, ListItemText } from "@calcom/ui";
|
||||
|
||||
interface AppListCardProps {
|
||||
logo?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export default function AppListCard(props: AppListCardProps) {
|
||||
const { t } = useLocale();
|
||||
const { logo, title, description, actions, isDefault } = props;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
|
||||
{isDefault ? <Badge variant="green">{t("default")}</Badge> : null}
|
||||
</div>
|
||||
<ListItemText component="p">{description}</ListItemText>
|
||||
</div>
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -597,7 +597,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
|||
<iframe
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLIFrameElement>}
|
||||
data-testid="embed-preview"
|
||||
className="border-1 h-[100vh] border"
|
||||
className="h-[100vh] border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||
|
@ -639,7 +639,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||
<div className="flex items-start">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100 ltr:mr-2 rtl:ml-2"
|
||||
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-gray-100 ltr:mr-2 rtl:ml-2"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
|
@ -1163,7 +1163,7 @@ export const EmbedButton = <T extends React.ElementType>({
|
|||
...props
|
||||
}: EmbedButtonProps<T> & React.ComponentPropsWithoutRef<T>) => {
|
||||
const router = useRouter();
|
||||
className = classNames(className, "hidden lg:inline-flex");
|
||||
className = classNames("hidden lg:inline-flex", className);
|
||||
const openEmbedModal = () => {
|
||||
goto(router, {
|
||||
dialog: "embed",
|
||||
|
|
|
@ -142,7 +142,7 @@ export default function ImageUploader({
|
|||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
|
|
|
@ -67,7 +67,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
|||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-gray-900"
|
||||
? "border-gray-900 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
||||
className
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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. */}
|
||||
|
|
|
@ -6,7 +6,7 @@ import classNames from "@lib/classNames";
|
|||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
|
|
@ -220,7 +220,8 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
},
|
||||
});
|
||||
};
|
||||
const showRecordingsButtons = booking.location === "integrations:daily" && isPast && isConfirmed;
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
|
@ -273,7 +274,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="group flex flex-col hover:bg-neutral-50 sm:flex-row">
|
||||
<tr className="group flex flex-col hover:bg-gray-50 sm:flex-row">
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { SkeletonText } from "@calcom/ui";
|
|||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:overflow-hidden">
|
||||
<ul className="animate-pulse divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
|
|
@ -492,7 +492,7 @@ const BookingPage = ({
|
|||
<div
|
||||
className={classNames(
|
||||
"main overflow-hidden",
|
||||
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white dark:border",
|
||||
"dark:border-darkgray-300 rounded-md sm:border"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
|
@ -954,7 +954,7 @@ const BookingPage = ({
|
|||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
tooltip={t("additional_guests")}
|
||||
StartIcon={Icon.FiUserPlus}
|
||||
onClick={() => setGuestToggle(!guestToggle)}
|
||||
|
|
|
@ -212,7 +212,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent disableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
||||
|
|
|
@ -183,7 +183,7 @@ function RadioInputHandler({
|
|||
{...register(`options.${index}.label` as const, { required: true })}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiX}
|
||||
onClick={() => {
|
||||
|
|
|
@ -129,7 +129,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
<Button
|
||||
type="button"
|
||||
StartIcon={Icon.FiEdit}
|
||||
size="icon"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -430,7 +430,7 @@ const BookingLimits = () => {
|
|||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="icon"
|
||||
StartIcon={Icon.FiTrash}
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
|
|
|
@ -162,7 +162,7 @@ export const EventSetupTab = (
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<li key={location.type} className="mb-2 rounded-md border border-neutral-300 py-1.5 px-2">
|
||||
<li key={location.type} className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||
<div className="flex max-w-full justify-between">
|
||||
<div key={index} className="flex flex-grow items-center">
|
||||
<img
|
||||
|
|
|
@ -208,7 +208,7 @@ function EventTypeSingleLayout({
|
|||
<Button
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
href={permalink}
|
||||
rel="noreferrer"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
|
@ -217,7 +217,7 @@ function EventTypeSingleLayout({
|
|||
|
||||
<Button
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
tooltip={t("copy_link")}
|
||||
onClick={() => {
|
||||
|
@ -229,12 +229,12 @@ function EventTypeSingleLayout({
|
|||
embedUrl={encodeURIComponent(embedLink)}
|
||||
StartIcon={Icon.FiCode}
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
StartIcon={Icon.FiTrash}
|
||||
tooltip={t("delete")}
|
||||
disabled={!hasPermsToDelete}
|
||||
|
@ -324,7 +324,7 @@ function EventTypeSingleLayout({
|
|||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 rounded-md border-neutral-200 bg-white sm:mx-0 xl:mt-0",
|
||||
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
||||
disableBorder ? "border-0 xl:-mt-4 " : "p-2 md:border md:p-6"
|
||||
)}>
|
||||
{children}
|
||||
|
|
|
@ -10,7 +10,7 @@ function SkeletonLoader() {
|
|||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
|
|
@ -113,7 +113,7 @@ const UserProfile = (props: IUserProfileProps) => {
|
|||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-gray-800 focus:outline-none focus:ring-gray-800"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<div className="flex items-center px-4">
|
||||
|
@ -146,7 +146,7 @@ const UserProfile = (props: IUserProfileProps) => {
|
|||
ref={bioRef}
|
||||
name="bio"
|
||||
id="bio"
|
||||
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
|
||||
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
|
||||
defaultValue={user?.bio || undefined}
|
||||
onChange={(event) => {
|
||||
setValue("bio", event.target.value);
|
||||
|
|
|
@ -190,7 +190,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
<div className="flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
isInputUsernamePremium ? "border-1 border-orange-400 " : "",
|
||||
isInputUsernamePremium ? "border border-orange-400 " : "",
|
||||
"hidden h-9 items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
|
@ -207,8 +207,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
className={classNames(
|
||||
"border-l-1 mb-0 mt-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
|
||||
isInputUsernamePremium
|
||||
? "border-1 focus:border-1 border-orange-400 focus:border-orange-400"
|
||||
: "border-1 focus:border-2",
|
||||
? "border border-orange-400 focus:border focus:border-orange-400"
|
||||
: "border focus:border",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none"
|
||||
: "border-l-gray-300",
|
||||
|
|
|
@ -41,7 +41,7 @@ export const CheckedSelect = ({
|
|||
{...props}
|
||||
/>
|
||||
{value.map((option) => (
|
||||
<div key={option.value} className="border-1 border p-2 font-medium">
|
||||
<div key={option.value} className="border p-2 font-medium">
|
||||
<Avatar
|
||||
className="inline h-6 w-6 rounded-full ltr:mr-2 rtl:ml-2"
|
||||
imageSrc={option.avatar}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -55,7 +55,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="dark:bg-darkgray-100 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-neutral-700 dark:hover:border-neutral-600">
|
||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
||||
<Icon.FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
<Link
|
||||
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
|
||||
|
@ -144,8 +144,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||
<div
|
||||
className={classNames(
|
||||
"rounded-md ",
|
||||
!isEventListEmpty &&
|
||||
"border border-neutral-200 dark:border-neutral-700 dark:hover:border-neutral-600"
|
||||
!isEventListEmpty && "border border-gray-200 dark:border-gray-700 dark:hover:border-gray-600"
|
||||
)}
|
||||
data-testid="event-types">
|
||||
{user.away ? (
|
||||
|
@ -164,7 +163,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||
<div
|
||||
key={type.id}
|
||||
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
||||
className="dark:bg-darkgray-100 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-neutral-700 dark:hover:border-neutral-600">
|
||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
||||
<Icon.FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
|
||||
<Link
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function Type(props: AvailabilityPageProps) {
|
|||
const { t } = useLocale();
|
||||
|
||||
return props.away ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<div className="h-screen dark:bg-gray-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
|
|
|
@ -26,7 +26,7 @@ export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
|||
export default function Book(props: BookPageProps) {
|
||||
const { t } = useLocale();
|
||||
return props.away ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<div className="h-screen dark:bg-gray-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
|
@ -41,7 +41,7 @@ export default function Book(props: BookPageProps) {
|
|||
</main>
|
||||
</div>
|
||||
) : props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<div className="h-screen dark:bg-gray-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
HorizontalTabs,
|
||||
Icon,
|
||||
TextField,
|
||||
TrendingAppsSlider,
|
||||
PopularAppsSlider,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppsLayout from "@components/apps/layouts/AppsLayout";
|
||||
|
@ -76,7 +76,7 @@ export default function Apps({ categories, appStore }: inferSSRProps<typeof getS
|
|||
{!searchText && (
|
||||
<>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<TrendingAppsSlider items={appStore} />
|
||||
<PopularAppsSlider items={appStore} />
|
||||
</>
|
||||
)}
|
||||
<AllApps
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -185,11 +185,8 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||
});
|
||||
}}
|
||||
className="flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1 divide-y divide-neutral-200 rounded-md border">
|
||||
<div className="flex-1 divide-y divide-gray-200 rounded-md border">
|
||||
<div className=" py-5 sm:p-6">
|
||||
<h3 className="mb-2 px-5 text-base font-medium leading-6 text-gray-900 sm:pl-0">
|
||||
{t("change_start_end")}
|
||||
</h3>
|
||||
{typeof me.data?.weekStart === "string" && (
|
||||
<Schedule
|
||||
control={control}
|
||||
|
|
|
@ -83,7 +83,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
|||
</div>
|
||||
) : (
|
||||
<div className="mb-16 overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<ul className="divide-y divide-neutral-200" data-testid="schedules" ref={animationParentRef}>
|
||||
<ul className="divide-y divide-gray-200" data-testid="schedules" ref={animationParentRef}>
|
||||
{schedules.map((schedule) => (
|
||||
<ScheduleListItem
|
||||
displayOptions={{
|
||||
|
|
|
@ -78,7 +78,7 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
|||
.map((slot: IBusySlot) => (
|
||||
<div
|
||||
key={dayjs(slot.start).format("HH:mm")}
|
||||
className="overflow-hidden rounded-md bg-neutral-100"
|
||||
className="overflow-hidden rounded-md bg-gray-100"
|
||||
data-testid="troubleshooter-busy-time">
|
||||
<div className="px-4 py-5 text-black sm:p-6">
|
||||
{t("calendar_shows_busy_between")}{" "}
|
||||
|
@ -97,7 +97,7 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
|||
</div>
|
||||
));
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md bg-neutral-100">
|
||||
<div className="overflow-hidden rounded-md bg-gray-100">
|
||||
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -370,391 +370,383 @@ export default function Success(props: SuccessProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "text-center" : "",
|
||||
"flex items-end justify-center p-4 sm:block sm:p-0"
|
||||
"flex items-end justify-center px-4 pt-4 pb-20 sm:block sm:p-0"
|
||||
)}>
|
||||
<div
|
||||
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
|
||||
aria-hidden="true">
|
||||
<div
|
||||
className={classNames(
|
||||
"main inline-block transform overflow-hidden rounded sm:my-8 sm:max-w-xl",
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white",
|
||||
"p-[1px] text-left align-bottom transition-all sm:w-full sm:align-middle"
|
||||
"main inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 dark:border-darkgray-200 bg-white",
|
||||
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<span
|
||||
aria-hidden
|
||||
className="before:animate-disco before:bg-gradient-conic absolute inset-0 scale-y-[5.0] blur before:absolute before:inset-0 before:top-1/2 before:z-0 before:aspect-square before:from-purple-700 before:via-red-500 before:to-amber-400"
|
||||
/>
|
||||
<div className="dark:bg-darkgray-100 relative rounded bg-white p-8 px-6">
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex items-center justify-center",
|
||||
!giphyImage && !isCancelled && !needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-green-100"
|
||||
: "",
|
||||
!giphyImage && !isCancelled && needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-gray-100"
|
||||
: "",
|
||||
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && (
|
||||
<Icon.FiCalendar className="h-5 w-5 text-gray-900" />
|
||||
)}
|
||||
{isCancelled && <Icon.FiX className="h-5 w-5 text-red-600" />}
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex items-center justify-center",
|
||||
!giphyImage && !isCancelled && !needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-green-100"
|
||||
: "",
|
||||
!giphyImage && !isCancelled && needsConfirmation
|
||||
? "h-12 w-12 rounded-full bg-gray-100"
|
||||
: "",
|
||||
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && <Icon.FiCalendar className="h-5 w-5 text-gray-900" />}
|
||||
{isCancelled && <Icon.FiX className="h-5 w-5 text-red-600" />}
|
||||
</div>
|
||||
<div className="mt-6 mb-8 text-center last:mb-0">
|
||||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-gray-900 dark:text-white"
|
||||
data-testid={isCancelled ? "cancelled-headline" : ""}
|
||||
id="modal-headline">
|
||||
{needsConfirmation && !isCancelled
|
||||
? props.recurringBookings
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
<div className="mt-3">
|
||||
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
<div className="mt-6 mb-8 text-center last:mb-0">
|
||||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-gray-900 dark:text-white"
|
||||
data-testid={isCancelled ? "cancelled-headline" : ""}
|
||||
id="modal-headline">
|
||||
{needsConfirmation && !isCancelled
|
||||
? props.recurringBookings
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
<div className="mt-3">
|
||||
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
{isCancelled ? t("reason") : t("reschedule_reason_success_page")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{reschedule && !!formerTime && (
|
||||
<p className="line-through">
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
duration={calculatedDuration}
|
||||
recurringBookings={props.recurringBookings}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
date={dayjs(formerTime)}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
duration={calculatedDuration}
|
||||
recurringBookings={props.recurringBookings}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</div>
|
||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
<>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{locationToDisplay && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a title="Meeting Link" href={locationToDisplay}>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{bookingInfo?.description && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p className="break-words">{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
// This breaks if you have two label that are the same.
|
||||
// TODO: Fix this in another PR
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
const eventTypeCustomFound = eventType.customInputs?.find((ci) => ci.label === key);
|
||||
return (
|
||||
<>
|
||||
{eventTypeCustomFound?.type === "RADIO" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{eventTypeCustomFound.label}
|
||||
</div>
|
||||
<div className="col-span-3 mt-1 mb-2">
|
||||
{eventTypeCustomFound.options &&
|
||||
eventTypeCustomFound.options.map((option) => {
|
||||
const selected = option.label == customInput;
|
||||
return (
|
||||
<div
|
||||
key={option.label}
|
||||
className={classNames(
|
||||
"flex space-x-1",
|
||||
!selected && "text-gray-500"
|
||||
)}>
|
||||
<p>{option.label}</p>
|
||||
<span>{option.label === customInput && "✅"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
|
||||
<div className="col-span-3 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{bookingInfo?.smsReminderNumber && hasSMSAttendeeAction && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("number_sms_notifications")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.smsReminderNumber}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(!needsConfirmation || !userIsOwner) &&
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mb-8" />
|
||||
<div className="text-center">
|
||||
<span className="text-gray-900 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-bookinglight inline text-gray-700">
|
||||
<span className="underline">
|
||||
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
</span>
|
||||
<div className="font-medium">
|
||||
{isCancelled ? t("reason") : t("reschedule_reason_success_page")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{reschedule && !!formerTime && (
|
||||
<p className="line-through">
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
duration={calculatedDuration}
|
||||
recurringBookings={props.recurringBookings}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
date={dayjs(formerTime)}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<RecurringBookings
|
||||
eventType={props.eventType}
|
||||
duration={calculatedDuration}
|
||||
recurringBookings={props.recurringBookings}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</div>
|
||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
<>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{locationToDisplay && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a title="Meeting Link" href={locationToDisplay}>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-bookinglight text-gray-700 underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-200" />
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title, id: bookingInfo?.id }}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{userIsOwner &&
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
!isCancelled &&
|
||||
calculatedDuration && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("add_to_calendar")}
|
||||
</span>
|
||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof location === "string"
|
||||
? "&location=" + encodeURIComponent(location)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}
|
||||
className="h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 ltr:mr-2 rtl:ml-2 dark:border-neutral-700 dark:text-white">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={"data:text/calendar," + eventLink()}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
{bookingInfo?.description && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p className="break-words">{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
// This breaks if you have two label that are the same.
|
||||
// TODO: Fix this in another PR
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
const eventTypeCustomFound = eventType.customInputs?.find((ci) => ci.label === key);
|
||||
return (
|
||||
<>
|
||||
{eventTypeCustomFound?.type === "RADIO" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{eventTypeCustomFound.label}
|
||||
</div>
|
||||
<div className="col-span-3 mt-1 mb-2">
|
||||
{eventTypeCustomFound.options &&
|
||||
eventTypeCustomFound.options.map((option) => {
|
||||
const selected = option.label == customInput;
|
||||
return (
|
||||
<div
|
||||
key={option.label}
|
||||
className={classNames(
|
||||
"flex space-x-1",
|
||||
!selected && "text-gray-500"
|
||||
)}>
|
||||
<p>{option.label}</p>
|
||||
<span>{option.label === customInput && "✅"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
|
||||
<div className="col-span-3 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{bookingInfo?.smsReminderNumber && hasSMSAttendeeAction && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("number_sms_notifications")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.smsReminderNumber}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(!needsConfirmation || !userIsOwner) &&
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mb-8" />
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-gray-900 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("need_to_make_a_change")}
|
||||
</span>
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-bookinglight inline text-gray-700">
|
||||
<span className="underline">
|
||||
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="cancel"
|
||||
className={classNames(
|
||||
"text-bookinglight text-gray-700 underline",
|
||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||
)}
|
||||
onClick={() => setIsCancellationMode(true)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-200" />
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title, id: bookingInfo?.id }}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{userIsOwner &&
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
!isCancelled &&
|
||||
calculatedDuration && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-300 pt-8 text-center text-xs dark:text-white">
|
||||
<a href="https://cal.com/signup">
|
||||
{t("create_booking_link_with_calcom", { appName: APP_NAME })}
|
||||
</a>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as typeof e.target & {
|
||||
email: { value: string };
|
||||
};
|
||||
router.push(`https://cal.com/signup?email=${target.email.value}`);
|
||||
}}
|
||||
className="mt-4 flex">
|
||||
<EmailInput
|
||||
name="email"
|
||||
id="email"
|
||||
defaultValue={email || ""}
|
||||
className="mr- focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-none rounded-l-md border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
||||
placeholder="rick.astley@cal.com"
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="min-w-max rounded-none rounded-r-md"
|
||||
color="primary">
|
||||
{t("try_for_free")}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("add_to_calendar")}
|
||||
</span>
|
||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||
<Link
|
||||
href={
|
||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof location === "string"
|
||||
? "&location=" + encodeURIComponent(location)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}
|
||||
className="h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 ltr:mr-2 rtl:ml-2 dark:border-gray-700 dark:text-white">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||
props.eventType.description +
|
||||
"&enddt=" +
|
||||
date.add(calculatedDuration, "minute").utc().format() +
|
||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={"data:text/calendar," + eventLink()}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||
download={props.eventType.title + ".ics"}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-300 pt-8 text-center text-xs dark:text-white">
|
||||
<a href="https://cal.com/signup">
|
||||
{t("create_booking_link_with_calcom", { appName: APP_NAME })}
|
||||
</a>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as typeof e.target & {
|
||||
email: { value: string };
|
||||
};
|
||||
router.push(`https://cal.com/signup?email=${target.email.value}`);
|
||||
}}
|
||||
className="mt-4 flex">
|
||||
<EmailInput
|
||||
name="email"
|
||||
id="email"
|
||||
defaultValue={email || ""}
|
||||
className="mr- focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-none rounded-l-md border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
||||
placeholder="rick.astley@cal.com"
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="min-w-max rounded-none rounded-r-md"
|
||||
color="primary">
|
||||
{t("try_for_free")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -250,7 +250,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
const lastItem = types[types.length - 1];
|
||||
return (
|
||||
<div className="mb-16 flex overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<ul ref={parent} className="!static w-full divide-y divide-neutral-200" data-testid="event-types">
|
||||
<ul ref={parent} className="!static w-full divide-y divide-gray-200" data-testid="event-types">
|
||||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${CAL_URL}/${embedLink}`;
|
||||
|
@ -311,7 +311,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
<Button
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
href={calLink}
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
/>
|
||||
|
@ -320,7 +320,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
<Tooltip content={t("copy_link")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
showToast(t("link_copied"), "success");
|
||||
|
@ -335,7 +335,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
className="radix-state-open:rounded-r-md">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
/>
|
||||
|
@ -395,7 +395,12 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
<div className="min-w-9 mx-5 flex sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
|
|
|
@ -28,7 +28,7 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
|||
</div>
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">{children}</div>
|
||||
</section>
|
||||
<hr className="border-neutral-200" />
|
||||
<hr className="border-gray-200" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -122,7 +122,7 @@ const AppearanceView = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-1 my-8 border-neutral-200" />
|
||||
<hr className="my-8 border border-gray-200" />
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">{t("custom_brand_colors")}</p>
|
||||
|
@ -168,7 +168,7 @@ const AppearanceView = () => {
|
|||
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
||||
Preview
|
||||
</Button> */}
|
||||
<hr className="border-1 my-8 border-neutral-200" />
|
||||
<hr className="my-8 border border-gray-200" />
|
||||
<Controller
|
||||
name="hideBranding"
|
||||
control={formMethods.control}
|
||||
|
|
|
@ -18,15 +18,14 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemTitle,
|
||||
Meta,
|
||||
showToast,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
|
@ -75,22 +74,17 @@ const ConferencingLayout = () => {
|
|||
apps.items
|
||||
.map((app) => ({ ...app, title: app.title || app.name }))
|
||||
.map((app) => (
|
||||
<ListItem className="flex-col border-0" key={app.title}>
|
||||
<div className="flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
app.logo && <img className="h-10 w-10" src={app.logo} alt={app.title} />
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
|
||||
<h3 className="truncate text-sm font-medium text-gray-900">{app.title}</h3>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{app.description}</ListItemText>
|
||||
</div>
|
||||
<AppListCard
|
||||
description={app.description}
|
||||
title={app.title}
|
||||
logo={app.logo}
|
||||
key={app.title}
|
||||
isDefault={app.isGlobal}
|
||||
actions={
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={Icon.FiMoreHorizontal} size="icon" color="secondary" />
|
||||
<Button StartIcon={Icon.FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
|
@ -109,8 +103,8 @@ const ConferencingLayout = () => {
|
|||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
|
|
|
@ -222,7 +222,7 @@ const ProfileView = () => {
|
|||
}
|
||||
/>
|
||||
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
<hr className="my-6 border-gray-200" />
|
||||
|
||||
<Label>{t("danger_zone")}</Label>
|
||||
{/* Delete account Dialog */}
|
||||
|
|
|
@ -36,12 +36,12 @@ function TeamPage({ team }: TeamPageProps) {
|
|||
}, [telemetry, router.asPath]);
|
||||
|
||||
const EventTypes = () => (
|
||||
<ul className="rounded-md border border-neutral-200 dark:border-neutral-700">
|
||||
<ul className="rounded-md border border-gray-200 dark:border-gray-700">
|
||||
{team.eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={classNames(
|
||||
"dark:bg-darkgray-100 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-neutral-700 dark:hover:border-neutral-600",
|
||||
"dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600",
|
||||
!isEmbed && "bg-white"
|
||||
)}>
|
||||
<Link
|
||||
|
|
|
@ -15,7 +15,7 @@ function Teams() {
|
|||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button
|
||||
size="fab"
|
||||
variant="fab"
|
||||
StartIcon={Icon.FiPlus}
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
|
||||
"someone_requested_password_reset": "Someone has requested a link to change your password.",
|
||||
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
|
||||
"event_awaiting_approval_subject": "Awaiting Approval: {{eventType}} at {{date}}",
|
||||
"event_awaiting_approval_subject": "Awaiting Approval: {{title}} at {{date}}",
|
||||
"event_still_awaiting_approval": "An event is still waiting for your approval",
|
||||
"booking_submitted_subject": "Booking Submitted: {{title}} at {{date}}",
|
||||
"your_meeting_has_been_booked": "Your meeting has been booked",
|
||||
|
@ -660,6 +660,7 @@
|
|||
"edit_availability": "Edit availability",
|
||||
"configure_availability": "Configure times when you are available for bookings.",
|
||||
"copy_times_to": "Copy times to",
|
||||
"copy_times_to_tooltip": "Copy times to …",
|
||||
"change_weekly_schedule": "Change your weekly schedule",
|
||||
"logo": "Logo",
|
||||
"error": "Error",
|
||||
|
@ -789,6 +790,7 @@
|
|||
"number_apps_one": "{{count}} App",
|
||||
"number_apps_other": "{{count}} Apps",
|
||||
"trending_apps": "Trending Apps",
|
||||
"most_popular":"Most Popular",
|
||||
"explore_apps": "{{category}} apps",
|
||||
"installed_apps": "Installed Apps",
|
||||
"free_to_use_apps": "Free",
|
||||
|
|
|
@ -183,19 +183,19 @@ button[role="switch"][data-state="checked"] {
|
|||
}
|
||||
|
||||
.slider > .slider-track {
|
||||
@apply relative h-1 flex-grow rounded-md bg-neutral-400;
|
||||
@apply relative h-1 flex-grow rounded-md bg-gray-400;
|
||||
}
|
||||
|
||||
.slider .slider-range {
|
||||
@apply absolute h-full rounded-full bg-neutral-700;
|
||||
@apply absolute h-full rounded-full bg-gray-700;
|
||||
}
|
||||
|
||||
.slider .slider-thumb {
|
||||
@apply block h-3 w-3 cursor-pointer rounded-full bg-neutral-700 transition-all;
|
||||
@apply block h-3 w-3 cursor-pointer rounded-full bg-gray-700 transition-all;
|
||||
}
|
||||
|
||||
.slider .slider-thumb:hover {
|
||||
@apply bg-neutral-600;
|
||||
@apply bg-gray-600;
|
||||
}
|
||||
|
||||
.slider .slider-thumb:focus {
|
||||
|
@ -237,12 +237,12 @@ button[role="switch"][data-state="checked"] {
|
|||
}
|
||||
|
||||
.react-multi-email > [type="text"] {
|
||||
@apply dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500 dark:text-white;
|
||||
@apply dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-white block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500;
|
||||
}
|
||||
|
||||
.react-multi-email [data-tag] {
|
||||
box-shadow: none !important;
|
||||
@apply dark:bg-brand my-1 inline-flex items-center rounded-md border border-transparent bg-neutral-200 px-2 py-1 text-sm font-medium text-gray-900 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ltr:mr-2 rtl:ml-2 dark:text-white;
|
||||
@apply dark:bg-brand my-1 inline-flex items-center rounded-md border border-transparent bg-gray-200 px-2 py-1 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 ltr:mr-2 rtl:ml-2 dark:text-white;
|
||||
}
|
||||
|
||||
.react-multi-email > span[data-placeholder] {
|
||||
|
@ -270,7 +270,7 @@ button[role="switch"][data-state="checked"] {
|
|||
}
|
||||
|
||||
.react-multi-email [data-tag] {
|
||||
@apply my-1 inline-flex items-center rounded-md border border-transparent bg-neutral-200 px-2 py-1 text-sm font-medium text-gray-900 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ltr:mr-2 rtl:ml-2 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-700;
|
||||
@apply my-1 inline-flex items-center rounded-md border border-transparent bg-gray-200 px-2 py-1 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 ltr:mr-2 rtl:ml-2 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.react-multi-email [data-tag] [data-tag-item] {
|
||||
|
@ -383,7 +383,7 @@ nav#nav--settings > a svg {
|
|||
}
|
||||
|
||||
nav#nav--settings > a.active {
|
||||
@apply border-neutral-500 bg-neutral-50 text-gray-700 hover:bg-neutral-50 hover:text-gray-700;
|
||||
@apply border-gray-500 bg-gray-50 text-gray-700 hover:bg-gray-50 hover:text-gray-700;
|
||||
}
|
||||
|
||||
nav#nav--settings > a.active svg {
|
||||
|
|
|
@ -2,23 +2,5 @@ const base = require("@calcom/config/tailwind-preset");
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...base,
|
||||
theme: {
|
||||
...base.theme,
|
||||
extend: {
|
||||
...base.theme.extend,
|
||||
backgroundImage: {
|
||||
"gradient-conic": "conic-gradient(var(--tw-gradient-stops))",
|
||||
},
|
||||
keyframes: {
|
||||
disco: {
|
||||
"0%": { transform: "translateY(-50%) rotate(0deg)" },
|
||||
"100%": { transform: "translateY(-50%) rotate(360deg)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
disco: "disco 8s linear infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
content: [...base.content],
|
||||
};
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit bb8a37c4ce912b825c31d5a8caf5d1f19fe04656
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
# If there's a `.gitmodule` file skip this script
|
||||
[ -f .gitmodules ] && {
|
||||
echo ".gitmodules already initializied"
|
||||
exit 0
|
||||
}
|
||||
|
||||
./git-setup.sh api website console
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/sh
|
||||
# If no project name is given
|
||||
if [ $# -eq 0 ]; then
|
||||
# Display usage and stop
|
||||
echo "Usage: git-setup.sh <api,console,website>"
|
||||
exit 1
|
||||
fi
|
||||
# Loop through the requested modules
|
||||
for module in "$@"; do
|
||||
echo "Setting up '$module' module..."
|
||||
# Set the project git URL
|
||||
project=$(echo "git@github.com:calcom/$module.git")
|
||||
# Check if we have access to the module
|
||||
if [ "$(git ls-remote "$project" 2>/dev/null)" ]; then
|
||||
echo "You have access to '${module}'"
|
||||
# Create the .gitmodules file if it doesn't exist
|
||||
([ -e ".gitmodules" ] || touch ".gitmodules") && [ ! -w ".gitmodules" ] && echo cannot write to .gitmodules && exit 1
|
||||
# Prevents duplicate entries
|
||||
git config -f .gitmodules --unset-all "submodule.apps/$module.branch"
|
||||
# Add the submodule
|
||||
git submodule add --force "git@github.com:calcom/$module.git" "apps/$module"
|
||||
# Set the default branch to main
|
||||
git config -f .gitmodules --add "submodule.apps/$module.branch" main
|
||||
# Adding the subdmoule ignores the `.gitignore` so a reset is needed
|
||||
git reset
|
||||
else
|
||||
# If the module is the API, display a link to request access
|
||||
if [ "$module" == "api" ]; then
|
||||
echo "You don't have access to: '${module}' module. You can request access in: https://console.cal.com"
|
||||
else
|
||||
# If the module is not the API, display normal message
|
||||
echo "You don't have access to: '${module}' module."
|
||||
fi
|
||||
fi
|
||||
done
|
|
@ -60,28 +60,29 @@ const config: Config = {
|
|||
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
|
||||
testEnvironment: "jsdom",
|
||||
},
|
||||
{
|
||||
displayName: "@calcom/api",
|
||||
roots: ["<rootDir>/apps/api"],
|
||||
testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
|
||||
setupFilesAfterEnv: ["<rootDir>/tests/config/singleton.ts"],
|
||||
transform: {
|
||||
"^.+\\.ts?$": "ts-jest",
|
||||
},
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
tsconfig: "<rootDir>/apps/api/tsconfig.json",
|
||||
},
|
||||
},
|
||||
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
|
||||
testEnvironment: "node",
|
||||
clearMocks: true,
|
||||
moduleNameMapper: {
|
||||
"^@lib/(.*)$": "<rootDir>/apps/api/lib/$1",
|
||||
"^@api/(.*)$": "<rootDir>/apps/api/pages/api/$1",
|
||||
},
|
||||
// setupFilesAfterEnv: ["<rootDir>/apps/api/jest.setup.ts"], // Uncomment when API becomes public
|
||||
},
|
||||
// FIXME: Prevent this breaking Jest when API module is missing
|
||||
// {
|
||||
// displayName: "@calcom/api",
|
||||
// roots: ["<rootDir>/apps/api"],
|
||||
// testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
|
||||
// setupFilesAfterEnv: ["<rootDir>/tests/config/singleton.ts"],
|
||||
// transform: {
|
||||
// "^.+\\.ts?$": "ts-jest",
|
||||
// },
|
||||
// globals: {
|
||||
// "ts-jest": {
|
||||
// tsconfig: "<rootDir>/apps/api/tsconfig.json",
|
||||
// },
|
||||
// },
|
||||
// transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
|
||||
// testEnvironment: "node",
|
||||
// clearMocks: true,
|
||||
// moduleNameMapper: {
|
||||
// "^@lib/(.*)$": "<rootDir>/apps/api/lib/$1",
|
||||
// "^@api/(.*)$": "<rootDir>/apps/api/pages/api/$1",
|
||||
// },
|
||||
// // setupFilesAfterEnv: ["<rootDir>/apps/api/jest.setup.ts"], // Uncomment when API becomes public
|
||||
// },
|
||||
],
|
||||
watchPlugins: [
|
||||
"jest-watch-typeahead/filename",
|
||||
|
|
|
@ -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",
|
||||
|
@ -45,6 +51,7 @@
|
|||
"lint": "turbo run lint",
|
||||
"postinstall": "turbo run post-install",
|
||||
"pre-commit": "lint-staged",
|
||||
"preinstall": "./git-init.sh",
|
||||
"predev": "echo 'Checking env files'",
|
||||
"prepare": "husky install",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
|
|
|
@ -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.
|
|
@ -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": [
|
||||
|
|
|
@ -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
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
|
@ -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: "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();
|
||||
}
|
|
@ -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} />);
|
||||
|
|
|
@ -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} />;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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" />;
|
||||
}
|
|
@ -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" />;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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");
|
|
@ -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`);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
export type SupportedCommands =
|
||||
| "create"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "create-template"
|
||||
| "delete-template"
|
||||
| "edit-template";
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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",
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -62,6 +55,11 @@ export async function getAppRegistryWithCredentials(userId: number) {
|
|||
select: safeCredentialSelect,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
credentials: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
});
|
||||
const apps = [] as (App & {
|
||||
credentials: Credential[];
|
||||
|
|
|
@ -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_
|
|
@ -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;
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"/*": "This file would be automatically updated by cli according to the inputs"
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -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 || ""}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as example } from "./example";
|
|
@ -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!");
|
||||
},
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as InstallAppButton } from "./InstallAppButton";
|
|
@ -1,3 +0,0 @@
|
|||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
export { metadata } from "./_metadata";
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default as VideoApiAdapter } from "./VideoApiAdapter";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user