Compare commits

...
51 Commits
Author SHA1 Message Date
Alex van Andel 17a0a61313 v2.5.3
release.yml / release (push) Canceled after 0s
2023-01-22 00:06:41 +00:00
Alex van Andel 12b7739c9a Bugfix/dropdown menu trigger as child remove class names (#6614)
* Fix UsernameTextfield to take right height

* Remove className side-effect

* Incorrect resolution version fixed

* Converted mobile DropdownMenuTrigger styles into Button
2023-01-21 17:03:59 -07:00
Alex van Andel ff283a25d2 Reduce the avatar size to 'sm' for now 2023-01-21 22:09:32 +00:00
sean-brydon 4b343f20a4 Update booking filters design (#6543)
* Update booking filters

* Add filter on YOUR bookings

* Fix pending members showing up in list
2023-01-21 18:15:59 +01:00
Udit Takkar 5bb6904ffd feat: add LocationSelect component (#6571)
* feat: add LocationSelect component

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-21 16:52:21 +00:00
sean-brydon 0e04f5d338 Fix FAB (#6611) 2023-01-21 16:14:08 +00:00
Nafees Nazik da0ea5d9d0 fix: case-sensitivity in apps path (#6552)
* fix: lowercase slug

* fix: make fallback blocking
2023-01-21 16:13:55 +00:00
Peer Richelsen 1a19261b8f remove 1 on 1 icon (#6609)
* removed 1-on-1 badge

* changed user to users for group events
2023-01-21 16:07:45 +00:00
Luis Cadillo 55a6c65db6 fix: remove annoying outline when <Button /> clicked (#6537)
* fix: remove annoying outline when <Button /> clicked

* Delete yarn.lock
2023-01-21 16:07:30 +00:00
Alex van Andel c9ed3388ae Regenerated lockfile without upgrade (#6610) 2023-01-21 15:49:35 +00:00
2588b7ef84 Harsh/add member invite (#6598)
Co-authored-by: Guest <guest@pop-os.localdomain>
Co-authored-by: root <harsh.singh@gocomet.com>
2023-01-21 16:32:00 +01:00
7ce83542e2 [CAL-811] Avatar icon not redirecting user back to the main page (#6586)
* Remove cursor-pointer, remove old Avatar* files

* Fixed styling for checkedSelect + some cleanup

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-01-21 11:41:49 +00:00
Alex van Andel 0bb0883e64 Fix string equality check in git-setup.sh 2023-01-21 06:24:29 +00:00
zomars d3be62fbdd Yarn lock fixes 2023-01-20 16:28:22 -07:00
zomars 736308e43a Test fixes 2023-01-20 15:10:09 -07:00
Peer Richelsen 791927e45e Update package.json
release.yml / release (push) Canceled after 0s
2023-01-20 23:06:43 +01:00
Alex van Andel fa984e1568 Changed a neutral- classes to gray (#6603)
* Changed a neutral- classes to gray

* Changed all border-1 to border
2023-01-20 23:04:58 +01:00
Peer Richelsen 9b197da5be Update README.md
release.yml / release (push) Canceled after 0s
2023-01-20 21:58:11 +01:00
Peer Richelsen b7c316b01b Update README.md 2023-01-20 21:57:52 +01:00
Udit Takkar 5ab56310bf fix: add delete in dropdown (#6599)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-20 14:57:14 -05:00
Udit Takkar 52e318ca18 fix: show button on empty string (#6601)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-20 12:27:58 -07:00
Omar López 8d078564dd Bye bye submodules (#6585)
* WIP

* Uses ssh instead

* Update .gitignore

* Update .gitignore

* Update Makefile

* Update git-setup.sh

* Update git-setup.sh

* Replaced Makefile with bash script

* Update package.json
2023-01-20 12:17:51 -07:00
7b5482e83d [CAL-808] /availability/single - UI issue on buttons beside time inputs (#6561)
* [CAL-808] /availability/single - UI issue on buttons beside time inputs

* Update apps/web/public/static/locales/en/common.json

* Update packages/features/schedules/components/Schedule.tsx

* create new translation for tooltip

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-01-20 17:56:28 +00:00
sean-brydon 23086476b3 Tailwind Merge (#6596)
* Tailwind Merge

* Fix merge classNames
2023-01-20 15:19:12 +01:00
Nafees Nazik 00cab5c41f fix: react-select overflow issue in some modals. (#6587)
* feat: add a disable overflow prop

* feat: use the disable overflow prop
2023-01-20 13:29:37 +00:00
sean-brydon fbc27e1d52 Fix booking filters not working for admin (#6576) 2023-01-19 19:23:58 +01:00
Nafees Nazik e1c9a8cbfc fix: i18n placeholder in awaiting approval email (#6573) 2023-01-19 16:56:44 +01:00
Nafees Nazik 8ee109e975 fix: app list item ui issues in /settings/my-account/conferencing (#6565)
* fix: border color

* fix: padding and title line-height

* feat: add app-list card

* feat: use the AppListCard component

* nit: remove unused imports
2023-01-19 15:02:01 +00:00
sean-brydonandAlex van Andel cfaecc3e02 Icon small button variant (#6525)
* Icon small button variant

* Rename

* Update CVA + Update button WIP

* add ticket ref and fix padding

* Update button and all uses of Icon button

* SB deployment test

* Fixing type error

* Fix lint

* Add icon button to canvas

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-01-19 14:55:32 +00:00
Syed Ali Shahbaz 18246e0dc1 path.join causing issues on windows so we keep path like the other exports (#6568) 2023-01-19 13:21:51 +00:00
sean-brydon 6dcb16724c Fixing tabs again (#6566) 2023-01-19 12:05:00 +00:00
d4c5a906b5 App Store Templates (#5289)
* Start by moving what we have to _templates

* WIP

* WIP

* Add create/edit/delete template commands

* Type fixes cli

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-01-18 15:30:25 -07:00
Alex van Andel 27e8d73b82 Revert "experiment: disco border (#6531)"
This reverts commit b5f274d663.
2023-01-18 19:29:36 +00:00
Alex van Andel 25eae4a546 find indexes instead of just one (#6547) 2023-01-18 12:26:07 -07:00
Udit Takkar f663774fee feat: add most popular section in apps (#6539)
* feat: add most popular section in apps

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove margin

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: merge conflict

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-18 11:48:10 -07:00
Nafees NazikandLeo Giovanetti e2258fba27 Feat: webhook form UX improvement (#6448)
* feat: add type and required attribute

* feat: disable ping button when subscriberUrl is
not present

Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-01-18 16:02:20 +00:00
Carina WollendorferandCarinaWolli d0d8878f34 Adds ability to add custom sender names for workflow emails (#6463)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-01-18 15:32:39 +01:00
Udit Takkar 59ff6c35ee fix: limits tab in event types (#6545)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-18 12:58:13 +01:00
Alex van Andel 3c07e305ce Create local next.config.js, add storybook-static to .gitignore (#6544) 2023-01-18 11:54:03 +00:00
Alex van Andel b5f274d663 experiment: disco border (#6531)
* Had a play, looking good (now needs isLoading state or however we want to do that)

* Also add tailwind config..

* Don't show conic bg on lightmode

* Slow down animation, show on light mode

* Apply disco border both on light/dark mode & only on success page

* 5s->8s animation speed, remove border crisping up the corners
2023-01-18 10:40:06 +00:00
Peer Richelsen 6c3ae984fc add tracking URL to LicenseRequired (#6526) 2023-01-17 21:49:40 +00:00
Mohammed Cherfaoui a2e9bf0504 fix toast dimensions issues (#6522) 2023-01-18 02:45:04 +05:30
GitStart-Cal.comandgitstart-calcom 8aa089efe6 [CAL-760] Emails: Design Tokens are not update to 2.0 (#6530)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-01-17 22:14:16 +01:00
sean-brydonandzomars de64d3d9d4 Move exports up a folder (#6519)
* Move exports up a folder

* Fix imports

* Fix imports again

Co-authored-by: zomars <zomars@me.com>
2023-01-17 12:10:47 -07:00
Udit Takkar 71833c0737 fix: type error and border (#6520)
* fix: type error and border

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: sepeator

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-17 17:12:22 +00:00
Udit Takkar c78f4deaf7 fix: use suffix in text field (#6532)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-01-17 18:08:00 +01:00
Hariom Balhara 24fc7d611f Add routing-forms README (#6506) 2023-01-17 17:58:10 +01:00
sean-brydonandHariom Balhara 8afbc3b457 Fixing horizontal tabs (#6454)
* Fix spacing and height

* Testing if its timing out or something else

* So, timep=out is the problem. Increase the timeout to 1s for now

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-01-17 16:36:06 +00:00
sean-brydon da18aae21c Adding rtl support for SB (#6523) 2023-01-17 13:38:14 +01:00
Nafees Nazik 87a0d533ef fix: german translation (#6512) 2023-01-17 13:35:35 +01:00
Nafees Nazik 7cdd453953 fix: button shifting while focusing (#6521)
* fix: remove border on focus

* refactor: remove dark mode styles
2023-01-17 12:21:05 +00:00
360 changed files with 3521 additions and 3358 deletions
+2
View File
@@ -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=
+6
View File
@@ -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
View File
@@ -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
+7 -11
View File
@@ -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
Submodule apps/api deleted from 7aebdb8c96
Submodule apps/console deleted from 9aac72159e
+2 -1
View File
@@ -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?
+4 -2
View File
@@ -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: {
+45
View File
@@ -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);
+2 -1
View File
@@ -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",
+35
View File
@@ -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>
);
}
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View 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
+8 -1
View File
@@ -12,7 +12,7 @@ import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/consta
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { App as AppType } from "@calcom/types/App";
import { Button, Icon, showToast, SkeletonButton, SkeletonText, HeadSeo } from "@calcom/ui";
import { Button, Icon, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
const Component = ({
name,
@@ -34,6 +34,7 @@ const Component = ({
privacy,
isProOnly,
images,
isTemplate,
}: Parameters<typeof App>[0]) => {
const { t } = useLocale();
const hasImages = images && images.length > 0;
@@ -106,6 +107,11 @@ const Component = ({
</Link>{" "}
• {t("published_by", { author })}
</h2>
{isTemplate && (
<Badge variant="red" className="mt-4">
Template - Available in Dev Environment only for testing
</Badge>
)}
</header>
</div>
{!appCredentials.isLoading ? (
@@ -310,6 +316,7 @@ export default function App(props: {
licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"];
images?: string[];
isTemplate?: boolean;
}) {
const { t } = useLocale();
@@ -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)}
+7 -33
View File
@@ -1,43 +1,17 @@
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
import { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import { EventTypeAppsList } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, Icon } from "@calcom/ui";
type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
function AppCardWrapper({
app,
eventType,
getAppData,
setAppData,
}: {
app: RouterOutputs["viewer"]["apps"][number];
eventType: EventType;
getAppData: GetAppData;
setAppData: SetAppData;
}) {
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
if (!Component) {
throw new Error('No component found for "' + dirName + '"');
}
return (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<Component key={app.slug} app={app} eventType={eventType} />
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
}
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { t } = useLocale();
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
@@ -97,7 +71,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
/>
) : null}
{installedApps?.map((app) => (
<AppCardWrapper
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
@@ -113,7 +87,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<AppCardWrapper
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
+104 -102
View File
@@ -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={() => {
+24 -11
View File
@@ -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);
+1 -1
View File
@@ -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">
-51
View File
@@ -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}</>
);
}
-47
View File
@@ -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;
-62
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.5.0",
"version": "2.5.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+5 -8
View File
@@ -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
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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
? {
+16 -5
View File
@@ -35,6 +35,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly}
images={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
@@ -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 },
+2 -2
View File
@@ -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">
+1 -4
View File
@@ -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}
+1 -1
View File
@@ -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={{
+2 -2
View File
@@ -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>
);
+4 -4
View File
@@ -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"
+10 -5
View File
@@ -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>
+1 -1
View File
@@ -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 */}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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": "上午/下午",
+8 -8
View File
@@ -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 {
Submodule apps/website deleted from 8f5d66f2cc
Executable
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+8
View File
@@ -15,6 +15,12 @@
"app-store:build": "yarn app-store-cli build",
"app-store:watch": "yarn app-store-cli watch",
"app-store": "yarn app-store-cli cli",
"create-app": "yarn app-store create",
"edit-app": "yarn app-store edit",
"delete-app": "yarn app-store delete",
"create-app-template": "yarn app-store create-template",
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",
@@ -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"
+9
View File
@@ -0,0 +1,9 @@
## How to build an App using the CLI
Refer to https://developer.cal.com/guides/how-to-build-an-app
## TODO
- Merge app-store:watch and app-store commands; introduce app-store --watch
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Use AppDeclarativeHandler across all apps. Whatever isn't supported in it, support that.
+3 -3
View File
@@ -7,10 +7,10 @@
"node": ">=10"
},
"scripts": {
"build": "ts-node --transpile-only src/app-store.ts",
"build": "ts-node --transpile-only src/build.ts",
"cli": "ts-node --transpile-only src/cli.tsx",
"watch": "ts-node --transpile-only src/app-store.ts --watch",
"generate": "ts-node --transpile-only src/app-store.ts",
"watch": "ts-node --transpile-only src/build.ts --watch",
"generate": "ts-node --transpile-only src/build.ts",
"post-install": "yarn build"
},
"files": [
-37
View File
@@ -1,37 +0,0 @@
## Steps to create an app
- Create a folder in packages/app-store/{APP_NAME} = {APP}
- Fill it with a sample app
- Modify {APP}/\_metadata.ts with the data provided
## Approach
- appType is derived from App Name(a slugify operation that makes a string that can be used as a director name, a variable name for imports and a URL path).
- appType is then used to create the app directory. It becomes `config.type` of config.json. config.type is the value used to create an entry in App table and retrieve any apps or credentials. It also becomes App.dirName
- dirnames that don't start with \_ are considered apps in packages/app-store and based on those apps .generated.ts\* files are created. This allows pre-cli apps to keep on working.
- app directory is populated with app-store/\_baseApp with newly updated config.json and package.json
- `packages/prisma/seed-app-store.config.json` is updated with new app.
NOTE: After app-store-cli is live, Credential.appId and Credential.type would be same for new apps. For old apps they would remain different. Credential.type would be used to identify credentials in integrations call and Credential.appId/App.slug would be used to identify apps.
If we rename all existing apps to their slug names, we can remove type and then use just appId to refer to things everywhere. This can be done later on.
## TODO
- Improvements
- Edit command Improvements
- Prefill fields in edit command -> It allows only that content to change which user wants to change.
- Don't override icon.svg
- Merge app-store:watch and app-store commands; introduce app-store --watch
- Allow inputs in non interactive way as well - That would allow easily copy pasting commands.
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Require assets path relative to app dir.
## Roadmap
- Avoid delete and edit on apps created outside of cli
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Improvements in shared code across app
- Use baseApp/api/add.ts for all apps with configuration of credentials creation and redirection URL.
- Delete creation side effects if App creation fails - Might make debugging difficult
- This is so that web app doesn't break because of additional app folders or faulty db-seed
+49
View File
@@ -0,0 +1,49 @@
import React, { FC } from "react";
import { SupportedCommands } from "src/types";
import Create from "./commandViews/Create";
import CreateTemplate from "./commandViews/Create";
import Delete from "./commandViews/Delete";
import DeleteTemplate from "./commandViews/DeleteTemplate";
import Edit from "./commandViews/Edit";
import EditTemplate from "./commandViews/EditTemplate";
export const App: FC<{
template: string;
command: SupportedCommands;
slug?: string;
}> = ({ command, template, slug }) => {
if (command === "create") {
return <Create template={template} />;
}
if (command === "edit") {
return <Edit slug={slug} />;
}
if (command === "edit-template") {
return <EditTemplate slug={slug} />;
}
if (command === "delete") {
if (!slug) {
throw new Error('Slug is required for "delete" command');
}
return <Delete slug={slug} />;
}
if (command === "create-template") {
return <CreateTemplate template={template} />;
}
if (command === "delete-template") {
if (!slug) {
throw new Error('Slug is required for "delete-template" command');
}
return <DeleteTemplate slug={slug} />;
}
return null;
};
export default App;
-463
View File
@@ -1,463 +0,0 @@
import fs from "fs";
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import path from "path";
import React, { FC, useEffect, useState } from "react";
import execSync from "./execSync";
const slugify = (str: string) => {
// It is to be a valid dir name, a valid JS variable name and a valid URL path
return str.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
};
function getSlugFromAppName(appName: string | null): string | null {
if (!appName) {
return appName;
}
return slugify(appName);
}
function getAppDirPath(slug: any) {
return path.join(appStoreDir, `${slug}`);
}
const appStoreDir = path.resolve(__dirname, "..", "..", "app-store");
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
function absolutePath(appRelativePath) {
return path.join(appStoreDir, appRelativePath);
}
const updatePackageJson = ({ slug, appDescription, appDirPath }) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const BaseAppFork = {
create: function* ({
category,
subCategory,
editMode = false,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
}) {
const appDirPath = getAppDirPath(slug);
let message = !editMode ? "Forking base app" : "Updating app";
yield message;
if (!editMode) {
execSync(`mkdir -p ${appDirPath}`);
execSync(`cp -r ${absolutePath("_baseApp/*")} ${appDirPath}`);
}
updatePackageJson({ slug, appDirPath, appDescription });
const categoryToVariantMap = {
video: "conferencing",
};
const dataFromCategory =
category === "video"
? {
appData: {
location: {
type: `integrations:${slug}_video`,
label: `${appName}`,
},
},
}
: {};
const dataFromSubCategory =
category === "video" && subCategory === "static"
? {
appData: {
...dataFromCategory.appData,
location: {
...dataFromCategory.appData.location,
linkType: "static",
organizerInputPlaceholder: "https://anything.anything",
urlRegExp: "",
},
},
}
: {};
let config = {
"/*": "Don't modify slug - If required, do it using cli edit command",
name: appName,
// Plan to remove it. DB already has it and name of dir is also the same.
slug: slug,
type: `${slug}_${category}`,
imageSrc: `/api/app-store/${slug}/icon.svg`,
logo: `/api/app-store/${slug}/icon.svg`,
url: `https://cal.com/apps/${slug}`,
variant: categoryToVariantMap[category] || category,
categories: [category],
publisher: publisherName,
email: publisherEmail,
description: appDescription,
extendsFeature: extendsFeature,
// TODO: Use this to avoid edit and delete on the apps created outside of cli
__createdUsingCli: true,
...dataFromCategory,
...dataFromSubCategory,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
fs.writeFileSync(
`${appDirPath}/DESCRIPTION.md`,
fs
.readFileSync(`${appDirPath}/DESCRIPTION.md`)
.toString()
.replace(/_DESCRIPTION_/g, appDescription)
.replace(/_APP_DIR_/g, slug)
);
message = !editMode ? "Forked base app" : "Updated app";
yield message;
},
delete: function ({ slug }) {
const appDirPath = getAppDirPath(slug);
execSync(`rm -rf ${appDirPath}`);
},
};
const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: function ({ slug, category, noDbUpdate }) {
let configContent = "[]";
try {
if (fs.statSync(this.seedConfigPath)) {
configContent = fs.readFileSync(this.seedConfigPath).toString();
}
} catch (e) {}
const seedConfig = JSON.parse(configContent);
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [category],
slug: slug,
type: `${slug}_${category}`,
});
}
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`cd ${workspaceDir} && yarn db-seed`);
}
},
revert: async function ({ slug, noDbUpdate }) {
let seedConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
}
},
};
const generateAppFiles = () => {
execSync(`cd ${__dirname} && yarn ts-node --transpile-only src/app-store.ts`);
};
const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => {
// AppName
// Type of App - Other, Calendar, Video, Payment, Messaging, Web3
const [appInputData, setAppInputData] = useState({});
const [inputIndex, setInputIndex] = useState(0);
const fields = [
{ label: "App Title", name: "appName", type: "text", explainer: "Keep it very short" },
{
label: "App Description",
name: "appDescription",
type: "text",
explainer:
"A detailed description of your app. You can later modify DESCRIPTION.md to add slider and other components",
},
{
label: "Category of App",
name: "appCategory",
type: "select",
options: [
{ label: "Calendar", value: "calendar" },
{
label:
"Static Link - Video(Apps like Ping.gg/Riverside/Whereby which require you to provide a link to join your room)",
value: "video_static",
},
{ label: "Other - Video", value: "video_other" },
{ label: "Payment", value: "payment" },
{ label: "Messaging", value: "messaging" },
{ label: "Web3", value: "web3" },
{ label: "Automation", value: "automation" },
{ label: "Analytics", value: "analytics" },
{ label: "Other", value: "other" },
],
explainer: "This is how apps are categorized in App Store.",
},
{
label: "What kind of app would you consider it?",
name: "extendsFeature",
options: [
{ label: "User", value: "User" },
{
label: "Event Type(Available for configuration in Apps tab for all Event Types)",
value: "EventType",
},
],
},
{ label: "Publisher Name", name: "publisherName", type: "text", explainer: "Let users know who you are" },
{
label: "Publisher Email",
name: "publisherEmail",
type: "text",
explainer: "Let users know how they can contact you.",
},
];
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
const fieldValue = appInputData[fieldName] || "";
const appName = appInputData["appName"];
const rawCategory = appInputData["appCategory"] || "";
const appDescription = appInputData["appDescription"];
const publisherName = appInputData["publisherName"];
const publisherEmail = appInputData["publisherEmail"];
let extendsFeature = appInputData["extendsFeature"] || [];
if (rawCategory === "analytics") {
// Analytics only means EventType Analytics as of now
extendsFeature = "EventType";
}
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
const allFieldsFilled = inputIndex === fields.length;
const [progressUpdate, setProgressUpdate] = useState("");
const category = rawCategory.split("_")[0];
const subCategory = rawCategory.split("_")[1];
if (!editMode) {
slug = getSlugFromAppName(appName);
}
useEffect(() => {
// When all fields have been filled
if (allFieldsFilled) {
const it = BaseAppFork.create({
category,
subCategory,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
});
for (const item of it) {
setProgressUpdate(item);
}
Seed.update({ slug, category, noDbUpdate });
generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setStatus("done");
}
});
if (!slug && editMode) {
return <Text>--slug is required</Text>;
}
if (allFieldsFilled) {
return (
<Box flexDirection="column">
<Text>
{editMode
? `Editing app with slug ${slug}`
: `Creating app with name '${appName}' of type '${category}'`}
</Text>
<Text>{progressUpdate}</Text>
{status === "done" ? (
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
<Text bold italic>
Just wait for a few seconds for process to exit and then you are good to go. Your App code
exists at ${getAppDirPath(slug)}
Tip : Go and change the logo of your app by replacing {getAppDirPath(slug) + "/static/icon.svg"}
</Text>
<Text bold italic>
App Summary:
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text color="green">Slug: </Text>
<Text>{slug}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">App URL: </Text>
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Name: </Text>
<Text>{appName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Description: </Text>
<Text>{appDescription}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Category: </Text>
<Text>{category}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Name: </Text>
<Text>{publisherName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Email: </Text>
<Text>{publisherEmail}</Text>
</Box>
</Box>
</Box>
) : (
<Text>Please wait...</Text>
)}
<Text italic color="gray">
Note: You should not rename app directory manually. Use cli only to do that as it needs to be
updated in DB as well
</Text>
</Box>
);
}
// Hack: using field.name == "appTitle" to identify that app Name has been submitted and not being edited.
if (!editMode && field.name === "appTitle" && slug && fs.existsSync(getAppDirPath(slug))) {
return (
<>
<Text>App with slug {slug} already exists. If you want to edit it, use edit command</Text>
</>
);
}
return (
<Box flexDirection="column">
<Box flexDirection="column">
<Box>
<Text color="green">{`${fieldLabel}:`}</Text>
{field.type == "text" ? (
<TextInput
value={fieldValue}
onSubmit={(value) => {
if (!value) {
return;
}
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field.options}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}
/>
)}
</Box>
<Box>
<Text color="gray" italic>
{field.explainer}
</Text>
</Box>
</Box>
</Box>
);
};
const DeleteApp = ({ noDbUpdate, slug }) => {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [allowDeletion, setAllowDeletion] = useState(false);
const [state, setState] = useState({ done: null, description: null });
useEffect(() => {
if (allowDeletion) {
BaseAppFork.delete({ slug });
Seed.revert({ slug });
generateAppFiles();
setState({ description: `App with slug ${slug} has been deleted`, done: true });
}
}, [allowDeletion, slug]);
return (
<>
<Text>
Confirm the slug of the app that you want to delete. Note, that it would cleanup the app directory,
App table and Credential table
</Text>
{!state.done && (
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState({ description: `Deletion started`, done: true });
setAllowDeletion(true);
} else {
setState({ description: `Slug doesn't match - Should have been ${slug}`, done: true });
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}
/>
)}
<Text>{state.description}</Text>
</>
);
};
const App: FC<{ noDbUpdate?: boolean; command: "create" | "delete"; slug?: string }> = ({
command,
noDbUpdate,
slug,
}) => {
if (command === "create") {
return <CreateApp noDbUpdate={noDbUpdate} />;
}
if (command === "delete") {
return <DeleteApp slug={slug} noDbUpdate={noDbUpdate} />;
}
if (command === "edit") {
return <CreateApp slug={slug} editMode={true} noDbUpdate={noDbUpdate} />;
}
};
module.exports = App;
export default App;
-278
View File
@@ -1,278 +0,0 @@
import chokidar from "chokidar";
import fs from "fs";
import { debounce } from "lodash";
import path from "path";
import prettier from "prettier";
import { AppMeta } from "@calcom/types/App";
import prettierConfig from "../../config/prettier-preset";
import execSync from "./execSync";
function isFileThere(path) {
try {
fs.statSync(path);
return true;
} catch (e) {
return false;
}
}
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const formatOutput = (source: string) => prettier.format(source, prettierConfig);
const getVariableName = function (appName: string) {
return appName.replace("-", "_");
};
const getAppId = function (app: { name: string }) {
// Handle stripe separately as it's an old app with different dirName than slug/appId
return app.name === "stripepayment" ? "stripe" : app.name;
};
const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
type App = Partial<AppMeta> & {
name: string;
path: string;
};
function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (
!candidatePath.startsWith("_") &&
candidatePath !== "ee" &&
!candidatePath.includes("/") &&
!candidatePath.includes("\\")
) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
}
// Get dirname of app from full path
const dirName = path.relative(APP_STORE_PATH, candidatePath);
return isValidAppName(dirName) ? dirName : null;
}
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (dir === "ee") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (eeDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, eeDir)).isDirectory()) {
if (!getAppName(path.resolve(eeDir))) {
appDirs.push({
name: eeDir,
path: path.join(dir, eeDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
}
});
function forEachAppDir(callback: (arg: App) => void) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else {
app = {};
}
callback({
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
}
}
forEachAppDir((app) => {
const templateDestinationDir = path.join(APP_STORE_PATH, app.path, "extensions");
const templateDestinationFilePath = path.join(templateDestinationDir, "EventTypeAppCard.tsx");
const zodDestinationFilePath = path.join(APP_STORE_PATH, app.path, "zod.ts");
if (app.extendsFeature === "EventType" && !isFileThere(templateDestinationFilePath)) {
execSync(`mkdir -p ${templateDestinationDir}`);
execSync(`cp ../app-store/_templates/extensions/EventTypeAppCard.tsx ${templateDestinationFilePath}`);
execSync(`cp ../app-store/_templates/zod.ts ${zodDestinationFilePath}`);
}
});
function getObjectExporter(
objectName,
{
fileToBeImported,
importBuilder,
entryBuilder,
}: {
fileToBeImported: string;
importBuilder?: (arg: App) => string;
entryBuilder: (arg: App) => string;
}
) {
const output = [];
forEachAppDir((app) => {
if (
fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported)) &&
typeof importBuilder === "function"
) {
output.push(importBuilder(app));
}
});
output.push(`export const ${objectName} = {`);
forEachAppDir((app) => {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported))) {
output.push(entryBuilder(app));
}
});
output.push(`};`);
return output;
}
serverOutput.push(
...getObjectExporter("apiHandlers", {
fileToBeImported: "api/index.ts",
// Import path must have / even for windows and not \
entryBuilder: (app) => ` "${app.name}": import("./${app.path.replace(/\\/g, "/")}/api"),`,
})
);
metadataOutput.push(
...getObjectExporter("appStoreMetadata", {
fileToBeImported: "_metadata.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { metadata as ${getVariableName(app.name)}_meta } from "./${app.path.replace(
/\\/g,
"/"
)}/_metadata";`,
entryBuilder: (app) => ` "${app.name}":${getVariableName(app.name)}_meta,`,
})
);
schemasOutput.push(
...getObjectExporter("appDataSchemas", {
fileToBeImported: "zod.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { appDataSchema as ${getVariableName(app.name)}_schema } from "./${app.path.replace(
/\\/g,
"/"
)}/zod";`,
// Key must be appId as this is used by eventType metadata and lookup is by appId
entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_schema ,`,
})
);
appKeysSchemasOutput.push(
...getObjectExporter("appKeysSchemas", {
fileToBeImported: "zod.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { appKeysSchema as ${getVariableName(app.name)}_keys_schema } from "./${app.path.replace(
/\\/g,
"/"
)}/zod";`,
// Key must be appId as this is used by eventType metadata and lookup is by appId
entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_keys_schema ,`,
})
);
browserOutput.push(
...getObjectExporter("InstallAppButtonMap", {
fileToBeImported: "components/InstallAppButton.tsx",
entryBuilder: (app) =>
` ${app.name}: dynamic(() =>import("./${app.path}/components/InstallAppButton")),`,
})
);
// TODO: Make a component map creator that accepts ComponentName and does the rest.
// TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
browserOutput.push(
...getObjectExporter("AppSettingsComponentsMap", {
fileToBeImported: "components/AppSettings.tsx",
entryBuilder: (app) => ` ${app.name}: dynamic(() =>import("./${app.path}/components/AppSettings")),`,
})
);
browserOutput.push(
...getObjectExporter("EventTypeAddonMap", {
fileToBeImported: path.join("extensions", "EventTypeAppCard.tsx"),
entryBuilder: (app) =>
` ${app.name}: dynamic(() =>import("./${app.path}/extensions/EventTypeAppCard")),`,
})
);
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
Don't modify this file manually.
**/
`;
const filesToGenerate: [string, string[]][] = [
["apps.metadata.generated.ts", metadataOutput],
["apps.server.generated.ts", serverOutput],
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
});
console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`);
}
const debouncedGenerateFiles = debounce(generateFiles);
if (isInWatchMode) {
chokidar
.watch(APP_STORE_PATH)
.on("addDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Added ${appName}`);
debouncedGenerateFiles();
}
})
.on("change", (filePath) => {
if (filePath.endsWith("config.json")) {
console.log("Config file changed");
debouncedGenerateFiles();
}
})
.on("unlinkDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Removed ${appName}`);
debouncedGenerateFiles();
}
});
} else {
generateFiles();
}
+348
View File
@@ -0,0 +1,348 @@
import chokidar from "chokidar";
import fs from "fs";
import { debounce } from "lodash";
import path from "path";
import prettier from "prettier";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import prettierConfig from "@calcom/config/prettier-preset";
import { AppMeta } from "@calcom/types/App";
import { APP_STORE_PATH } from "./constants";
import { getAppName } from "./utils/getAppName";
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const formatOutput = (source: string) =>
prettier.format(source, {
parser: "babel",
...prettierConfig,
});
const getVariableName = function (appName: string) {
return appName.replace(/[-.]/g, "_");
};
const getAppId = function (app: { name: string }) {
// Handle stripe separately as it's an old app with different dirName than slug/appId
return app.name === "stripepayment" ? "stripe" : app.name;
};
type App = Partial<AppMeta> & {
name: string;
path: string;
};
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (dir === "ee" || dir === "templates") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (subDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, subDir)).isDirectory()) {
if (getAppName(subDir)) {
appDirs.push({
name: subDir,
path: path.join(dir, subDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
}
});
function forEachAppDir(callback: (arg: App) => void) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else {
app = {};
}
callback({
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
}
}
/**
* Windows has paths with backslashes, so we need to replace them with forward slashes
* .ts and .tsx files are imported without extensions
* If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part
*/
function getModulePath(path: string, moduleName: string) {
return (
`./${path.replace(/\\/g, "/")}/` +
moduleName.replace(/\/index\.ts|\/index\.tsx/, "").replace(/\.tsx$|\.ts$/, "")
);
}
type ImportConfig =
| {
fileToBeImported: string;
importName?: string;
}
| [
{
fileToBeImported: string;
importName?: string;
},
{
fileToBeImported: string;
importName: string;
}
];
/**
* If importConfig is an array, only 2 items are allowed. First one is the main one and second one is the fallback
*/
function getExportedObject(
objectName: string,
{
lazyImport = false,
importConfig,
entryObjectKeyGetter = (app) => app.name,
}: {
lazyImport?: boolean;
importConfig: ImportConfig;
entryObjectKeyGetter?: (arg: App, importName?: string) => string;
}
) {
const output: string[] = [];
const getLocalImportName = (
app: { name: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`;
const fileToBeImportedExists = (
app: { path: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => fs.existsSync(path.join(APP_STORE_PATH, app.path, chosenConfig.fileToBeImported));
addImportStatements();
createExportObject();
return output;
function addImportStatements() {
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) {
const importName = chosenConfig.importName;
if (!lazyImport) {
if (importName !== "default") {
// Import with local alias that will be used by createExportObject
output.push(
`import { ${importName} as ${getLocalImportName(app, chosenConfig)} } from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
} else {
// Default Import
output.push(
`import ${getLocalImportName(app, chosenConfig)} from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
}
}
}
});
}
function createExportObject() {
output.push(`export const ${objectName} = {`);
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig)) {
if (!lazyImport) {
const key = entryObjectKeyGetter(app);
output.push(`"${key}": ${getLocalImportName(app, chosenConfig)},`);
} else {
const key = entryObjectKeyGetter(app);
if (chosenConfig.fileToBeImported.endsWith(".tsx")) {
output.push(
`"${key}": dynamic(() => import("${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}")),`
);
} else {
output.push(`"${key}": import("${getModulePath(app.path, chosenConfig.fileToBeImported)}"),`);
}
}
}
});
output.push(`};`);
}
function getChosenImportConfig(importConfig: ImportConfig, app: { path: string }) {
let chosenConfig;
if (!(importConfig instanceof Array)) {
chosenConfig = importConfig;
} else {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, importConfig[0].fileToBeImported))) {
chosenConfig = importConfig[0];
} else {
chosenConfig = importConfig[1];
}
}
return chosenConfig;
}
}
serverOutput.push(
...getExportedObject("apiHandlers", {
importConfig: {
fileToBeImported: "api/index.ts",
},
lazyImport: true,
})
);
metadataOutput.push(
...getExportedObject("appStoreMetadata", {
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
})
);
schemasOutput.push(
...getExportedObject("appDataSchemas", {
// Import path must have / even for windows and not \
importConfig: {
fileToBeImported: "zod.ts",
importName: "appDataSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
appKeysSchemasOutput.push(
...getExportedObject("appKeysSchemas", {
importConfig: {
fileToBeImported: "zod.ts",
importName: "appKeysSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
browserOutput.push(
...getExportedObject("InstallAppButtonMap", {
importConfig: {
fileToBeImported: "components/InstallAppButton.tsx",
},
lazyImport: true,
})
);
// TODO: Make a component map creator that accepts ComponentName and does the rest.
// TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
browserOutput.push(
...getExportedObject("AppSettingsComponentsMap", {
importConfig: {
fileToBeImported: "components/AppSettingsInterface.tsx",
},
lazyImport: true,
})
);
browserOutput.push(
...getExportedObject("EventTypeAddonMap", {
importConfig: {
fileToBeImported: "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();
}
+38 -14
View File
@@ -3,23 +3,35 @@ import { render } from "ink";
import meow from "meow";
import React from "react";
import App from "./CliApp";
import App from "./App";
import { SupportedCommands } from "./types";
const cli = meow(
`
Usage
$ app-store create/delete/edit - Edit and Delete commands must be used on apps created using cli
$ 'app-store create' or 'app-store create-template' - Creates a new app or template
Options
[--template -t] Template to use.
Options
[--slug] Slug. This is the name of app dir for apps created with cli.
$ 'app-store edit' or 'app-store edit-template' - Edit the App or Template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
$ 'app-store delete' or 'app-store delete-template' - Deletes the app or template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
`,
{
flags: {
noDbUpdate: {
type: "boolean",
},
slug: {
type: "string",
alias: "s",
},
template: {
type: "string",
alias: "t",
},
},
allowUnknownFlags: false,
@@ -30,20 +42,32 @@ if (cli.input.length !== 1) {
cli.showHelp();
}
const command = cli.input[0] as "create" | "delete" | "edit";
const supportedCommands = ["create", "delete", "edit"];
const command = cli.input[0] as SupportedCommands;
const supportedCommands = [
"create",
"delete",
"edit",
"create-template",
"delete-template",
"edit-template",
] as const;
if (!supportedCommands.includes(command)) {
cli.showHelp();
}
let slug;
let slug = null;
if (command === "delete" || command === "edit") {
if (
command === "delete" ||
command === "edit" ||
command === "delete-template" ||
command === "edit-template"
) {
slug = cli.flags.slug;
if (!slug) {
console.log("--slug is required");
cli.showHelp();
cli.showHelp(0);
}
}
render(<App slug={slug} command={command} noDbUpdate={cli.flags.noDbUpdate} />);
render(<App slug={slug} template={cli.flags.template || ""} command={command} />);
@@ -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