Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17a0a61313 | ||
|
|
12b7739c9a | ||
|
|
ff283a25d2 | ||
|
|
4b343f20a4 | ||
|
|
5bb6904ffd | ||
|
|
0e04f5d338 | ||
|
|
da0ea5d9d0 | ||
|
|
1a19261b8f | ||
|
|
55a6c65db6 | ||
|
|
c9ed3388ae | ||
|
|
2588b7ef84 | ||
|
|
7ce83542e2 | ||
|
|
0bb0883e64 | ||
|
|
d3be62fbdd | ||
|
|
736308e43a | ||
|
|
791927e45e | ||
|
|
fa984e1568 | ||
|
|
9b197da5be | ||
|
|
b7c316b01b | ||
|
|
5ab56310bf | ||
|
|
52e318ca18 | ||
|
|
8d078564dd | ||
|
|
7b5482e83d | ||
|
|
23086476b3 | ||
|
|
00cab5c41f | ||
|
|
fbc27e1d52 | ||
|
|
e1c9a8cbfc | ||
|
|
8ee109e975 | ||
|
|
cfaecc3e02 | ||
|
|
18246e0dc1 | ||
|
|
6dcb16724c | ||
|
|
d4c5a906b5 | ||
|
|
27e8d73b82 | ||
|
|
25eae4a546 | ||
|
|
f663774fee | ||
|
|
e2258fba27 | ||
|
|
d0d8878f34 | ||
|
|
59ff6c35ee | ||
|
|
3c07e305ce | ||
|
|
b5f274d663 | ||
|
|
6c3ae984fc | ||
|
|
a2e9bf0504 | ||
|
|
8aa089efe6 | ||
|
|
de64d3d9d4 | ||
|
|
71833c0737 | ||
|
|
c78f4deaf7 | ||
|
|
24fc7d611f | ||
|
|
8afbc3b457 | ||
|
|
da18aae21c | ||
|
|
87a0d533ef | ||
|
|
7cdd453953 |
@@ -82,6 +82,7 @@ SEND_FEEDBACK_EMAIL=
|
||||
# Used for email reminders in workflows and internal sync services
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_EMAIL=
|
||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||
|
||||
# Twilio
|
||||
# Used to send SMS reminders in workflows
|
||||
@@ -89,6 +90,7 @@ TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
|
||||
|
||||
@@ -80,3 +80,9 @@ apps/storybook/build-storybook.log
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
# Submodules
|
||||
.gitmodules
|
||||
apps/api
|
||||
apps/website
|
||||
apps/console
|
||||
|
||||
-12
@@ -1,12 +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
|
||||
@@ -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
|
||||
@@ -425,6 +420,7 @@ following
|
||||
3. Copy API key to your .env file into the SENDGRID_API_KEY field
|
||||
4. Go to Settings -> Sender Authentication and verify a single sender
|
||||
5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field
|
||||
6. Add your custom sender name to the .env file into the NEXT_PUBLIC_SENDGRID_SENDER_NAME field (fallback is Cal.com)
|
||||
|
||||
### Setting up Twilio for SMS reminders
|
||||
|
||||
|
||||
-1
Submodule apps/api deleted from 7aebdb8c96
-1
Submodule apps/console deleted from 9aac72159e
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
storybook-static
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -21,4 +22,4 @@ dist-ssr
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sw?
|
||||
@@ -11,13 +11,15 @@ module.exports = {
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"storybook-addon-rtl-direction",
|
||||
"storybook-react-i18next",
|
||||
{
|
||||
"storybook-addon-next",
|
||||
/*{
|
||||
name: "storybook-addon-next",
|
||||
options: {
|
||||
nextConfigPath: path.resolve(__dirname, "../../web/next.config.js"),
|
||||
},
|
||||
},
|
||||
},*/
|
||||
],
|
||||
framework: "@storybook/react",
|
||||
core: {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const withTM = require("next-transpile-modules")([
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/trpc",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-react",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/ui",
|
||||
]);
|
||||
const glob = require("glob");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withTM, withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ["www.datocms-assets.com"],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: { images: { allowFutureImage: true } },
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
if (!isServer) {
|
||||
// don't resolve 'fs' module on the client to prevent this error on build --> Error: Can't resolve 'fs'
|
||||
config.resolve.fallback = {
|
||||
fs: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
@@ -22,7 +22,8 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"next": "^13.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook-addon-rtl-direction": "^0.0.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
@@ -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,19 +954,14 @@ const BookingPage = ({
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
tooltip={t("additional_guests")}
|
||||
StartIcon={Icon.FiUserPlus}
|
||||
onClick={() => setGuestToggle(!guestToggle)}
|
||||
className="mr-auto"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
color="minimal"
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
// We override this for this component only for now - as we don't support darkmode everywhere in the app
|
||||
className="dark:hover:bg-darkgray-200 dark:border-none dark:text-white">
|
||||
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { components } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -14,32 +13,25 @@ import {
|
||||
LocationObject,
|
||||
LocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import Select from "@components/ui/form/Select";
|
||||
import LocationSelect, { LocationOption } from "@components/ui/form/LocationSelect";
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
value: EventLocationType["type"];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: OptionTypeBase;
|
||||
selection?: LocationOption;
|
||||
booking?: BookingItem;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
||||
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||
setEditingLocationType?: (param: string) => void;
|
||||
}
|
||||
|
||||
@@ -211,8 +203,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent>
|
||||
<Dialog open={isOpenDialog} onOpenChange={(open) => setShowLocationModal(open)}>
|
||||
<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" />
|
||||
@@ -296,38 +288,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select<{ label: string; value: string; icon?: string }>
|
||||
<LocationSelect
|
||||
maxMenuHeight={300}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={locationOptions}
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<div className="flex items-center gap-3">
|
||||
{props.data.icon && (
|
||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
props.isSelected ? "text-white" : "text-gray-900"
|
||||
)}>
|
||||
{props.data.label}
|
||||
</span>
|
||||
</div>
|
||||
</components.Option>
|
||||
),
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
formatGroupLabel={(e) => (
|
||||
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
||||
)}
|
||||
isSearchable
|
||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||
onChange={(val) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -133,108 +133,110 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
||||
<Controller
|
||||
name="beforeBufferTime"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.beforeEventBuffer || 0}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const beforeBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
defaultValue={
|
||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||
}
|
||||
options={beforeBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
||||
<Controller
|
||||
name="beforeBufferTime"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.beforeEventBuffer || 0}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const beforeBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
defaultValue={
|
||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||
}
|
||||
options={beforeBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
||||
<Controller
|
||||
name="afterBufferTime"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.afterEventBuffer || 0}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const afterBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
defaultValue={
|
||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
||||
}
|
||||
options={afterBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
||||
<Controller
|
||||
name="afterBufferTime"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.afterEventBuffer || 0}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const afterBufferOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
defaultValue={
|
||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
||||
}
|
||||
options={afterBufferOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
control={formMethods.control}
|
||||
render={() => {
|
||||
const slotIntervalOptions = [
|
||||
{
|
||||
label: t("slot_interval_default"),
|
||||
value: -1,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||
}}
|
||||
defaultValue={
|
||||
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
||||
slotIntervalOptions[0]
|
||||
}
|
||||
options={slotIntervalOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
control={formMethods.control}
|
||||
render={() => {
|
||||
const slotIntervalOptions = [
|
||||
{
|
||||
label: t("slot_interval_default"),
|
||||
value: -1,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||
}}
|
||||
defaultValue={
|
||||
slotIntervalOptions.find((option) => option.value === eventType.slotInterval) ||
|
||||
slotIntervalOptions[0]
|
||||
}
|
||||
options={slotIntervalOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
@@ -428,7 +430,7 @@ const BookingLimits = () => {
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="icon"
|
||||
StartIcon={Icon.FiTrash}
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
|
||||
@@ -17,11 +17,21 @@ import { Button, Icon, Label, Select, SettingsToggle, Skeleton, TextField } from
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import LocationSelect, {
|
||||
SingleValueLocationOption,
|
||||
LocationOption,
|
||||
} from "@components/ui/form/LocationSelect";
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
value: EventLocationType["type"];
|
||||
disabled?: boolean;
|
||||
const getLocationFromType = (
|
||||
type: EventLocationType["type"],
|
||||
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
||||
) => {
|
||||
for (const locationOption of locationOptions) {
|
||||
const option = locationOption.options.find((option) => option.value === type);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
@@ -32,7 +42,7 @@ export const EventSetupTab = (
|
||||
const { eventType, locationOptions, team } = props;
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
||||
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||
@@ -51,7 +61,8 @@ export const EventSetupTab = (
|
||||
);
|
||||
|
||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
||||
const option = getLocationFromType(type, locationOptions);
|
||||
setSelectedLocation(option);
|
||||
setShowLocationModal(true);
|
||||
};
|
||||
|
||||
@@ -131,14 +142,14 @@ export const EventSetupTab = (
|
||||
<div className="w-full">
|
||||
{validLocations.length === 0 && (
|
||||
<div className="flex">
|
||||
<Select
|
||||
<LocationSelect
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
onChange={(e) => {
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
if (e?.value) {
|
||||
const newLocationType: EventLocationType["type"] = e.value;
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
@@ -162,7 +173,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
|
||||
@@ -362,7 +373,9 @@ export const EventSetupTab = (
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation ? { value: selectedLocation.value, label: t(selectedLocation.label) } : undefined
|
||||
selectedLocation
|
||||
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
|
||||
@@ -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}
|
||||
@@ -245,8 +245,13 @@ function EventTypeSingleLayout({
|
||||
<VerticalDivider className="hidden lg:block" />
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="block h-9 w-9 justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 lg:hidden">
|
||||
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="lg:hidden"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
@@ -324,7 +329,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);
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="border-1 mx-2 rounded-md border-gray-200 bg-white px-4 py-10 sm:px-10">
|
||||
<div className="mx-2 rounded-md border border-gray-200 bg-white px-4 py-10 sm:px-10">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-gray-600">
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
|
||||
import { Maybe } from "@calcom/trpc/server";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export type AvatarProps = {
|
||||
className?: string;
|
||||
size?: number;
|
||||
imageSrc?: Maybe<string>;
|
||||
title?: string;
|
||||
alt: string;
|
||||
gravatarFallbackMd5?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use AvatarSSR instead. Once, there is no usage of Avatar, AvatarSSR can be renamed.
|
||||
*/
|
||||
export default function Avatar(props: AvatarProps) {
|
||||
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
const avatar = (
|
||||
<AvatarPrimitive.Root>
|
||||
<AvatarPrimitive.Image
|
||||
src={imageSrc ?? undefined}
|
||||
alt={alt}
|
||||
className={classNames("rounded-full", `h-auto w-${size}`, props.className)}
|
||||
/>
|
||||
<AvatarPrimitive.Fallback delayMs={600}>
|
||||
{gravatarFallbackMd5 && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={className} />
|
||||
)}
|
||||
</AvatarPrimitive.Fallback>
|
||||
</AvatarPrimitive.Root>
|
||||
);
|
||||
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Tooltip>
|
||||
) : (
|
||||
<>{avatar}</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
export type AvatarGroupProps = {
|
||||
border?: string; // this needs to be the color of the parent container background, i.e.: border-white dark:border-gray-900
|
||||
size: number;
|
||||
truncateAfter?: number;
|
||||
items: {
|
||||
image: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
href?: string;
|
||||
}[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
return (
|
||||
<ul className={classNames(props.className)}>
|
||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
|
||||
if (item.image != null) {
|
||||
const avatar = (
|
||||
<AvatarSSR
|
||||
className={props.border}
|
||||
imageSrc={item.image}
|
||||
title={item.title}
|
||||
alt={item.alt || ""}
|
||||
size={props.size}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={idx} className="-ltr:mr-2 inline-block rtl:ml-2">
|
||||
{item.href ? <Link href={item.href}>{avatar}</Link> : avatar}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarGroup;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { User } from "@prisma/client";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export type AvatarProps = (
|
||||
| {
|
||||
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
|
||||
}
|
||||
| {
|
||||
user?: null;
|
||||
imageSrc: string;
|
||||
}
|
||||
) & {
|
||||
className?: string;
|
||||
size?: number;
|
||||
title?: string;
|
||||
href?: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
// defaultAvatarSrc from profile.tsx can't be used as it imports crypto
|
||||
function defaultAvatarSrc(md5: string) {
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`;
|
||||
}
|
||||
|
||||
// An SSR Supported version of Avatar component.
|
||||
export function AvatarSSR(props: AvatarProps) {
|
||||
const { size, title } = props;
|
||||
|
||||
let imgSrc = "";
|
||||
let alt: string = props.alt;
|
||||
|
||||
if (props.user) {
|
||||
const user = props.user;
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
alt = alt || nameOrUsername;
|
||||
|
||||
if (user.avatar) {
|
||||
imgSrc = user.avatar;
|
||||
} else if (user.emailMd5) {
|
||||
imgSrc = defaultAvatarSrc(user.emailMd5);
|
||||
}
|
||||
} else {
|
||||
imgSrc = props.imageSrc;
|
||||
}
|
||||
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
|
||||
const avatar = imgSrc ? <img alt={alt} className={className} src={imgSrc} /> : null;
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger asChild>{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Tooltip>
|
||||
) : (
|
||||
<>{avatar}</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -131,7 +131,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 h-6 rounded-md ltr:rounded-l-none rtl:rounded-r-none",
|
||||
"mb-0 mt-0 rounded-md ltr:rounded-l-none rtl:rounded-r-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
||||
@@ -2,9 +2,8 @@ import React from "react";
|
||||
import { Props } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Icon, Avatar } from "@calcom/ui";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type CheckedSelectOption = {
|
||||
@@ -41,9 +40,10 @@ 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"
|
||||
className="inline ltr:mr-2 rtl:ml-2"
|
||||
size="sm"
|
||||
imageSrc={option.avatar}
|
||||
alt={option.label}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { components, GroupBase, Props, SingleValue } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
export type LocationOption = {
|
||||
label: string;
|
||||
value: EventLocationType["type"];
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
|
||||
export type GroupOptionType = GroupBase<LocationOption>;
|
||||
|
||||
const OptionWithIcon = ({
|
||||
icon,
|
||||
isSelected,
|
||||
label,
|
||||
}: {
|
||||
icon?: string;
|
||||
isSelected?: boolean;
|
||||
label: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />}
|
||||
<span className={classNames("text-sm font-medium", isSelected ? "text-white" : "text-gray-900")}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LocationSelect(props: Props<LocationOption, false, GroupOptionType>) {
|
||||
return (
|
||||
<Select<LocationOption>
|
||||
name="location"
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} isSelected={props.isSelected} />
|
||||
</components.Option>
|
||||
),
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.SingleValue>
|
||||
),
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
formatGroupLabel={(e) => <p className="text-xs font-medium text-gray-600">{e.label}</p>}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -26,13 +26,11 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Icon, HeadSeo, AvatarGroup } from "@calcom/ui";
|
||||
import { Icon, HeadSeo, AvatarGroup, Avatar } from "@calcom/ui";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
||||
@@ -56,7 +54,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 })}
|
||||
@@ -132,7 +130,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
)}>
|
||||
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||
<div className="mb-8 text-center">
|
||||
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername} />
|
||||
<Avatar imageSrc={user.avatar} size="xl" alt={nameOrUsername} />
|
||||
<h1 className="font-cal mb-1 text-3xl text-gray-900 dark:text-white">
|
||||
{nameOrUsername}
|
||||
{user.verified && (
|
||||
@@ -145,8 +143,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 ? (
|
||||
@@ -165,7 +162,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">
|
||||
|
||||
@@ -27,7 +27,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">
|
||||
@@ -42,7 +42,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">
|
||||
|
||||
@@ -81,7 +81,7 @@ class MyDocument extends Document<Props> {
|
||||
</Head>
|
||||
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100"
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
|
||||
style={
|
||||
this.props.isEmbed
|
||||
? {
|
||||
|
||||
@@ -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={
|
||||
@@ -53,7 +54,7 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,7 +62,7 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true };
|
||||
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: ctx.params.slug },
|
||||
where: { slug: ctx.params.slug.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!app) return { notFound: true };
|
||||
@@ -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 },
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
HorizontalTabs,
|
||||
Icon,
|
||||
TextField,
|
||||
TrendingAppsSlider,
|
||||
PopularAppsSlider,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppsLayout from "@components/apps/layouts/AppsLayout";
|
||||
@@ -77,7 +77,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">
|
||||
|
||||
@@ -186,11 +186,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}
|
||||
|
||||
@@ -84,7 +84,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>
|
||||
);
|
||||
|
||||
@@ -637,7 +637,7 @@ export default function Success(props: SuccessProps) {
|
||||
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">
|
||||
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"
|
||||
@@ -660,7 +660,7 @@ export default function Success(props: SuccessProps) {
|
||||
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"
|
||||
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"
|
||||
@@ -684,7 +684,7 @@ export default function Success(props: SuccessProps) {
|
||||
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"
|
||||
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"
|
||||
@@ -697,7 +697,7 @@ export default function Success(props: SuccessProps) {
|
||||
</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"
|
||||
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"
|
||||
|
||||
@@ -251,7 +251,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}`;
|
||||
@@ -312,7 +312,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<Button
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
href={calLink}
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
/>
|
||||
@@ -321,7 +321,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");
|
||||
@@ -336,7 +336,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}
|
||||
/>
|
||||
@@ -396,7 +396,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>
|
||||
|
||||
@@ -29,7 +29,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" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,7 +123,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>
|
||||
@@ -169,7 +169,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}
|
||||
|
||||
@@ -17,15 +17,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemTitle,
|
||||
Meta,
|
||||
showToast,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
@@ -76,22 +75,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>
|
||||
@@ -110,8 +104,8 @@ const ConferencingLayout = () => {
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
|
||||
@@ -223,7 +223,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
|
||||
|
||||
@@ -16,7 +16,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`}>
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "يحتوي على رقم واحد على الأقل",
|
||||
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من 7 أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
|
||||
"incorrect_password": "كلمة المرور غير صحيحة.",
|
||||
"1_on_1": "اجتماع 1 مع 1",
|
||||
"24_h": "24 ساعة",
|
||||
"use_setting": "استخدام الإعداد",
|
||||
"am_pm": "صباحًا/مساءً",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Obsahuje alespoň 1 číslo",
|
||||
"invalid_password_hint": "Heslo musí být minimálně 7 znaků dlouhé, obsahovat alespoň jedno číslo a obsahovat kombinaci velkých a malých písmen",
|
||||
"incorrect_password": "Heslo není správné.",
|
||||
"1_on_1": "Jeden na jednoho",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Použít nastavení",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"have_any_questions": "Haben Sie Fragen? Wir sind hier um Ihnen zu helfen.",
|
||||
"reset_password_subject": "{{appName}}: Anleitung zum Zurücksetzen des Passworts",
|
||||
"event_declined_subject": "Abgelehnt: {{eventType}} mit {{name}} am {{date}}",
|
||||
"event_cancelled_subject": "Abgesagt: {{eventType}} mit {{name}} am {{date}}",
|
||||
"event_cancelled_subject": "Storniert: {{title}} um {{date}}",
|
||||
"event_request_declined": "Ihre Event-Anfrage wurde abgelehnt",
|
||||
"event_request_declined_recurring": "Ihre wiederkehrende Terminanfrage wurde abgelehnt",
|
||||
"event_request_cancelled": "Ihr geplanter Termin wurde abgesagt",
|
||||
@@ -425,7 +425,6 @@
|
||||
"password_hint_num": "Enthält mindestens 1 Zahl",
|
||||
"invalid_password_hint": "Das Passwort muss mindestens 7 Zeichen lang sein und mindestens eine Nummer beinhalten, sowie eine Mischung aus Groß- und Kleinbuchstaben sein",
|
||||
"incorrect_password": "Passwort ist falsch",
|
||||
"1_on_1": "1-on-1",
|
||||
"24_h": "24 Std",
|
||||
"use_setting": "Benutze Einstellung",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"rejection_reason_description": "Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.",
|
||||
"rejection_confirmation": "Reject the booking",
|
||||
"manage_this_event": "Manage this event",
|
||||
"invite_team_member": "Invite team member",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||
@@ -64,7 +65,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",
|
||||
@@ -429,7 +430,6 @@
|
||||
"password_hint_num": "Contain at least 1 number",
|
||||
"invalid_password_hint": "The password must be a minimum of 7 characters long containing at least one number and have a mixture of uppercase and lowercase letters",
|
||||
"incorrect_password": "Password is incorrect.",
|
||||
"1_on_1": "1-on-1",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Use setting",
|
||||
"am_pm": "am/pm",
|
||||
@@ -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",
|
||||
@@ -1463,6 +1465,7 @@
|
||||
"individual":"Individual",
|
||||
"all_bookings_filter_label":"All Bookings",
|
||||
"all_users_filter_label":"All Users",
|
||||
"your_bookings_filter_label":"Your Bookings",
|
||||
"meeting_url_variable": "Meeting url",
|
||||
"meeting_url_info": "The event meeting conference url",
|
||||
"date_overrides": "Date overrides",
|
||||
@@ -1510,5 +1513,6 @@
|
||||
"continue_to_install_google_calendar": "Continue to install Google Calendar",
|
||||
"install_google_meet": "Install Google Meet",
|
||||
"install_google_calendar": "Install Google Calendar",
|
||||
"sender_name": "Sender name",
|
||||
"no_recordings_found": "No recordings found"
|
||||
}
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Debe contener al menos 1 número",
|
||||
"invalid_password_hint": "La contraseña debe tener un mínimo de 7 caracteres que contengan al menos un número y una mezcla de letras mayúsculas y minúsculas",
|
||||
"incorrect_password": "La Contraseña es Incorrecta.",
|
||||
"1_on_1": "1 a 1",
|
||||
"24_h": "24hs",
|
||||
"use_setting": "Usar Ajuste",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -429,7 +429,6 @@
|
||||
"password_hint_num": "Contient au moins 1 chiffre",
|
||||
"invalid_password_hint": "Le mot de passe doit contenir au moins 7 caractères, un chiffre et un mélange de lettres, majuscules et minuscules.",
|
||||
"incorrect_password": "Le mot de passe est incorrect.",
|
||||
"1_on_1": "tête-à-tête",
|
||||
"24_h": "24 h",
|
||||
"use_setting": "Utiliser le paramètre",
|
||||
"am_pm": "AM/PM",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "לכלול לפחות ספרה אחת",
|
||||
"invalid_password_hint": "הסיסמה חייבת להיות באורך של 7 תווים לפחות ולכלול לפחות ספרה אחת ושילוב של אותיות רישיות וקטנות",
|
||||
"incorrect_password": "הסיסמה שגויה.",
|
||||
"1_on_1": "אחד על אחד",
|
||||
"24_h": "24 שעות",
|
||||
"use_setting": "השתמש בהגדרה",
|
||||
"am_pm": "לפנה\"צ/אחה\"צ",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Deve contenere almeno 1 numero",
|
||||
"invalid_password_hint": "La password deve avere una lunghezza minima di 7 caratteri e deve contenere almeno 1 numero e una combinazione di caratteri maiuscoli e minuscoli",
|
||||
"incorrect_password": "La password non è corretta.",
|
||||
"1_on_1": "1-su-1",
|
||||
"24_h": "24 ore",
|
||||
"use_setting": "Usa impostazione",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "少なくとも 1 文字は数字を含めてください",
|
||||
"invalid_password_hint": "パスワードには少なくとも 1 文字以上数字を含め、アルファベットの大文字と小文字を両方使用した上で 7 文字以上の長さに設定してください",
|
||||
"incorrect_password": "パスワードが正しくありません。",
|
||||
"1_on_1": "1対1",
|
||||
"24_h": "24時間",
|
||||
"use_setting": "設定を使用",
|
||||
"am_pm": "午前/午後",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "1개 이상 숫자 포함",
|
||||
"invalid_password_hint": "비밀번호는 7자 이상이어야 하고 숫자가 하나 이상 포함되어야 하며 대문자와 소문자가 혼합되어야 합니다",
|
||||
"incorrect_password": "비밀번호가 잘못되었습니다",
|
||||
"1_on_1": "일대일",
|
||||
"24_h": "24시간",
|
||||
"use_setting": "사용 설정",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Minimaal 1 cijfer bevatten",
|
||||
"invalid_password_hint": "Het wachtwoord moet minimaal 7 tekens lang zijn, minimaal 1 cijfer bevatten en bestaan uit een mix van hoofd- en kleine letters",
|
||||
"incorrect_password": "Uw wachtwoord is incorrect.",
|
||||
"1_on_1": "1 op 1",
|
||||
"24_h": "24u",
|
||||
"use_setting": "Instelling gebruiken",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -429,7 +429,6 @@
|
||||
"password_hint_num": "Inneholde minst 1 tall",
|
||||
"invalid_password_hint": "Passordet må være minst 7 tegn langt og inneholde minst ett tall og ha en blanding av store og små bokstaver",
|
||||
"incorrect_password": "Passordet er feil.",
|
||||
"1_on_1": "1-til-1",
|
||||
"24_h": "24t",
|
||||
"use_setting": "Bruk innstilling",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Zawrzyj co najmniej 1 cyfrę",
|
||||
"invalid_password_hint": "Hasło musi składać się z co najmniej 7 znaków, zawierać co najmniej jedną cyfrę oraz kombinację wielkich i małych liter",
|
||||
"incorrect_password": "Hasło jest nieprawidłowe.",
|
||||
"1_on_1": "1-na-1",
|
||||
"24_h": "24 godz.",
|
||||
"use_setting": "Użyj ustawień",
|
||||
"am_pm": "rano/po południu",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Contém pelo menos um número",
|
||||
"invalid_password_hint": "A senha precisa ter pelo menos sete caracteres com pelo menos um número e ser uma combinação de letras maiúsculas e minúsculas",
|
||||
"incorrect_password": "Senha incorreta.",
|
||||
"1_on_1": "1 em 1",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Usar configurações",
|
||||
"am_pm": "manhã/tarde",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Contém pelo menos 1 número",
|
||||
"invalid_password_hint": "A palavra-passe deve ter no mínimo 7 caracteres, com pelo menos um número e uma mistura de letras maiúsculas e minúsculas",
|
||||
"incorrect_password": "Palavra-Passe incorreta",
|
||||
"1_on_1": "1 para 1",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Usar Configuração",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Conține cel puțin 1 cifră",
|
||||
"invalid_password_hint": "Parola trebuie să aibă cel puțin 7 caractere, să conțină cel puțin o cifră și o combinație de litere mari și litere mici",
|
||||
"incorrect_password": "Parola este incorectă.",
|
||||
"1_on_1": "1-la-1",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Folosește setarea",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Не менее 1 цифры",
|
||||
"invalid_password_hint": "Пароль должен быть не короче 7 символов и содержать заглавные и строчные буквы, а также не менее одной цифры",
|
||||
"incorrect_password": "Неверный пароль",
|
||||
"1_on_1": "1-на-1",
|
||||
"24_h": "24 часа",
|
||||
"use_setting": "Использовать настройки",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Sadrži barem 1 broj",
|
||||
"invalid_password_hint": "Lozinka mora da bude dugačka najmanje 7 znakova, da sadrži najmanje jedan broj i da ima mešavinu velikih i malih slova",
|
||||
"incorrect_password": "Lozinka je netačna.",
|
||||
"1_on_1": "1 na 1",
|
||||
"24_h": "24 sata",
|
||||
"use_setting": "Koristi podešavanje",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Innehåller minst 1 siffra",
|
||||
"invalid_password_hint": "Lösenordet måste innehålla minst 7 tecken och har minst en siffra samt en blandning av versaler och gemener",
|
||||
"incorrect_password": "Lösenordet är felaktigt.",
|
||||
"1_on_1": "1 och 1",
|
||||
"24_h": "24 timmar",
|
||||
"use_setting": "Använd inställning",
|
||||
"am_pm": "am/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "En az 1 rakam içermelidir",
|
||||
"invalid_password_hint": "Şifre en az 7 karakter uzunluğunda olmalı, en az bir rakam içermeli ve büyük ve küçük harflerin karışımından oluşmalıdır",
|
||||
"incorrect_password": "Şifre hatalı.",
|
||||
"1_on_1": "Bire bir",
|
||||
"24_h": "24 sa",
|
||||
"use_setting": "Ayarı kullan",
|
||||
"am_pm": "öö/ös",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Містить принаймні 1 цифру",
|
||||
"invalid_password_hint": "Пароль має містити не менше ніж 7 символів: принаймні одну цифру та комбінацію великих і малих літер",
|
||||
"incorrect_password": "Пароль неправильний.",
|
||||
"1_on_1": "1 на 1",
|
||||
"24_h": "24 год",
|
||||
"use_setting": "Використовувати налаштування",
|
||||
"am_pm": "дп/пп",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "Chứa ít nhất 1 con số",
|
||||
"invalid_password_hint": "Mật khẩu phải có tối thiểu 7 ký tự chứa ít nhất một con số và phải kết hợp chữ cái in hoa lẫn in thường",
|
||||
"incorrect_password": "Mật khẩu không đúng.",
|
||||
"1_on_1": "1 với 1",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Sử dụng cài đặt",
|
||||
"am_pm": "\u001dam/pm",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "包含至少 1 个数字",
|
||||
"invalid_password_hint": "密码必须是至少 7 个字符长,包含至少一个数字,并且是大写和小写字母混合",
|
||||
"incorrect_password": "密码不正确。",
|
||||
"1_on_1": "1对1",
|
||||
"24_h": "24 小时制",
|
||||
"use_setting": "使用设置",
|
||||
"am_pm": "12小时制",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"password_hint_num": "至少包含 1 個數字",
|
||||
"invalid_password_hint": "密碼長度至少要有 7 個字元,其中包含至少一個數字和大小寫字母組合",
|
||||
"incorrect_password": "密碼不正確。",
|
||||
"1_on_1": "1 對 1",
|
||||
"24_h": "24 小時",
|
||||
"use_setting": "使用設定",
|
||||
"am_pm": "上午/下午",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
-1
Submodule apps/website deleted from 8f5d66f2cc
Executable
+8
@@ -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
|
||||
Executable
+35
@@ -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
|
||||
+23
-22
@@ -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",
|
||||
@@ -44,6 +50,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",
|
||||
@@ -77,6 +84,7 @@
|
||||
"turbo": "^1.4.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@apidevtools/json-schema-ref-parser": "9.0.9",
|
||||
"@types/node": "16.9.1",
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6"
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user