2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import classNames from "classnames";
import { HeadSeo, Logo } from "@calcom/ui";
import Loader from "@components/Loader";
interface Props {
title: string;
description: string;
footerText?: React.ReactNode | string;
showLogo?: boolean;
heading?: string;
loading?: boolean;
}
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
return (
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
<HeadSeo title={props.title} description={props.description} />
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
{props.heading && <h2 className="font-cal text-emphasis text-center text-3xl">{props.heading}</h2>}
</div>
{props.loading && (
<div className="bg-muted absolute z-50 flex h-screen w-full items-center">
<Loader />
</div>
)}
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
{props.children}
</div>
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Icon } from "@calcom/ui";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: React.ComponentProps<typeof Icon>["name"];
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
type="button"
{...props}
className="text-md hover:bg-emphasis hover:text-emphasis text-default flex items-center rounded-sm px-2 py-1 text-sm font-medium">
<Icon name={props.Icon} className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
{props.children}
</button>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import classNames from "classnames";
import type { PropsWithChildren } from "react";
import React from "react";
import { Dialog, DialogContent } from "@calcom/ui";
export default function ModalContainer(
props: PropsWithChildren<{
wide?: boolean;
scroll?: boolean;
noPadding?: boolean;
isOpen: boolean;
onExit: () => void;
}>
) {
return (
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<DialogContent>
<div
className={classNames(
"bg-default inline-block w-full transform text-left align-bottom transition-all sm:align-middle",
{
"sm:w-full sm:max-w-lg ": !props.wide,
"sm:w-4xl sm:max-w-4xl": props.wide,
"overflow-auto": props.scroll,
"!p-0": props.noPadding,
}
)}>
{props.children}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,25 @@
export default function SettingInputContainer({
Input,
Icon,
label,
htmlFor,
}: {
Input: React.ReactNode;
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element | null;
label: string;
htmlFor?: string;
}) {
return (
<div className="space-y-3">
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor={htmlFor} className="text-default mt-1 flex text-sm font-medium">
<Icon className="text-subtle mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" />
{label}
</label>
</div>
<div className="w-full flex-grow">{Input}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,332 @@
import classNames from "classnames";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useSession } from "next-auth/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { RefCallback } from "react";
import { useEffect, useState } from "react";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Input, Label } from "@calcom/ui";
import { Icon } from "@calcom/ui";
export enum UsernameChangeStatusEnum {
UPGRADE = "UPGRADE",
}
interface ICustomUsernameProps {
currentUsername: string | undefined;
setCurrentUsername?: (newUsername: string) => void;
inputUsernameValue: string | undefined;
usernameRef: RefCallback<HTMLInputElement>;
setInputUsernameValue: (value: string) => void;
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
readonly?: boolean;
}
const obtainNewUsernameChangeCondition = ({
userIsPremium,
isNewUsernamePremium,
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
}) => {
if (!userIsPremium && isNewUsernamePremium) {
return UsernameChangeStatusEnum.UPGRADE;
}
};
const PremiumTextfield = (props: ICustomUsernameProps) => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const { t } = useLocale();
const { update } = useSession();
const {
currentUsername,
setCurrentUsername = noop,
inputUsernameValue,
setInputUsernameValue,
usernameRef,
onSuccessMutation,
onErrorMutation,
readonly: disabled,
} = props;
const [user] = trpc.viewer.me.useSuspenseQuery();
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
const isCurrentUsernamePremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
// debounce the username input, set the delay to 600ms to be consistent with signup form
const debouncedUsername = useDebounce(inputUsernameValue, 600);
useEffect(() => {
// Use the current username or if it's not set, use the one available from stripe
setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
useEffect(() => {
async function checkUsername(username: string | undefined) {
if (!username) {
setUsernameIsAvailable(false);
setMarkAsError(false);
setIsInputUsernamePremium(false);
return;
}
const { data } = await fetchUsername(username, null);
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available);
}
checkUsername(debouncedUsername);
}, [debouncedUsername, currentUsername]);
const updateUsername = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
await update({ username: inputUsernameValue });
setOpenDialogSaveUsername(false);
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
},
});
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
const paymentRequired = !currentUsername && stripeCustomer?.isPremium;
const usernameChangeCondition = obtainNewUsernameChangeCondition({
userIsPremium: isCurrentUsernamePremium,
isNewUsernamePremium: isInputUsernamePremium,
stripeCustomer,
});
const usernameFromStripe = stripeCustomer?.username;
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
inputUsernameValue || usernameFromStripe
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${pathname}`;
const ActionButtons = () => {
if (paymentRequired) {
return (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
href={paymentLink}
data-testid="reserve-username-btn">
{t("Reserve")}
</Button>
</div>
);
}
if ((usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue) {
return (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid="update-username-btn">
{t("update")}
</Button>
<Button
type="button"
color="secondary"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
}
}}>
{t("cancel")}
</Button>
</div>
);
}
return <></>;
};
const saveUsername = () => {
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
updateUsername.mutate({
username: inputUsernameValue,
});
setCurrentUsername(inputUsernameValue);
}
};
let paymentMsg = !currentUsername ? (
<span className="text-xs text-orange-400">
You need to reserve your premium username for {getPremiumPlanPriceValue()}
</span>
) : null;
if (recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid") {
paymentMsg = (
<span className="text-sm text-red-500">
Your payment could not be completed. Your username is still not reserved
</span>
);
}
return (
<div>
<div className="flex justify-items-center">
<Label htmlFor="username">{t("username")}</Label>
</div>
<div className="flex rounded-md">
<span
className={classNames(
isInputUsernamePremium ? "border border-orange-400 " : "",
"border-default bg-muted text-subtle hidden h-9 items-center rounded-l-md border border-r-0 px-3 text-sm md:inline-flex"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
</span>
<div className="relative w-full">
<Input
ref={usernameRef}
name="username"
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
disabled={disabled}
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 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-default",
disabled ? "bg-subtle text-muted focus:border-0" : ""
)}
value={inputUsernameValue}
onChange={(event) => {
event.preventDefault();
// Reset payment status
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.delete("paymentStatus");
if (searchParams?.toString() !== _searchParams.toString()) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
setInputUsernameValue(event.target.value);
}}
data-testid="username-input"
/>
<div className="absolute right-2 top-0 flex flex-row">
<span
className={classNames(
"mx-2 py-2",
isInputUsernamePremium ? "text-transparent" : "",
usernameIsAvailable ? "" : ""
)}>
{isInputUsernamePremium ? (
<Icon name="star" className="mt-[2px] h-4 w-4 fill-orange-400" />
) : (
<></>
)}
{!isInputUsernamePremium && usernameIsAvailable ? (
<Icon name="check" className="mt-[2px] h-4 w-4" />
) : (
<></>
)}
</span>
</div>
</div>
{(usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue && (
<div className="flex justify-end">
<ActionButtons />
</div>
)}
</div>
{paymentMsg}
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
<Dialog open={openDialogSaveUsername}>
<DialogContent
Icon="pencil"
title={t("confirm_username_change_dialog_title")}
description={
<>
{usernameChangeCondition && usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
<p className="text-default mb-4 text-sm">{t("change_username_standard_to_premium")}</p>
)}
</>
}>
<div className="flex flex-row">
<div className="mb-4 w-full px-4 pt-1">
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
<div className="flex-1 px-2">
<p className="text-subtle">{t("current_username")}</p>
<p className="text-emphasis mt-1" data-testid="current-username">
{currentUsername}
</p>
</div>
<div className="ml-6 flex-1">
<p className="text-subtle" data-testid="new-username">
{t("new_username")}
</p>
<p className="text-emphasis">{inputUsernameValue}</p>
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
{/* redirect to checkout */}
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
<Button
type="button"
loading={updateUsername.isPending}
data-testid="go-to-billing"
href={paymentLink}>
<>
{t("go_to_stripe_billing")} <Icon name="external-link" className="ml-1 h-4 w-4" />
</>
</Button>
)}
{/* Normal save */}
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
<Button
type="button"
loading={updateUsername.isPending}
data-testid="save-username"
onClick={() => {
saveUsername();
}}>
{t("save")}
</Button>
)}
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
{t("cancel")}
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export { PremiumTextfield };

View File

@@ -0,0 +1,206 @@
import classNames from "classnames";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useSession } from "next-auth/react";
import type { RefCallback } from "react";
import { useEffect, useState } from "react";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Dialog, DialogClose, DialogContent, TextField, DialogFooter, Tooltip } from "@calcom/ui";
import { Icon } from "@calcom/ui";
interface ICustomUsernameProps {
currentUsername: string | undefined;
setCurrentUsername?: (newUsername: string) => void;
inputUsernameValue: string | undefined;
usernameRef: RefCallback<HTMLInputElement>;
setInputUsernameValue: (value: string) => void;
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
}
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
const { t } = useLocale();
const { update } = useSession();
const {
currentUsername,
setCurrentUsername = noop,
inputUsernameValue,
setInputUsernameValue,
usernameRef,
onSuccessMutation,
onErrorMutation,
...rest
} = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
// debounce the username input, set the delay to 600ms to be consistent with signup form
const debouncedUsername = useDebounce(inputUsernameValue, 600);
useEffect(() => {
async function checkUsername(username: string | undefined) {
if (!username) {
setUsernameIsAvailable(false);
setMarkAsError(false);
return;
}
if (currentUsername !== username) {
const { data } = await fetchUsername(username, null);
setMarkAsError(!data.available);
setUsernameIsAvailable(data.available);
} else {
setUsernameIsAvailable(false);
}
}
checkUsername(debouncedUsername);
}, [debouncedUsername, currentUsername]);
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
setOpenDialogSaveUsername(false);
setCurrentUsername(inputUsernameValue);
await update({ username: inputUsernameValue });
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
},
});
const ActionButtons = () => {
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
<div className="relative bottom-[6px] me-2 ms-2 flex flex-row space-x-2">
<Button
type="button"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid="update-username-btn">
{t("update")}
</Button>
<Button
type="button"
color="minimal"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
}
}}>
{t("cancel")}
</Button>
</div>
) : (
<></>
);
};
const updateUsername = async () => {
updateUsernameMutation.mutate({
username: inputUsernameValue,
});
};
return (
<div>
<div className="flex rounded-md">
<div className="relative w-full">
<TextField
ref={usernameRef}
name="username"
value={inputUsernameValue}
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
)}
onChange={(event) => {
event.preventDefault();
setInputUsernameValue(event.target.value);
}}
data-testid="username-input"
{...rest}
/>
{currentUsername !== inputUsernameValue && (
<div className="absolute right-[2px] top-6 flex h-7 flex-row">
<span className={classNames("bg-default mx-0 p-3")}>
{usernameIsAvailable ? (
<Icon name="check" className="relative bottom-[6px] h-4 w-4" />
) : (
<></>
)}
</span>
</div>
)}
</div>
<div className="mt-7 hidden md:inline">
<ActionButtons />
</div>
</div>
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end md:hidden">
<ActionButtons />
</div>
)}
<Dialog open={openDialogSaveUsername}>
<DialogContent type="confirmation" Icon="pencil" title={t("confirm_username_change_dialog_title")}>
<div className="flex flex-row">
<div className="mb-4 w-full pt-1">
<div className="bg-subtle flex w-full flex-wrap justify-between gap-6 rounded-sm px-4 py-3 text-sm">
<div>
<p className="text-subtle">{t("current_username")}</p>
<Tooltip content={currentUsername}>
<p
className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis"
data-testid="current-username">
{currentUsername}
</p>
</Tooltip>
</div>
<div>
<p className="text-subtle" data-testid="new-username">
{t("new_username")}
</p>
<Tooltip content={inputUsernameValue}>
<p className="text-emphasis mt-1 max-w-md overflow-hidden text-ellipsis">
{inputUsernameValue}
</p>
</Tooltip>
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button
type="button"
loading={updateUsernameMutation.isPending}
data-testid="save-username"
onClick={updateUsername}>
{t("save")}
</Button>
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
{t("cancel")}
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export { UsernameTextfield };

View File

@@ -0,0 +1,71 @@
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import useRouterQuery from "@lib/hooks/useRouterQuery";
interface UsernameAvailabilityFieldProps {
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
}
export const getUsernameAvailabilityComponent = (isPremium: boolean) => {
if (isPremium)
return dynamic(() => import("./PremiumTextfield").then((m) => m.PremiumTextfield), { ssr: false });
return dynamic(() => import("./UsernameTextfield").then((m) => m.UsernameTextfield), { ssr: false });
};
export const UsernameAvailabilityField = ({
onSuccessMutation,
onErrorMutation,
}: UsernameAvailabilityFieldProps) => {
const searchParams = useSearchParams();
const [user] = trpc.viewer.me.useSuspenseQuery();
const [currentUsernameState, setCurrentUsernameState] = useState(user.username || "");
const { username: usernameFromQuery, setQuery: setUsernameFromQuery } = useRouterQuery("username");
const { username: currentUsername, setQuery: setCurrentUsername } =
searchParams?.get("username") && user.username === null
? { username: usernameFromQuery, setQuery: setUsernameFromQuery }
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
const formMethods = useForm({
defaultValues: {
username: currentUsername,
},
});
const UsernameAvailability = getUsernameAvailabilityComponent(!IS_SELF_HOSTED && !user.organization?.id);
const orgBranding = useOrgBranding();
const usernamePrefix = orgBranding
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
return (
<Controller
control={formMethods.control}
name="username"
render={({ field: { ref, onChange, value } }) => {
return (
<UsernameAvailability
currentUsername={currentUsername}
setCurrentUsername={setCurrentUsername}
inputUsernameValue={value}
usernameRef={ref}
setInputUsernameValue={onChange}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
disabled={!!user.organization?.id}
addOnLeading={`${usernamePrefix}/`}
/>
);
}}
/>
);
};

View File

@@ -0,0 +1,67 @@
import type { InputHTMLAttributes } from "react";
import React, { forwardRef } from "react";
import classNames from "@calcom/lib/classNames";
import { InfoBadge } from "@calcom/ui";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: React.ReactNode;
description: string;
descriptionAsLabel?: boolean;
informationIconText?: string;
};
const CheckboxField = forwardRef<HTMLInputElement, Props>(
({ label, description, informationIconText, ...rest }, ref) => {
const descriptionAsLabel = !label || rest.descriptionAsLabel;
return (
<div className="block items-center sm:flex">
{label && (
<div className="min-w-48 mb-4 sm:mb-0">
{React.createElement(
descriptionAsLabel ? "div" : "label",
{
className: "flex text-sm font-medium text-default",
...(!descriptionAsLabel
? {
htmlFor: rest.id,
}
: {}),
},
label
)}
</div>
)}
<div className="w-full">
<div className="relative flex items-start">
{React.createElement(
descriptionAsLabel ? "label" : "div",
{
className: classNames(
"relative flex items-start",
descriptionAsLabel ? "text-default" : "text-emphasis"
),
},
<>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded"
/>
</div>
<span className="ms-2 text-sm">{description}</span>
</>
)}
{informationIconText && <InfoBadge content={informationIconText} />}
</div>
</div>
</div>
);
}
);
CheckboxField.displayName = "CheckboxField";
export default CheckboxField;

View File

@@ -0,0 +1,57 @@
import React from "react";
import type { Props } from "react-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import Select from "@components/ui/form/Select";
type CheckedSelectOption = {
avatar: string;
label: string;
value: string;
disabled?: boolean;
};
export const CheckedSelect = ({
options = [],
value = [],
...props
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
value?: readonly CheckedSelectOption[];
onChange: (value: readonly CheckedSelectOption[]) => void;
}) => {
const { t } = useLocale();
return (
<>
<Select
name={props.name}
placeholder={props.placeholder || t("select")}
isSearchable={false}
options={options}
value={value}
isMulti
{...props}
/>
{value.map((option) => (
<div key={option.value} className="border p-2 font-medium">
<Avatar
className="inline ltr:mr-2 rtl:ml-2"
size="sm"
imageSrc={option.avatar}
alt={option.label}
/>
{option.label}
<Icon
name="x"
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className="text-subtle float-right mt-0.5 h-5 w-5 cursor-pointer"
/>
</div>
))}
</>
);
};
export default CheckedSelect;

View File

@@ -0,0 +1,33 @@
import React from "react";
import "react-calendar/dist/Calendar.css";
import "react-date-picker/dist/DatePicker.css";
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
import { Icon } from "@calcom/ui";
import classNames from "@lib/classNames";
type Props = {
date: Date;
onDatesChange?: ((date: Date) => void) | undefined;
className?: string;
disabled?: boolean;
minDate?: Date;
};
export const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
return (
<PrimitiveDatePicker
className={classNames(
"focus:ring-primary-500 focus:border-primary-500 border-default rounded-sm border p-1 pl-2 text-sm",
className
)}
clearIcon={null}
calendarIcon={<Icon name="calendar" className="text-subtle h-5 w-5" />}
value={date}
minDate={minDate}
disabled={disabled}
onChange={onDatesChange}
/>
);
};

View File

@@ -0,0 +1,72 @@
import type { GroupBase, Props, SingleValue } from "react-select";
import { components } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import { classNames } from "@calcom/lib";
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
import { Select } from "@calcom/ui";
export type LocationOption = {
label: string;
value: EventLocationType["type"];
icon?: string;
disabled?: boolean;
address?: string;
credentialId?: number;
teamName?: string;
};
export type SingleValueLocationOption = SingleValue<LocationOption>;
export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
return (
<div className="flex items-center gap-3">
{icon && <img src={icon} alt="cover" className={classNames("h-3.5 w-3.5", invertLogoOnDark(icon))} />}
<span className={classNames("text-sm font-medium")}>{label}</span>
</div>
);
};
export default function LocationSelect(props: Props<LocationOption, false, GroupOptionType>) {
return (
<Select<LocationOption>
name="location"
id="location-select"
data-testid="location-select"
components={{
Option: (props) => {
return (
<components.Option {...props}>
<div data-testid={`location-select-item-${props.data.value}`}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</div>
</components.Option>
);
},
SingleValue: (props) => (
<components.SingleValue {...props}>
<div data-testid={`location-select-item-${props.data.value}`}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</div>
</components.SingleValue>
),
}}
formatOptionLabel={(e) => (
<div className="flex items-center gap-3">
{e.icon && (
<img
src={e.icon}
alt="app-icon"
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
/>
)}
<span>{e.label}</span>
</div>
)}
formatGroupLabel={(e) => <p className="text-default text-xs font-medium">{e.label}</p>}
{...props}
/>
);
}

View File

@@ -0,0 +1,43 @@
import classNames from "classnames";
import type { InputHTMLAttributes, ReactNode } from "react";
import React, { forwardRef } from "react";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: ReactNode;
};
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
return (
<div className="block sm:flex">
{!!label && (
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor={rest.id} className="text-default flex h-full items-center text-sm font-medium">
{label}
</label>
</div>
)}
<div className="w-full">
<div className="relative rounded-sm">
<input
{...rest}
ref={ref}
type="number"
className={classNames(
"border-default block w-full rounded-sm pl-2 pr-12 text-sm",
rest.className
)}
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-subtle text-sm" id="duration">
mins
</span>
</div>
</div>
</div>
</div>
);
});
MinutesField.displayName = "MinutesField";
export default MinutesField;

View File

@@ -0,0 +1,192 @@
import React, { useCallback, useEffect, useState } from "react";
import type { GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
import ReactSelect, { components } from "react-select";
import classNames from "@calcom/lib/classNames";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
export type SelectProps<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = Props<Option, IsMulti, Group>;
export const InputComponent = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>({
inputClassName,
...props
}: InputProps<Option, IsMulti, Group>) => {
return (
<components.Input
// disables our default form focus hightlight on the react-select input element
inputClassName={classNames("focus:ring-0 focus:ring-offset-0", inputClassName)}
{...props}
/>
);
};
function Select<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
const [mounted, setMounted] = useState<boolean>(false);
const { resolvedTheme, forcedTheme } = useGetTheme();
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
const darkThemeColors = {
/** Dark Theme starts */
//primary - Border when selected and Selected Option background
primary: "rgb(41 41 41 / var(--tw-border-opacity))",
neutral0: "rgb(62 62 62 / var(--tw-bg-opacity))",
// Down Arrow hover color
neutral5: "white",
neutral10: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral20 - border color + down arrow default color
neutral20: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral30 - hover border color
neutral30: "rgb(41 41 41 / var(--tw-border-opacity))",
neutral40: "white",
danger: "white",
// Cross button in multiselect
dangerLight: "rgb(41 41 41 / var(--tw-border-opacity))",
// neutral50 - MultiSelect - "Select Text" color
neutral50: "white",
// neutral60 - Down Arrow color
neutral60: "white",
neutral70: "red",
// neutral80 - Selected option
neutral80: "white",
neutral90: "blue",
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
/** Dark Theme ends */
};
useEffect(() => {
setMounted(true);
}, []);
// Till we know in JS the theme is ready, we can't render react-select as it would render with light theme instead
if (!mounted) {
return <input type="text" className={className} />;
}
return (
<ReactSelect
theme={(theme) => ({
...theme,
borderRadius: 6,
colors: {
...theme.colors,
...(hasDarkTheme
? darkThemeColors
: {
/** Light Theme starts */
primary: "var(--brand-color)",
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
/** Light Theme Ends */
}),
},
})}
styles={{
option: (provided, state) => ({
...provided,
color: state.isSelected ? "var(--brand-text-color)" : "black",
":active": {
backgroundColor: state.isSelected ? "" : "var(--brand-color)",
color: "var(--brand-text-color)",
},
}),
}}
components={{
...components,
IndicatorSeparator: () => null,
Input: InputComponent,
}}
className={classNames("border-0 text-sm", className)}
{...props}
/>
);
}
export function SelectWithValidation<
Option extends { label: string; value: string },
isMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
required = false,
onChange,
value,
...remainingProps
}: SelectProps<Option, isMulti, Group> & { required?: boolean }) {
const [hiddenInputValue, _setHiddenInputValue] = useState(() => {
if (value instanceof Array || !value) {
return;
}
return value.value || "";
});
const setHiddenInputValue = useCallback((value: MultiValue<Option> | SingleValue<Option>) => {
let hiddenInputValue = "";
if (value instanceof Array) {
hiddenInputValue = value.map((val) => val.value).join(",");
} else {
hiddenInputValue = value?.value || "";
}
_setHiddenInputValue(hiddenInputValue);
}, []);
useEffect(() => {
if (!value) {
return;
}
setHiddenInputValue(value);
}, [value, setHiddenInputValue]);
return (
<div className={classNames("relative", remainingProps.className)}>
<Select
value={value}
{...remainingProps}
onChange={(value, ...remainingArgs) => {
setHiddenInputValue(value);
if (onChange) {
onChange(value, ...remainingArgs);
}
}}
/>
{required && (
<input
tabIndex={-1}
autoComplete="off"
style={{
opacity: 0,
width: "100%",
height: 1,
position: "absolute",
}}
value={hiddenInputValue}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange={() => {}}
// TODO:Not able to get focus to work
// onFocus={() => selectRef.current?.focus()}
required={required}
/>
)}
</div>
);
}
export default Select;