Merge branch 'main' into 11576-cal-2537-a11y-turn-booking-entries-in-booking-to-links-not-clickable-divs
This commit is contained in:
commit
5117c26f2d
|
@ -1 +0,0 @@
|
|||
_
|
|
@ -1,6 +1,6 @@
|
|||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
Welcome to the first stage of Cal.ai!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
|
@ -27,7 +27,7 @@ Before running the app, please see [env.mjs](./src/env.mjs) for all required env
|
|||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
|
|
|
@ -61,9 +61,9 @@ export const POST = async (request: NextRequest) => {
|
|||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
@ -78,9 +78,9 @@ export const POST = async (request: NextRequest) => {
|
|||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
|
|
@ -16,7 +16,7 @@ import now from "./now";
|
|||
const gptModel = "gpt-4";
|
||||
|
||||
/**
|
||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
|
@ -49,7 +49,7 @@ const agent = async (
|
|||
*/
|
||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||
agentArgs: {
|
||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||
|
|
|
@ -27,7 +27,7 @@ const send = async ({
|
|||
cc,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
name: "Cal.ai",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
|
|
|
@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
|
|
|
@ -29,9 +29,10 @@ interface AppListProps {
|
|||
variant?: AppCategories;
|
||||
data: RouterOutputs["viewer"]["integrations"];
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => {
|
||||
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
|
||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const utils = trpc.useContext();
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
|
@ -155,7 +156,7 @@ export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => {
|
|||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<List className={listClassName}>
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
|
|
|
@ -89,6 +89,7 @@ export const AppPage = ({
|
|||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
|
|
|
@ -34,7 +34,7 @@ import {
|
|||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
||||
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
|
||||
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/**
|
||||
* Only display calendar selector if user has connected calendars AND if it's not
|
||||
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
||||
* This will fallback to each user selected destination calendar.
|
||||
*/}
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="-mt-1 w-full">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||
</div>
|
||||
<div className="-mt-1 w-full">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-default text-sm">{t("select_which_cal")}</p>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
||||
<div>
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
|
@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.requiresBookerEmailVerification}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("requires_booker_email_verification")}
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
|
@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hideCalendarNotes}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_notes")}
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
description={t("disable_notes_description")}
|
||||
|
@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
redirectUrlVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("redirect_success_booking")}
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_url_description")}
|
||||
|
@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
||||
<div className="lg:-mb-2 lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
|
@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
</>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
hashedLinkVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
Badge={
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
||||
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
</a>
|
||||
}
|
||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
|
@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
setHashedLinkVisible(e);
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||
<div className="lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
|
@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
control={formMethods.control}
|
||||
|
@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
value && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
{...seatsLocked}
|
||||
|
@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) =>
|
||||
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
|
||||
}
|
||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||
</>
|
||||
|
@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
/>
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
|
@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
control={formMethods.control}
|
||||
|
@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
|
|
|
@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
<div className="bg-muted rounded-md p-8">
|
||||
<div className="bg-muted mt-6 rounded-md p-8">
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<>
|
||||
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
||||
|
@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||
</h2>
|
||||
<p className="text-default mb-6 text-sm font-normal">
|
||||
<Trans i18nKey="available_apps_desc">
|
||||
You have no apps installed. View popular apps below and explore more in our
|
||||
View popular apps below and explore more in our
|
||||
<Link className="cursor-pointer underline" href="/apps">
|
||||
App Store
|
||||
</Link>
|
||||
|
|
|
@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo(
|
|||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr className="border-subtle" />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<div className="border-subtle space-y-4 border-x p-6">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
|
@ -234,8 +235,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||
}, [availabilityValue, setValue]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div>
|
||||
<div className="border-subtle rounded-t-md border p-6">
|
||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||
{t("availability")}
|
||||
{shouldLockIndicator("availability")}
|
||||
|
|
|
@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
|
|||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Plus, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
|
@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
|||
type="number"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={passThroughProps.disabled}
|
||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||
)}
|
||||
|
@ -170,8 +170,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div>
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
|
@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="durationLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="periodType"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={value && value !== "UNLIMITED"}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value.toString() as "0" | "1"
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
optionsPeriod.find(
|
||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||
) ?? optionsPeriod[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
defaultValue={periodDates}
|
||||
render={() => (
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = value && value !== "UNLIMITED";
|
||||
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value.toString() as "0" | "1"
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
optionsPeriod.find(
|
||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||
) ?? optionsPeriod[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
defaultValue={periodDates}
|
||||
render={() => (
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
offsetToggle && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
|
@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||
formMethods.setValue("offsetStart", 0);
|
||||
}
|
||||
}}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
|
@ -509,19 +547,19 @@ const IntervalLimitItem = ({
|
|||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
className="mb-0 !h-auto"
|
||||
className="mb-0"
|
||||
placeholder={`${value}`}
|
||||
disabled={disabled}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
|
@ -529,9 +567,16 @@ const IntervalLimitItem = ({
|
|||
isDisabled={disabled}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasDeleteButton && !disabled && (
|
||||
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
||||
<Button
|
||||
variant="icon"
|
||||
StartIcon={Trash2}
|
||||
color="destructive"
|
||||
className="border-none"
|
||||
onClick={() => onDelete(limitKey)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -387,178 +387,185 @@ export const EventSetupTab = (
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
required
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<div>
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
<div>
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import type { Webhook } from "@prisma/client";
|
||||
import { Webhook as TbWebhook } from "lucide-react";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
|
||||
|
@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
|
|||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
|
@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
|||
)}
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mb-2 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-default mt-8 text-sm font-normal">
|
||||
<Trans i18nKey="edit_or_manage_webhooks">
|
||||
If you wish to edit or manage your web hooks, please head over to
|
||||
<Link
|
||||
className="cursor-pointer font-semibold underline"
|
||||
href="/settings/developer/webhooks">
|
||||
webhooks settings
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<NewWebhookButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
|||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
@ -47,6 +48,12 @@ export default function RecurringEventController({
|
|||
) : (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
recurringEventState !== null && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("recurring_event")}
|
||||
{...recurringLocked}
|
||||
description={t("recurring_event_description")}
|
||||
|
@ -66,68 +73,70 @@ export default function RecurringEventController({
|
|||
setRecurringEventState(newVal);
|
||||
}
|
||||
}}>
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
|
|||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
requiresConfirmation && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("requires_confirmation")}
|
||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
|
@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
|
|||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
className="ml-2"
|
||||
onChange={(opt) => {
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||
onChange={(opt) => {
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -250,11 +250,8 @@ export default function Custom404() {
|
|||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-blue-500">
|
||||
{new URL(WEBSITE_URL).hostname}
|
||||
{username}
|
||||
</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
|
||||
<span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
|
|
|
@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
|
|||
const asPath = ctx.asPath || "";
|
||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
|
||||
const isEmbedSnippetGeneratorPath = parsedUrl.pathname.startsWith("/event-types");
|
||||
// FIXME: Revisit this logic to remove embedType query param check completely. Ideally, /embed should always be there at the end of the URL. Test properly and then remove it.
|
||||
const isEmbed =
|
||||
(parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null) &&
|
||||
!isEmbedSnippetGeneratorPath;
|
||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default, config } from "@calcom/app-store/alby/api/webhook";
|
|
@ -1 +1 @@
|
|||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
export { default, config } from "@calcom/app-store/paypal/api/webhook";
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
|
@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
|||
|
||||
SetupInformation.PageWrapper = PageWrapper;
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
export { getStaticProps };
|
||||
export { getServerSideProps };
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
|
@ -93,7 +94,7 @@ const querySchema = z.object({
|
|||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
|
@ -490,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
|||
: t("payment")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-2 mt-3">
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: props.paymentStatus.currency,
|
||||
}).format(props.paymentStatus.amount / 100.0)}
|
||||
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
availability={availability}
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
disableBorder={true}
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
|
|
|
@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
|||
<>
|
||||
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
|
||||
<div>
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
||||
</section>
|
||||
<hr className="border-subtle" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -45,14 +44,16 @@ const BillingView = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
||||
<div className="space-y-6 text-sm sm:space-y-8">
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||
<Button color="secondary" onClick={onContactSupportClick}>
|
||||
{t("contact_support")}
|
||||
|
|
|
@ -14,12 +14,25 @@ import {
|
|||
DialogContent,
|
||||
EmptyScreen,
|
||||
Meta,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiKeysView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -39,49 +52,57 @@ const ApiKeysView = () => {
|
|||
setApiKeyToEdit(undefined);
|
||||
setApiKeyModal(true);
|
||||
}}>
|
||||
{t("new_api_key")}
|
||||
{t("add")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<SkeletonLoader
|
||||
title={t("api_keys")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("api_keys")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
CTA={<NewApiKeyButton />}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
|
||||
<LicenseRequired>
|
||||
<>
|
||||
{isLoading && <SkeletonLoader />}
|
||||
<div>
|
||||
{isLoading ? null : data?.length ? (
|
||||
<>
|
||||
<div className="border-subtle mb-8 mt-6 rounded-md border">
|
||||
{data.map((apiKey, index) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
lastItem={data.length === index + 1}
|
||||
onEditClick={() => {
|
||||
setApiKeyToEdit(apiKey);
|
||||
setApiKeyModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<NewApiKeyButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_first_api_key")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewApiKeyButton />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
{data?.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
{data.map((apiKey, index) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
lastItem={data.length === index + 1}
|
||||
onEditClick={() => {
|
||||
setApiKeyToEdit(apiKey);
|
||||
setApiKeyModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_first_api_key")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
className="rounded-b-md rounded-t-none border-t-0"
|
||||
buttonRaw={<NewApiKeyButton />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
|
||||
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
||||
|
|
|
@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
|
|||
import type { z } from "zod";
|
||||
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
|
@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
|
@ -22,7 +25,7 @@ import {
|
|||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
SettingsToggle,
|
||||
UpgradeTeamsBadge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
|
@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
|
|||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
|
@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||
</div>
|
||||
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceView = () => {
|
||||
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
||||
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
||||
|
||||
const AppearanceView = ({
|
||||
user,
|
||||
hasPaidPlan,
|
||||
}: {
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
hasPaidPlan: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const [darkModeError, setDarkModeError] = useState(false);
|
||||
const [lightModeError, setLightModeError] = useState(false);
|
||||
const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState(
|
||||
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
|
||||
|
||||
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
||||
|
||||
const formMethods = useForm({
|
||||
const userThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
theme: user?.theme,
|
||||
brandColor: user?.brandColor || "#292929",
|
||||
darkBrandColor: user?.darkBrandColor || "#fafafa",
|
||||
hideBranding: user?.hideBranding,
|
||||
metadata: user?.metadata as z.infer<typeof userMetadata>,
|
||||
theme: user.theme,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTheme = formMethods.watch("theme");
|
||||
const {
|
||||
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
|
||||
reset: resetUserThemeReset,
|
||||
} = userThemeFormMethods;
|
||||
|
||||
const bookerLayoutFormMethods = useForm({
|
||||
defaultValues: {
|
||||
metadata: user.metadata as z.infer<typeof userMetadata>,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
|
||||
reset: resetBookerLayoutThemeReset,
|
||||
} = bookerLayoutFormMethods;
|
||||
|
||||
const brandColorsFormMethods = useForm({
|
||||
defaultValues: {
|
||||
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
|
||||
reset: resetBrandColorsThemeReset,
|
||||
} = brandColorsFormMethods;
|
||||
|
||||
const selectedTheme = userThemeFormMethods.watch("theme");
|
||||
const selectedThemeIsDark =
|
||||
selectedTheme === "dark" ||
|
||||
(selectedTheme === "" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
reset,
|
||||
} = formMethods;
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await utils.viewer.me.invalidate();
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
reset(data);
|
||||
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
|
||||
resetBookerLayoutThemeReset({ metadata: data.metadata });
|
||||
resetUserThemeReset({ theme: data.theme });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message) {
|
||||
|
@ -97,136 +134,180 @@ const AppearanceView = () => {
|
|||
},
|
||||
});
|
||||
|
||||
if (isLoading || isTeamPlanStatusLoading)
|
||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||
if (layoutError) throw new Error(t(layoutError));
|
||||
|
||||
mutation.mutate({
|
||||
...values,
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// back to null here.
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} />
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default font-semibold">{t("theme")}</p>
|
||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||
<p className="text-default">{t("theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.theme === "light"}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.theme === "dark"}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
|
||||
<BookerLayoutSelector
|
||||
isDark={selectedThemeIsDark}
|
||||
name="metadata.defaultBookerLayouts"
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
/>
|
||||
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
|
||||
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
|
||||
<Form
|
||||
form={userThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// back to null here.
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.theme === "light"}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.theme === "dark"}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
disabled={isUserThemeSubmitting || !isUserThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-theme-btn"
|
||||
color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<div className="block justify-between sm:flex">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.brandColor}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
<Form
|
||||
form={bookerLayoutFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||
if (layoutError) {
|
||||
showToast(t(layoutError), "error");
|
||||
return;
|
||||
} else {
|
||||
mutation.mutate(values);
|
||||
}
|
||||
}}>
|
||||
<BookerLayoutSelector
|
||||
isDark={selectedThemeIsDark}
|
||||
name="metadata.defaultBookerLayouts"
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Form
|
||||
form={brandColorsFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("custom_brand_colors")}
|
||||
description={t("customize_your_brand_colors")}
|
||||
checked={isCustomBrandColorChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomBranColorChecked(checked);
|
||||
if (!checked) {
|
||||
mutation.mutate({
|
||||
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
|
||||
});
|
||||
}
|
||||
}}
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
isCustomBrandColorChecked && "rounded-b-none"
|
||||
)}>
|
||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#ffffff", value)) {
|
||||
setLightModeError(true);
|
||||
} else {
|
||||
setLightModeError(false);
|
||||
}
|
||||
formMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
}}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#ffffff", value);
|
||||
setLightModeError(false);
|
||||
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setLightModeError(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.darkBrandColor}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#101010", value)) {
|
||||
setDarkModeError(true);
|
||||
} else {
|
||||
setDarkModeError(false);
|
||||
}
|
||||
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
}}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#101010", value);
|
||||
setDarkModeError(false);
|
||||
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setDarkModeError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||
color="primary"
|
||||
type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
) : null}
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
{/* TODO future PR to preview brandColors */}
|
||||
{/* <Button
|
||||
color="secondary"
|
||||
|
@ -235,51 +316,37 @@ const AppearanceView = () => {
|
|||
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
||||
Preview
|
||||
</Button> */}
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<Controller
|
||||
name="hideBranding"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.hideBranding}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<div className="flex w-full text-sm">
|
||||
<div className="mr-1 flex-grow">
|
||||
<div className="flex items-center">
|
||||
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
|
||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
||||
</p>
|
||||
<UpgradeTeamsBadge />
|
||||
</div>
|
||||
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<Switch
|
||||
id="hideBranding"
|
||||
disabled={!hasPaidPlan}
|
||||
onCheckedChange={(checked) =>
|
||||
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
|
||||
}
|
||||
checked={hasPaidPlan ? value : false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("disable_cal_branding", { appName: APP_NAME })}
|
||||
disabled={!hasPaidPlan || mutation?.isLoading}
|
||||
description={t("removes_cal_branding", { appName: APP_NAME })}
|
||||
checked={hasPaidPlan ? hideBrandingValue : false}
|
||||
Badge={<UpgradeTeamsBadge />}
|
||||
onCheckedChange={(checked) => {
|
||||
setHideBrandingValue(checked);
|
||||
mutation.mutate({ hideBranding: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
loading={mutation.isLoading}
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
data-testid="update-theme-btn">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AppearanceView.getLayout = getLayout;
|
||||
AppearanceView.PageWrapper = PageWrapper;
|
||||
const AppearanceViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
||||
|
||||
export default AppearanceView;
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading || isTeamPlanStatusLoading || !user)
|
||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
||||
|
||||
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
|
||||
};
|
||||
|
||||
AppearanceViewWrapper.getLayout = getLayout;
|
||||
AppearanceViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default AppearanceViewWrapper;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
|
|||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
|
@ -65,6 +66,21 @@ const CalendarsView = () => {
|
|||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
|
||||
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
|
||||
integration: string;
|
||||
externalId: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data?.destinationCalendar) {
|
||||
setSelectedDestinationCalendar({
|
||||
integration: query.data.destinationCalendar.integration,
|
||||
externalId: query.data.destinationCalendar.externalId,
|
||||
});
|
||||
}
|
||||
}, [query?.isLoading, query?.data?.destinationCalendar]);
|
||||
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
|
@ -79,43 +95,58 @@ const CalendarsView = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
|
||||
<Meta
|
||||
title={t("calendars")}
|
||||
description={t("calendars_description")}
|
||||
CTA={<AddCalendarButton />}
|
||||
borderInShellHeader={false}
|
||||
/>
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
const isDestinationUpdateBtnDisabled =
|
||||
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||
return data.connectedCalendars.length ? (
|
||||
<div>
|
||||
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
|
||||
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
|
||||
<Calendar className="text-default h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col space-y-3">
|
||||
<div>
|
||||
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
|
||||
{t("add_to_calendar")}
|
||||
</h4>
|
||||
<p className=" text-default text-sm leading-5">
|
||||
<Trans i18nKey="add_to_calendar_description">
|
||||
Where to add events when you re booked. You can override this on a per-event basis in
|
||||
advanced settings in the event type.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={data.destinationCalendar?.externalId}
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={selectedDestinationCalendarOption?.externalId}
|
||||
onChange={(option) => {
|
||||
setSelectedDestinationCalendar(option);
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
disabled={isDestinationUpdateBtnDisabled}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
|
||||
}}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
||||
</div>
|
||||
|
||||
<List
|
||||
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
|
||||
noBorderTreatment>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && item.error.message && (
|
||||
|
@ -207,6 +238,7 @@ const CalendarsView = () => {
|
|||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||
className="mt-6"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
@ -15,8 +15,8 @@ import { AppList } from "@components/apps/AppList";
|
|||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="divide-subtle mb-8 mt-6 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
|
@ -28,11 +28,9 @@ const AddConferencingButton = () => {
|
|||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
|
||||
{t("add_conferencing_app")}
|
||||
</Button>
|
||||
</>
|
||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
|
||||
{t("add")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -72,6 +70,7 @@ const ConferencingLayout = () => {
|
|||
title={t("conferencing")}
|
||||
description={t("conferencing_description")}
|
||||
CTA={<AddConferencingButton />}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<QueryCell
|
||||
query={query}
|
||||
|
@ -93,13 +92,20 @@ const ConferencingLayout = () => {
|
|||
color="secondary"
|
||||
data-testid="connect-conferencing-apps"
|
||||
href="/apps/categories/conferencing">
|
||||
{t("connect_conferencing_apps")}
|
||||
{t("connect_conference_apps")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AppList handleDisconnect={handleDisconnect} data={data} variant="conferencing" />;
|
||||
return (
|
||||
<AppList
|
||||
listClassName="rounded-xl rounded-t-none border-t-0"
|
||||
handleDisconnect={handleDisconnect}
|
||||
data={data}
|
||||
variant="conferencing"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localeOptions } from "@calcom/lib/i18n";
|
||||
|
@ -13,12 +15,12 @@ import {
|
|||
Label,
|
||||
Meta,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
TimezoneSelect,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
|
|||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
|
@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
const { update } = useSession();
|
||||
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
|
@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||
},
|
||||
onSettled: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
setIsUpdateBtnLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||
value: user.weekStart,
|
||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
allowDynamicBooking: user.allowDynamicBooking ?? true,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true,
|
||||
},
|
||||
});
|
||||
const {
|
||||
|
@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||
} = formMethods;
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
|
||||
!!user.allowDynamicBooking
|
||||
);
|
||||
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
|
||||
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
|
||||
!!user.receiveMonthlyDigestEmail
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
locale: values.locale.value,
|
||||
timeFormat: values.timeFormat.value,
|
||||
weekStart: values.weekStart.value,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("general")} description={t("general_description")} />
|
||||
<Controller
|
||||
name="locale"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis">
|
||||
<>{t("language")}</>
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
className="capitalize"
|
||||
options={localeOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("time_format")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={timeFormatOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
{t("timeformat_profile_hint")}
|
||||
</div>
|
||||
<Controller
|
||||
name="weekStart"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("start_of_week")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={weekStartOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Controller
|
||||
name="allowDynamicBooking"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("dynamic_booking")}
|
||||
description={t("allow_dynamic_booking")}
|
||||
checked={formMethods.getValues("allowDynamicBooking")}
|
||||
onCheckedChange={(checked) => {
|
||||
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
setIsUpdateBtnLoading(true);
|
||||
mutation.mutate({
|
||||
...values,
|
||||
locale: values.locale.value,
|
||||
timeFormat: values.timeFormat.value,
|
||||
weekStart: values.weekStart.value,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
|
||||
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
|
||||
<Controller
|
||||
name="locale"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis">
|
||||
<>{t("language")}</>
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
className="capitalize"
|
||||
options={localeOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("time_format")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={timeFormatOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
{t("timeformat_profile_hint")}
|
||||
</div>
|
||||
<Controller
|
||||
name="weekStart"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("start_of_week")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={weekStartOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Controller
|
||||
name="allowSEOIndexing"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("seo_indexing")}
|
||||
description={t("allow_seo_indexing")}
|
||||
checked={formMethods.getValues("allowSEOIndexing")}
|
||||
onCheckedChange={(checked) => {
|
||||
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
|
||||
<>{t("update")}</>
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<div className="mt-8">
|
||||
<Controller
|
||||
name="receiveMonthlyDigestEmail"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("monthly_digest_email")}
|
||||
description={t("monthly_digest_email_for_teams")}
|
||||
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
|
||||
onCheckedChange={(checked) => {
|
||||
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("dynamic_booking")}
|
||||
description={t("allow_dynamic_booking")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isAllowDynamicBookingChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsAllowDynamicBookingChecked(checked);
|
||||
mutation.mutate({ allowDynamicBooking: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="mt-8">
|
||||
<>{t("update")}</>
|
||||
</Button>
|
||||
</Form>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("seo_indexing")}
|
||||
description={t("allow_seo_indexing")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isAllowSEOIndexingChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsAllowSEOIndexingChecked(checked);
|
||||
mutation.mutate({ allowSEOIndexing: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("monthly_digest_email")}
|
||||
description={t("monthly_digest_email_for_teams")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isReceiveMonthlyDigestEmailChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsReceiveMonthlyDigestEmailChecked(checked);
|
||||
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,8 +7,10 @@ import { z } from "zod";
|
|||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
|
@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
|||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
||||
<div className="flex items-center">
|
||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||
|
@ -69,18 +71,30 @@ interface DeleteAccountValues {
|
|||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
avatar: string;
|
||||
avatar: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { update } = useSession();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
||||
onSuccess: (userData) => {
|
||||
fetch(userData.avatar).then((res) => {
|
||||
if (res.url) setFetchedImgSrc(res.url);
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
|
@ -204,7 +218,7 @@ const ProfileView = () => {
|
|||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
if (isLoading || !user)
|
||||
if (isLoading || !user || !fetchedImgSrc)
|
||||
return (
|
||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
);
|
||||
|
@ -219,11 +233,17 @@ const ProfileView = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
<Meta
|
||||
title={t("profile")}
|
||||
description={t("profile_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<ProfileForm
|
||||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||
userAvatar={user.avatar}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
|
@ -238,7 +258,7 @@ const ProfileView = () => {
|
|||
}
|
||||
}}
|
||||
extraField={
|
||||
<div className="mt-8">
|
||||
<div className="mt-6">
|
||||
<UsernameAvailabilityField
|
||||
onSuccessMutation={async () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
|
@ -252,16 +272,19 @@ const ProfileView = () => {
|
|||
}
|
||||
/>
|
||||
|
||||
<hr className="border-subtle my-6" />
|
||||
|
||||
<Label>{t("danger_zone")}</Label>
|
||||
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
|
||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
{/* Delete account Dialog */}
|
||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||
{t("delete_account")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||
{t("delete_account")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</SectionBottomActions>
|
||||
<DialogContent
|
||||
title={t("delete_account_modal_title")}
|
||||
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
||||
|
@ -364,12 +387,16 @@ const ProfileForm = ({
|
|||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
isFallbackImg,
|
||||
userAvatar,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
isFallbackImg: boolean;
|
||||
userAvatar: string;
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
@ -377,7 +404,7 @@ const ProfileForm = ({
|
|||
|
||||
const profileFormSchema = z.object({
|
||||
username: z.string(),
|
||||
avatar: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
|
@ -402,56 +429,77 @@ const ProfileForm = ({
|
|||
|
||||
return (
|
||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||
return (
|
||||
<>
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
||||
/>
|
||||
|
||||
{showRemoveAvatarButton && (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
formMethods.setValue("avatar", null, { shouldDirty: true });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{extraField}
|
||||
<div className="mt-6">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("bio") || "")}
|
||||
setText={(value: string) => {
|
||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{extraField}
|
||||
<div className="mt-8">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("bio") || "")}
|
||||
setText={(value: string) => {
|
||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,23 +1,34 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileImpersonationView = () => {
|
||||
const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const [disableImpersonation, setDisableImpersonation] = useState<boolean | undefined>(
|
||||
user?.disableImpersonation
|
||||
);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("profile_updated_successfully"), "success");
|
||||
reset(getValues());
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.me.invalidate();
|
||||
|
@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
|
|||
await utils.viewer.me.cancel();
|
||||
const previousValue = utils.viewer.me.getData();
|
||||
|
||||
if (previousValue && disableImpersonation) {
|
||||
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
|
||||
}
|
||||
setDisableImpersonation(disableImpersonation);
|
||||
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.me.setData(undefined, context.previousValue);
|
||||
setDisableImpersonation(context.previousValue?.disableImpersonation);
|
||||
}
|
||||
showToast(`${t("error")}, ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const formMethods = useForm<{ disableImpersonation: boolean }>({
|
||||
defaultValues: {
|
||||
disableImpersonation: user?.disableImpersonation,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
setValue,
|
||||
reset,
|
||||
getValues,
|
||||
watch,
|
||||
} = formMethods;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("impersonation")} description={t("impersonation_description")} />
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={({ disableImpersonation }) => {
|
||||
mutation.mutate({ disableImpersonation });
|
||||
}}>
|
||||
<div className="flex space-x-3">
|
||||
<Switch
|
||||
onCheckedChange={(e) => {
|
||||
setValue("disableImpersonation", !e, { shouldDirty: true });
|
||||
}}
|
||||
fitToHeight={true}
|
||||
checked={!watch("disableImpersonation")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
|
||||
{t("user_impersonation_heading")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
|
||||
{t("user_impersonation_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
loading={mutation.isLoading}
|
||||
className="mt-8"
|
||||
type="submit"
|
||||
disabled={isDisabled}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
<Meta
|
||||
title={t("impersonation")}
|
||||
description={t("impersonation_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("user_impersonation_heading")}
|
||||
description={t("user_impersonation_description")}
|
||||
checked={!disableImpersonation}
|
||||
onCheckedChange={(checked) => {
|
||||
mutation.mutate({ disableImpersonation: !checked });
|
||||
}}
|
||||
disabled={mutation.isLoading}
|
||||
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileImpersonationView.getLayout = getLayout;
|
||||
ProfileImpersonationView.PageWrapper = PageWrapper;
|
||||
const ProfileImpersonationViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
if (isLoading || !user)
|
||||
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
|
||||
|
||||
return <ProfileImpersonationView user={user} />;
|
||||
};
|
||||
|
||||
export default ProfileImpersonationView;
|
||||
ProfileImpersonationViewWrapper.getLayout = getLayout;
|
||||
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default ProfileImpersonationViewWrapper;
|
||||
|
|
|
@ -1,13 +1,29 @@
|
|||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Meta,
|
||||
PasswordField,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
|
@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
|
|||
apiError: string;
|
||||
};
|
||||
|
||||
const PasswordView = () => {
|
||||
interface PasswordViewProps {
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
}
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const PasswordView = ({ user }: PasswordViewProps) => {
|
||||
const { data } = useSession();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const metadata = userMetadata.safeParse(user?.metadata);
|
||||
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||
const metadata = userMetadataSchema.safeParse(user?.metadata);
|
||||
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||
|
||||
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
|
||||
|
||||
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
showToast(t("session_timeout_changed"), "success");
|
||||
formMethods.reset(formMethods.getValues());
|
||||
setSessionTimeout(data.metadata?.sessionTimeout);
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.me.invalidate();
|
||||
},
|
||||
onMutate: async () => {
|
||||
await utils.viewer.me.cancel();
|
||||
const previousValue = utils.viewer.me.getData();
|
||||
const previousMetadata = userMetadata.parse(previousValue?.metadata);
|
||||
const previousValue = await utils.viewer.me.getData();
|
||||
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
|
||||
|
||||
if (previousValue && sessionTimeout) {
|
||||
if (previousValue && sessionTimeout && previousMetadata.success) {
|
||||
utils.viewer.me.setData(undefined, {
|
||||
...previousValue,
|
||||
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
|
||||
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
|
||||
});
|
||||
return { previousValue };
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
if (context?.previousValue) {
|
||||
|
@ -84,20 +124,30 @@ const PasswordView = () => {
|
|||
defaultValues: {
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
sessionTimeout,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
|
||||
|
||||
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
||||
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
|
||||
const { oldPassword, newPassword } = values;
|
||||
|
||||
if (!oldPassword.length) {
|
||||
formMethods.setError(
|
||||
"oldPassword",
|
||||
{ type: "required", message: t("error_required_field") },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
}
|
||||
if (!newPassword.length) {
|
||||
formMethods.setError(
|
||||
"newPassword",
|
||||
{ type: "required", message: t("error_required_field") },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (oldPassword && newPassword) {
|
||||
passwordMutation.mutate({ oldPassword, newPassword });
|
||||
}
|
||||
if (sessionTimeout !== newSessionTimeout) {
|
||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
||||
|
@ -112,7 +162,7 @@ const PasswordView = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} />
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<div>
|
||||
<div className="mt-6">
|
||||
|
@ -130,87 +180,127 @@ const PasswordView = () => {
|
|||
</div>
|
||||
) : (
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||
value: passwordMinLength,
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
<div className="border-x px-4 py-6 sm:px-6">
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
|
||||
<div>
|
||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||
value: passwordMinLength,
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-default mt-4 w-full text-sm">
|
||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-default mt-4 max-w-[38rem] text-sm">
|
||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||
</p>
|
||||
<div className="border-subtle mt-8 border-t py-8">
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
loading={passwordMutation.isLoading}
|
||||
onClick={() => formMethods.clearErrors("apiError")}
|
||||
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("session_timeout")}
|
||||
description={t("session_timeout_description")}
|
||||
checked={sessionTimeoutWatch !== undefined}
|
||||
checked={sessionTimeout !== undefined}
|
||||
data-testid="session-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
|
||||
setSessionTimeout(undefined);
|
||||
|
||||
if (metadata.success) {
|
||||
sessionMutation.mutate({
|
||||
metadata: { ...metadata.data, sessionTimeout: undefined },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
|
||||
setSessionTimeout(10);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionTimeoutWatch && (
|
||||
<div className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
|
||||
<Select
|
||||
options={timeoutOptions}
|
||||
defaultValue={
|
||||
sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||
: timeoutOptions[1]
|
||||
}
|
||||
isSearchable={false}
|
||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
||||
onChange={(event) => {
|
||||
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
!!sessionTimeout && "rounded-b-none"
|
||||
)}>
|
||||
<>
|
||||
<div className="border-subtle border-x p-6 pb-8">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
|
||||
<Select
|
||||
options={timeoutOptions}
|
||||
defaultValue={
|
||||
sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||
: timeoutOptions[1]
|
||||
}
|
||||
isSearchable={false}
|
||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
||||
onChange={(event) => {
|
||||
setSessionTimeout(event?.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
color="primary"
|
||||
loading={sessionMutation.isLoading}
|
||||
onClick={() => {
|
||||
sessionMutation.mutate({
|
||||
metadata: { ...metadata, sessionTimeout },
|
||||
});
|
||||
formMethods.clearErrors("apiError");
|
||||
}}
|
||||
disabled={
|
||||
initialSessionTimeout === sessionTimeout ||
|
||||
passwordMutation.isLoading ||
|
||||
sessionMutation.isLoading
|
||||
}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
|
||||
<Button
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
type="submit"
|
||||
loading={passwordMutation.isLoading || sessionMutation.isLoading}
|
||||
onClick={() => formMethods.clearErrors("apiError")}
|
||||
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordView.getLayout = getLayout;
|
||||
PasswordView.PageWrapper = PageWrapper;
|
||||
const PasswordViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { t } = useLocale();
|
||||
if (isLoading || !user)
|
||||
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
|
||||
|
||||
export default PasswordView;
|
||||
return <PasswordView user={user} />;
|
||||
};
|
||||
|
||||
PasswordViewWrapper.getLayout = getLayout;
|
||||
PasswordViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default PasswordViewWrapper;
|
||||
|
|
|
@ -3,15 +3,24 @@ import { useState } from "react";
|
|||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui";
|
||||
import {
|
||||
Badge,
|
||||
Meta,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Alert,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
|
@ -28,36 +37,34 @@ const TwoFactorAuthView = () => {
|
|||
const { t } = useLocale();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
|
||||
const [enableModalOpen, setEnableModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
|
||||
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
if (isLoading)
|
||||
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
|
||||
|
||||
const isCalProvider = user?.identityProvider === "CAL";
|
||||
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} />
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
|
||||
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
|
||||
<div className="mt-6 flex items-start space-x-4">
|
||||
<Switch
|
||||
data-testid="two-factor-switch"
|
||||
disabled={canSetupTwoFactor}
|
||||
checked={user?.twoFactorEnabled}
|
||||
onCheckedChange={() =>
|
||||
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
||||
}
|
||||
/>
|
||||
<div className="!mx-4">
|
||||
<div className="flex">
|
||||
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
|
||||
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
data-testid="two-factor-switch"
|
||||
title={t("two_factor_auth")}
|
||||
description={t("add_an_extra_layer_of_security")}
|
||||
checked={user?.twoFactorEnabled ?? false}
|
||||
onCheckedChange={() =>
|
||||
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
||||
}
|
||||
Badge={
|
||||
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
}
|
||||
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
|
||||
/>
|
||||
|
||||
<EnableTwoFactorModal
|
||||
open={enableModalOpen}
|
||||
|
|
|
@ -556,6 +556,8 @@ export async function apiLogin(
|
|||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
await page.locator("div > .ml-auto").first().click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
await page.getByPlaceholder("Price").fill("100");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
}
|
||||
|
|
|
@ -174,7 +174,7 @@ test.describe("Stripe integration", () => {
|
|||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.locator("div").filter({ hasText: "United States dollar (USD)" }).nth(1).click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Payment app", () => {
|
||||
test("Should be able to edit alby price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
account_id: "random",
|
||||
account_email: "random@example.com",
|
||||
webhook_endpoint_id: "ep_randomString",
|
||||
webhook_endpoint_secret: "whsec_randomString",
|
||||
account_lightning_address: "random@getalby.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("200");
|
||||
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 200 sats to be displayed in page
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "stripe_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
scope: "read_write",
|
||||
livemode: false,
|
||||
token_type: "bearer",
|
||||
access_token: "sk_test_randomString",
|
||||
refresh_token: "rt_randomString",
|
||||
stripe_user_id: "acct_randomString",
|
||||
default_currency: "usd",
|
||||
stripe_publishable_key: "pk_test_randomString",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
|
||||
await page.getByTestId("price-input-stripe").click();
|
||||
await page.getByTestId("price-input-stripe").fill("350");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 200 sats to be displayed in page
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should be able to edit paypal price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
client_id: "randomString",
|
||||
secret_key: "randomString",
|
||||
webhook_id: "randomString",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("150");
|
||||
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-option-13").click();
|
||||
|
||||
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
|
||||
|
||||
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 150 to be displayed in page
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
// expect 150 to be displayed in page
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 150
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should display App is not setup already for alby", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
|
||||
// Expect "Connect with Alby" to be displayed
|
||||
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should display App is not setup already for paypal", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
|
||||
// Expect "Getting started with Paypal APP" to be displayed
|
||||
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -255,7 +255,7 @@
|
|||
"yours": "Your account",
|
||||
"available_apps": "Available Apps",
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
|
||||
"available_apps_desc": "View popular apps below and explore more in our <1>App Store</1>",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
|
||||
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
|
@ -288,6 +288,7 @@
|
|||
"when": "When",
|
||||
"where": "Where",
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"add_to_calendar_description":"Select where to add events when you’re booked.",
|
||||
"add_another_calendar": "Add another calendar",
|
||||
"other": "Other",
|
||||
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
||||
|
@ -599,6 +600,7 @@
|
|||
"hide_book_a_team_member": "Hide Book a Team Member Button",
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
|
@ -688,6 +690,7 @@
|
|||
"people": "People",
|
||||
"your_email": "Your Email",
|
||||
"change_avatar": "Change Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"language": "Language",
|
||||
"timezone": "Timezone",
|
||||
"first_day_of_week": "First Day of Week",
|
||||
|
@ -1293,7 +1296,7 @@
|
|||
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
|
||||
"pro": "Pro",
|
||||
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
|
||||
"profile_picture": "Profile picture",
|
||||
"profile_picture": "Profile Picture",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Add profile photo",
|
||||
"web3": "Web3",
|
||||
|
@ -1880,6 +1883,7 @@
|
|||
"edit_invite_link": "Edit link settings",
|
||||
"invite_link_copied": "Invite link copied",
|
||||
"invite_link_deleted": "Invite link deleted",
|
||||
"api_key_deleted":"API Key deleted",
|
||||
"invite_link_updated": "Invite link settings saved",
|
||||
"link_expires_after": "Links set to expire after...",
|
||||
"one_day": "1 day",
|
||||
|
@ -2059,5 +2063,6 @@
|
|||
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||
"resend_invitation": "Resend invitation",
|
||||
"invitation_resent": "The invitation was resent.",
|
||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -1063,6 +1063,7 @@
|
|||
"your_unique_api_key": "מפתח ה-API הייחודי שלך",
|
||||
"copy_safe_api_key": "העתק/י את מפתח ה-API הזה ושמור/י אותו במקום בטוח. אם תאבד/י אותו, יהיה עליך ליצור מפתח חדש.",
|
||||
"zapier_setup_instructions": "<0>התחבר/י לחשבון Zapier שלך וצור/י Zap חדש.</0><1>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</1><2>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</2><3>בדוק/י את ה-Trigger.</3><4>וזהו, הכל מוכן!</4>",
|
||||
"make_setup_instructions": "<0>עבור/י אל <1><0>יצירת קישור Invite</0></1> והתקן/י את אפליקציית Cal.com.</0><1>התחבר/י לחשבון Make שלך וצור/י Scenario חדש.</1><2>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</2><3>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</3><4>בדוק/י את ה-Trigger.</4><5>וזהו, הכל מוכן!</5>",
|
||||
"install_zapier_app": "תחילה עליך להוריד את אפליקציית Zapier מה-App Store ולהתקין אותה.",
|
||||
"install_make_app": "תחילה עליך להוריד את אפליקציית Make מה-App Store ולהתקין אותה.",
|
||||
"connect_apple_server": "חיבור לשרת Apple",
|
||||
|
@ -1695,6 +1696,7 @@
|
|||
"email_no_user_invite_heading_org": "הוזמנת להצטרף לארגון ב-{{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} הזמין/ה אותך להצטרף לארגון שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולארגון שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.",
|
||||
"email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
|
||||
"email_no_user_step_one": "בחר שם משתמש",
|
||||
"email_no_user_step_two": "קשר את לוח השנה שלך",
|
||||
|
@ -1865,6 +1867,8 @@
|
|||
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
|
||||
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
|
||||
"card_details": "פרטי כרטיס",
|
||||
"something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. פנה/י למחלקת התמיכה שלנו, ואנחנו נפתור זאת מיד עבורך.",
|
||||
"please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר",
|
||||
"seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה",
|
||||
"complete_your_booking": "יש להשלים את ההזמנה",
|
||||
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
|
||||
|
@ -1993,16 +1997,46 @@
|
|||
"add_to_team": "הוספה לצוות",
|
||||
"remove_users_from_org": "הסרת משתמשים מהארגון",
|
||||
"remove_users_from_org_confirm": "בטוח שברצונך להסיר {{userCount}} משתמשים מהארגון הזה?",
|
||||
"user_has_no_schedules": "משתמש זה עדיין לא הגדיר לוחות זמנים",
|
||||
"user_isnt_in_any_teams": "משתמש זה לא שייך לאף צוות",
|
||||
"requires_booker_email_verification": "מחייב אימות של כתובת הדוא\"ל של המזמין",
|
||||
"description_requires_booker_email_verification": "כדי להבטיח אימות של כתובת הדוא\"ל של המזמין לפני תזמון אירועים",
|
||||
"requires_confirmation_mandatory": "ניתן לשלוח הודעות טקסט למשתתפים רק כאשר סוג האירוע מחייב אישור.",
|
||||
"organizations": "ארגונים",
|
||||
"org_admin_other_teams": "צוותים אחרים",
|
||||
"org_admin_other_teams_description": "כאן תוכל/י לראות צוותים בארגון שאינך שייך/ת אליהם. יש לך אפשרות להוסיף את עצמך, במקרה הצורך.",
|
||||
"no_other_teams_found": "לא נמצא אף צוות אחר",
|
||||
"no_other_teams_found_description": "אין צוותים אחרים בארגון הזה.",
|
||||
"attendee_first_name_variable": "השם הפרטי של המשתתף",
|
||||
"attendee_last_name_variable": "שם המשפחה של המשתתף",
|
||||
"attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה",
|
||||
"attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה",
|
||||
"me": "אני",
|
||||
"verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים",
|
||||
"member_removed": "החבר הוסר",
|
||||
"my_availability": "הזמינות שלי",
|
||||
"team_availability": "הזמינות של הצוות",
|
||||
"backup_code": "קוד גיבוי",
|
||||
"backup_codes": "קודי גיבוי",
|
||||
"backup_code_instructions": "כל קוד גיבוי יכול לשמש פעם אחת בלבד להענקת גישה בלי היישום המאמת.",
|
||||
"backup_codes_copied": "קודי הגיבוי הועתקו!",
|
||||
"incorrect_backup_code": "קוד הגיבוי שגוי.",
|
||||
"lost_access": "הגישה אבדה",
|
||||
"missing_backup_codes": "לא נמצאו קודי גיבוי. צור/י אותם בהגדרות.",
|
||||
"admin_org_notification_email_subject": "נוצר ארגון חדש: בהמתנה לפעולה",
|
||||
"hi_admin": "שלום, מנהל/ת מערכת",
|
||||
"admin_org_notification_email_title": "ארגון מחייב הגדרת DNS",
|
||||
"admin_org_notification_email_body_part1": "נוצר ארגון עם רכיב ה-slug \"{{orgSlug}}\".<br /><br />חשוב להקפיד להגדיר את רשם ה-DNS כך שיפנה את התת-דומיין המקביל לארגון החדש למיקום שבו האפליקציה הראשית פועלת. אחרת, הארגון לא יוכל לפעול.<br /><br />לפניך פירוט של האפשרויות הבסיסיות ממש להגדרת תת-דומיין כך שיפנה לאפליקציה שלו על מנת שדף הפרופיל של הארגון ייטען.<br /><br />אפשר לעשות את זה עם רשומת A:",
|
||||
"admin_org_notification_email_body_part2": "או רשומת CNAME:",
|
||||
"admin_org_notification_email_body_part3": "לאחר שתגדיר/י את התת-דומיין, יש לסמן שתצורת DNS הושלמה בהגדרות מנהלי המערכת של הארגון.",
|
||||
"admin_org_notification_email_cta": "עבור/י אל הגדרות מנהלי המערכת של הארגון",
|
||||
"org_has_been_processed": "עיבוד הארגון הושלם",
|
||||
"org_error_processing": "היתה שגיאה בעיבוד של ארגון זה",
|
||||
"orgs_page_description": "רשימה של כל הארגונים. קבלת ארגון תאפשר לכל המשתמשים מאותו דומיין דוא\"ל להירשם בלי להצטרך לבצע אימות של כתובת הדוא\"ל.",
|
||||
"unverified": "לא אומת",
|
||||
"dns_missing": "DNS חסר",
|
||||
"mark_dns_configured": "סימון כי DNS הוגדר",
|
||||
"value": "ערך",
|
||||
"your_organization_updated_sucessfully": "עדכון הארגון שלך בוצע בהצלחה",
|
||||
"team_no_event_types": "אין לצוות זה אף סוג של אירוע",
|
||||
"seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים",
|
||||
|
|
|
@ -19,4 +19,4 @@
|
|||
1. Add API documentation links in comments for files `api`, `lib` and `types`
|
||||
2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that.
|
||||
3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509)
|
||||
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin.
|
||||
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))
|
|
@ -1,10 +1,12 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Switch, Badge, Avatar } from "@calcom/ui";
|
||||
import { Switch, Badge, Avatar, Button } from "@calcom/ui";
|
||||
import { Settings } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { CredentialOwner } from "../types";
|
||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||
|
@ -27,6 +29,7 @@ export default function AppCard({
|
|||
teamId?: number;
|
||||
LockedIcon?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
|
||||
|
||||
|
@ -111,8 +114,23 @@ export default function AppCard({
|
|||
</div>
|
||||
<div ref={animationRef}>
|
||||
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
|
||||
|
||||
{app?.isInstalled && switchChecked ? (
|
||||
<div className="p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">{children}</div>
|
||||
app.isSetupAlready ? (
|
||||
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
|
||||
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
|
||||
<Settings className="text-default h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
|
||||
<p>{t("this_app_is_not_setup_already")}</p>
|
||||
<Link href={`/apps/${app.slug}/setup`}>
|
||||
<Button StartIcon={Settings}>{t("setup")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
export const AppSetupPageMap = {
|
||||
alby: import("../../alby/pages/setup/_getServerSideProps"),
|
||||
make: import("../../make/pages/setup/_getServerSideProps"),
|
||||
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
|
||||
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const { slug } = ctx.params || {};
|
||||
if (typeof slug !== "string") return { notFound: true } as const;
|
||||
|
||||
if (!(slug in AppSetupPageMap)) return { props: {} };
|
||||
|
||||
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
|
||||
|
||||
if (!page.getServerSideProps) return { props: {} };
|
||||
|
||||
const props = await page.getServerSideProps(ctx);
|
||||
|
||||
return props;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
export const AppSetupPageMap = {
|
||||
zapier: import("../../zapier/pages/setup/_getStaticProps"),
|
||||
make: import("../../make/pages/setup/_getStaticProps"),
|
||||
};
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
const { slug } = ctx.params || {};
|
||||
if (typeof slug !== "string") return { notFound: true } as const;
|
||||
|
||||
if (!(slug in AppSetupPageMap)) return { props: {} };
|
||||
|
||||
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
|
||||
|
||||
if (!page.getStaticProps) return { props: {} };
|
||||
|
||||
const props = await page.getStaticProps(ctx);
|
||||
|
||||
return props;
|
||||
};
|
|
@ -3,6 +3,7 @@ import dynamic from "next/dynamic";
|
|||
import { DynamicComponent } from "../../_components/DynamicComponent";
|
||||
|
||||
export const AppSetupMap = {
|
||||
alby: dynamic(() => import("../../alby/pages/setup")),
|
||||
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
|
||||
exchange: dynamic(() => import("../../exchangecalendar/pages/setup")),
|
||||
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
|
||||
|
@ -12,6 +13,7 @@ export const AppSetupMap = {
|
|||
make: dynamic(() => import("../../make/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
};
|
||||
|
||||
|
|
|
@ -1,16 +1,42 @@
|
|||
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
|
||||
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const handler: AppDeclarativeHandler = {
|
||||
appType: appConfig.type,
|
||||
variant: appConfig.variant,
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
createCredential: ({ appType, user, slug, teamId }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
|
||||
};
|
||||
import config from "../config.json";
|
||||
|
||||
export default handler;
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const appType = config.type;
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
throw new Error("Already installed");
|
||||
}
|
||||
const installation = await prisma.credential.create({
|
||||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
appId: "alby",
|
||||
},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new Error("Unable to create user credential for Alby");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500);
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: "/apps/alby/setup" });
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook";
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { albyCredentialKeysSchema } from "@calcom/app-store/alby/lib";
|
||||
import parseInvoice from "@calcom/app-store/alby/lib/parseInvoice";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const bodyRaw = await getRawBody(req);
|
||||
const headers = req.headers;
|
||||
const bodyAsString = bodyRaw.toString();
|
||||
|
||||
const parseHeaders = webhookHeadersSchema.safeParse(headers);
|
||||
if (!parseHeaders.success) {
|
||||
console.error(parseHeaders.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedHeaders } = parseHeaders;
|
||||
|
||||
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
|
||||
if (!parse.success) {
|
||||
console.error(parse.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.metadata?.payer_data?.appId !== "cal.com") {
|
||||
throw new HttpCode({ statusCode: 204, message: "Payment not for cal.com" });
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
uid: parsedPayload.metadata.payer_data.referenceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
bookingId: true,
|
||||
booking: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
credentials: {
|
||||
where: {
|
||||
type: "alby_payment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
const key = payment.booking?.user?.credentials?.[0].key;
|
||||
if (!key) throw new HttpCode({ statusCode: 204, message: "Credentials not found" });
|
||||
|
||||
const parseCredentials = albyCredentialKeysSchema.safeParse(key);
|
||||
if (!parseCredentials.success) {
|
||||
console.error(parseCredentials.error);
|
||||
throw new HttpCode({ statusCode: 500, message: "Credentials not valid" });
|
||||
}
|
||||
|
||||
const credentials = parseCredentials.data;
|
||||
|
||||
const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret);
|
||||
if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" });
|
||||
if (albyInvoice.amount !== payment.amount) {
|
||||
throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" });
|
||||
}
|
||||
|
||||
return await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
return res.status(err.statusCode || 500).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payerDataSchema = z
|
||||
.object({
|
||||
appId: z.string().optional(),
|
||||
referenceId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const metadataSchema = z
|
||||
.object({
|
||||
payer_data: payerDataSchema,
|
||||
})
|
||||
.optional();
|
||||
|
||||
const eventSchema = z.object({
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
const webhookHeadersSchema = z
|
||||
.object({
|
||||
"svix-id": z.string(),
|
||||
"svix-timestamp": z.string(),
|
||||
"svix-signature": z.string(),
|
||||
})
|
||||
.passthrough();
|
|
@ -0,0 +1,177 @@
|
|||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
import z from "zod";
|
||||
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { showToast } from "@calcom/ui";
|
||||
import { ClipboardCheck, Clipboard } from "@calcom/ui/components/icon";
|
||||
import { Spinner } from "@calcom/ui/components/icon/Spinner";
|
||||
|
||||
interface IAlbyPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
paymentPageProps: PaymentPageProps;
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentAlbyDataSchema = z.object({
|
||||
invoice: z
|
||||
.object({
|
||||
paymentRequest: z.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const [showQRCode, setShowQRCode] = useState(window.webln === undefined);
|
||||
const [isPaying, setPaying] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentAlbyDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) {
|
||||
return wrongUrl;
|
||||
}
|
||||
const paymentRequest = parsedData.data.invoice.paymentRequest;
|
||||
|
||||
return (
|
||||
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<PaymentChecker {...props.paymentPageProps} />
|
||||
{isPaying && <Spinner className="mt-12 h-8 w-8" />}
|
||||
{!isPaying && (
|
||||
<>
|
||||
{!showQRCode && (
|
||||
<div className="flex gap-4">
|
||||
<Button color="secondary" onClick={() => setShowQRCode(true)}>
|
||||
Show QR
|
||||
</Button>
|
||||
{window.webln && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (!window.webln) {
|
||||
throw new Error("webln not found");
|
||||
}
|
||||
setPaying(true);
|
||||
await window.webln.enable();
|
||||
window.webln.sendPayment(paymentRequest);
|
||||
} catch (error) {
|
||||
setPaying(false);
|
||||
alert((error as Error).message);
|
||||
}
|
||||
}}>
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showQRCode && (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-xs">Waiting for payment...</p>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">Click or scan the invoice below to pay</p>
|
||||
<Link
|
||||
href={`lightning:${paymentRequest}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent p-2
|
||||
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
<QRCode size={128} value={paymentRequest} />
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => copyToClipboard(paymentRequest)}
|
||||
className="text-subtle rounded-md"
|
||||
StartIcon={isCopied ? ClipboardCheck : Clipboard}>
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<Link target="_blank" href="https://getalby.com" className="link mt-4 text-sm underline">
|
||||
Don't have a lightning wallet?
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Link target="_blank" href="https://getalby.com">
|
||||
<div className="mt-4 flex items-center text-sm">
|
||||
Powered by
|
||||
<img title="Alby" src="/app-store/alby/icon.svg" alt="Alby" className="h-8 w-8" />
|
||||
Alby
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PaymentCheckerProps = PaymentPageProps;
|
||||
|
||||
function PaymentChecker(props: PaymentCheckerProps) {
|
||||
// TODO: move booking success code to a common lib function
|
||||
// TODO: subscribe rather than polling
|
||||
const searchParams = useSearchParams();
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
(async () => {
|
||||
if (props.booking.status === "ACCEPTED") {
|
||||
return;
|
||||
}
|
||||
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
|
||||
bookingUid: props.booking.uid,
|
||||
});
|
||||
|
||||
if (bookingResult?.paid) {
|
||||
showToast("Payment successful", "success");
|
||||
|
||||
const params: {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
location: string;
|
||||
} = {
|
||||
uid: props.booking.uid,
|
||||
email: searchParams.get("email"),
|
||||
location: t("web_conferencing_details_to_follow"),
|
||||
};
|
||||
|
||||
bookingSuccessRedirect({
|
||||
successRedirectUrl: props.eventType.successRedirectUrl,
|
||||
query: params,
|
||||
booking: props.booking,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
bookingSuccessRedirect,
|
||||
props.booking,
|
||||
props.booking.id,
|
||||
props.booking.status,
|
||||
props.eventType.id,
|
||||
props.eventType.successRedirectUrl,
|
||||
props.payment.success,
|
||||
searchParams,
|
||||
t,
|
||||
utils.viewer.bookings,
|
||||
]);
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { fiat } from "@getalby/lightning-tools";
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
type AlbyPriceComponentProps = {
|
||||
displaySymbol: boolean;
|
||||
price: number;
|
||||
formattedPrice: string;
|
||||
};
|
||||
|
||||
export function AlbyPriceComponent({ displaySymbol, price, formattedPrice }: AlbyPriceComponentProps) {
|
||||
const [fiatValue, setFiatValue] = React.useState<string>("loading...");
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" });
|
||||
setFiatValue(`$${unformattedFiatValue.toFixed(2)}`);
|
||||
})();
|
||||
}, [price]);
|
||||
|
||||
return (
|
||||
<Tooltip content={fiatValue}>
|
||||
<div className="inline-flex items-center justify-center">
|
||||
{displaySymbol && <SatSymbol className="h-4 w-4" />}
|
||||
{formattedPrice}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Select, TextField } from "@calcom/ui";
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
import type { appDataSchema } from "../zod";
|
||||
import { PaypalPaymentOptions as paymentOptions } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
value: paymentOptions[0].value,
|
||||
};
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
||||
// make sure a currency is selected
|
||||
useEffect(() => {
|
||||
if (!currency && requirePayment) {
|
||||
setAppData("currency", selectedCurrency.value);
|
||||
}
|
||||
}, [currency, selectedCurrency, setAppData, requirePayment]);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
}}
|
||||
description={<>Add bitcoin lightning payments to your events</>}>
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<TextField
|
||||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading={<SatSymbol className="h-4 w-4" />}
|
||||
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
|
||||
type="number"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value));
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
}
|
||||
}}
|
||||
value={price && price > 0 ? price : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
Payment option
|
||||
</label>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
|
@ -4,14 +4,15 @@
|
|||
"slug": "alby",
|
||||
"type": "alby_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://example.com/link",
|
||||
"url": "https://getalby.com",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "Alby",
|
||||
"email": "support@getalby.com",
|
||||
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "alby"
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
import { LightningAddress } from "@getalby/lightning-tools";
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type z from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import { albyCredentialKeysSchema } from "./albyCredentialKeysSchema";
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof albyCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
const keyParsing = albyCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking || !this.credentials?.account_lightning_address) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const lightningAddress = new LightningAddress(this.credentials.account_lightning_address);
|
||||
await lightningAddress.fetch();
|
||||
const invoice = await lightningAddress.requestInvoice({
|
||||
satoshi: payment.amount,
|
||||
payerdata: {
|
||||
appId: "cal.com",
|
||||
referenceId: uid,
|
||||
},
|
||||
});
|
||||
console.log("Created invoice", invoice, uid);
|
||||
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "alby",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
externalId: invoice.paymentRequest,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async refund(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number,
|
||||
_bookerEmail: string,
|
||||
_paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
chargeCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
afterPayment(
|
||||
_event: CalendarEvent,
|
||||
_booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
_paymentData: Payment
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
deletePayment(_paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import z from "zod";
|
||||
|
||||
export const albyCredentialKeysSchema = z.object({
|
||||
account_id: z.string(),
|
||||
account_email: z.string(),
|
||||
account_lightning_address: z.string(),
|
||||
webhook_endpoint_id: z.string(),
|
||||
webhook_endpoint_secret: z.string(),
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export const currencyOptions = [{ label: "BTC", value: "BTC", unit: "sats" }];
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getAlbyKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("alby");
|
||||
return appKeysSchema.parse(appKeys);
|
||||
};
|
||||
|
||||
const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import type { Invoice as AlbyInvoice } from "@getalby/sdk/dist/types";
|
||||
|
||||
export * from "./PaymentService";
|
||||
export * from "./albyCredentialKeysSchema";
|
||||
|
||||
export type { AlbyInvoice };
|
|
@ -0,0 +1,22 @@
|
|||
import type { Invoice } from "@getalby/sdk/dist/types";
|
||||
import { Webhook } from "svix";
|
||||
|
||||
export default function parseInvoice(
|
||||
body: string,
|
||||
headers: {
|
||||
"svix-id": string;
|
||||
"svix-timestamp": string;
|
||||
"svix-signature": string;
|
||||
},
|
||||
webhookEndpointSecret: string
|
||||
): Invoice | null {
|
||||
try {
|
||||
const wh = new Webhook(webhookEndpointSecret);
|
||||
return wh.verify(body, headers) as Invoice;
|
||||
} catch (err) {
|
||||
// Looks like alby might sent multiple webhooks for the same invoice but it should only work once
|
||||
// TODO: remove the Alby webhook when uninstalling the Alby app
|
||||
console.error(err);
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -5,7 +5,12 @@
|
|||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/lib": "*",
|
||||
"@getalby/lightning-tools": "^4.0.2",
|
||||
"@getalby/sdk": "^2.4.0",
|
||||
"@webbtc/webln-types": "^2.0.1",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"svix": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { getAlbyKeys } from "../../lib/getAlbyKeys";
|
||||
import type { IAlbySetupProps } from "./index";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
|
||||
const { req, res } = ctx;
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return res.writeHead(401).end();
|
||||
}
|
||||
|
||||
const credentials = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: "alby_payment",
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { client_id: clientId, client_secret: clientSecret } = await getAlbyKeys();
|
||||
|
||||
const props: IAlbySetupProps = {
|
||||
email: null,
|
||||
lightningAddress: null,
|
||||
clientId,
|
||||
clientSecret,
|
||||
};
|
||||
if (credentials?.key) {
|
||||
const { account_lightning_address, account_email } = credentials.key as {
|
||||
account_lightning_address?: string;
|
||||
account_email?: string;
|
||||
};
|
||||
if (account_lightning_address) {
|
||||
props.lightningAddress = account_lightning_address;
|
||||
}
|
||||
if (account_email) {
|
||||
props.email = account_email;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,178 @@
|
|||
import { auth, Client, webln } from "@getalby/sdk";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Badge, Button, showToast } from "@calcom/ui";
|
||||
import { Info } from "@calcom/ui/components/icon";
|
||||
|
||||
import { albyCredentialKeysSchema } from "../../lib/albyCredentialKeysSchema";
|
||||
|
||||
export interface IAlbySetupProps {
|
||||
email: string | null;
|
||||
lightningAddress: string | null;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export default function AlbySetup(props: IAlbySetupProps) {
|
||||
const params = useSearchParams();
|
||||
if (params?.get("callback") === "true") {
|
||||
return <AlbySetupCallback />;
|
||||
}
|
||||
|
||||
return <AlbySetupPage {...props} />;
|
||||
}
|
||||
|
||||
function AlbySetupCallback() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const params = useSearchParams();
|
||||
useEffect(() => {
|
||||
if (!window.opener) {
|
||||
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const error = params.get("error");
|
||||
|
||||
if (!code) {
|
||||
setError("declined");
|
||||
}
|
||||
if (error) {
|
||||
setError(error);
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener.postMessage({
|
||||
type: "alby:oauth:success",
|
||||
payload: { code },
|
||||
});
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p>Authorization failed: {error}</p>}
|
||||
{!error && <p>Connecting...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbySetupPage(props: IAlbySetupProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "alby" });
|
||||
const [albyPaymentAppCredentials] = integrations.data?.items || [];
|
||||
const [credentialId] = albyPaymentAppCredentials?.userCredentialIds || [-1];
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
router.push("/event-types");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const connectWithAlby = useCallback(async () => {
|
||||
const authClient = new auth.OAuth2User({
|
||||
client_id: props.clientId,
|
||||
client_secret: props.clientSecret,
|
||||
callback: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/apps/alby/setup?callback=true`,
|
||||
scopes: ["invoices:read", "account:read"],
|
||||
user_agent: "cal.com",
|
||||
});
|
||||
|
||||
const weblnOAuthProvider = new webln.OauthWeblnProvider({
|
||||
auth: authClient,
|
||||
});
|
||||
await weblnOAuthProvider.enable();
|
||||
|
||||
const client = new Client(authClient);
|
||||
const accountInfo = await client.accountInformation({});
|
||||
// TODO: add a way to delete the endpoint when the app is uninstalled
|
||||
const webhookEndpoint = await client.createWebhookEndpoint({
|
||||
filter_types: ["invoice.incoming.settled"],
|
||||
url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/alby/webhook`,
|
||||
description: "Cal.com",
|
||||
});
|
||||
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: albyCredentialKeysSchema.parse({
|
||||
account_id: accountInfo.identifier,
|
||||
account_email: accountInfo.email,
|
||||
account_lightning_address: accountInfo.lightning_address,
|
||||
webhook_endpoint_id: webhookEndpoint.id,
|
||||
webhook_endpoint_secret: webhookEndpoint.endpoint_secret,
|
||||
}),
|
||||
});
|
||||
}, [credentialId, props.clientId, props.clientSecret, saveKeysMutation]);
|
||||
|
||||
if (integrations.isLoading) {
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="flex w-full items-center justify-center p-4">
|
||||
<div className="bg-default border-subtle m-auto flex max-w-[43em] flex-col items-center justify-center gap-4 overflow-auto rounded border p-4 md:p-10">
|
||||
{!props.lightningAddress ? (
|
||||
<>
|
||||
<p className="text-default">
|
||||
Create or connect to an existing Alby account to receive lightning payments for your paid
|
||||
bookings.
|
||||
</p>
|
||||
<button
|
||||
className="font-body flex h-10 w-56 items-center justify-center gap-2 rounded-md font-bold text-black shadow transition-all hover:brightness-90 active:scale-95"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #FFDE6E 63.72%, #F8C455 95.24%)",
|
||||
}}
|
||||
type="button"
|
||||
onClick={connectWithAlby}>
|
||||
<img className="h-8 w-8" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
|
||||
<span className="mr-2">Connect with Alby</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img className="h-16 w-16" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
|
||||
<p>Alby Connected!</p>
|
||||
<Badge>Email: {props.email}</Badge>
|
||||
<Badge>Lightning Address: {props.lightningAddress}</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: remove when invoices are generated using user identifier */}
|
||||
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<Info className="mb-0.5 inline-flex h-4 w-4" /> Your Alby lightning address will be used to
|
||||
generate invoices. If you update your lightning address, please disconnect and setup the Alby
|
||||
app again.
|
||||
</div>
|
||||
<Link href="/apps/alby">
|
||||
<Button color="secondary">Go to App Store Listing</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>Alby</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/alby" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="554" height="554" viewBox="0 0 554 554" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M127.7 202.4C107.431 202.4 90.9999 185.969 90.9999 165.7C90.9999 145.431 107.431 129 127.7 129C147.969 129 164.4 145.431 164.4 165.7C164.4 185.969 147.969 202.4 127.7 202.4Z" fill="black"/>
|
||||
<path d="M121.6 160.2L190.1 228.7" stroke="black" stroke-width="18.3"/>
|
||||
<path d="M427.2 202.4C447.469 202.4 463.9 185.969 463.9 165.7C463.9 145.431 447.469 129 427.2 129C406.931 129 390.5 145.431 390.5 165.7C390.5 185.969 406.931 202.4 427.2 202.4Z" fill="black"/>
|
||||
<path d="M434 160.2L365.5 228.7" stroke="black" stroke-width="18.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.1 384.6C128.451 380.122 120.543 372.597 115.592 363.182C110.641 353.768 108.922 342.987 110.7 332.5C127.3 240 196.2 170.6 278.7 170.6C361.4 170.6 430.5 240.3 446.8 333.2C448.539 343.695 446.779 354.47 441.792 363.867C436.805 373.263 428.866 380.759 419.2 385.2C375.441 405.799 327.664 416.454 279.3 416.4C228.8 416.4 180.9 405 138.1 384.6Z" fill="#FFDF6F"/>
|
||||
<path d="M119.8 334.2C135.8 244.7 201.8 179.8 278.8 179.8V161.5C190.6 161.5 118.8 235.3 101.7 330.9L119.7 334.1L119.8 334.2ZM278.8 179.8C355.8 179.8 422 245.1 437.8 334.8L455.8 331.6C439 235.6 367 161.5 278.8 161.5V179.8ZM415.3 376.9C372.76 396.917 326.314 407.265 279.3 407.2V425.5C330.7 425.5 379.4 414 423.1 393.5L415.3 376.9ZM279.3 407.2C230.2 407.2 183.7 396.1 142.1 376.3L134.2 392.9C178.2 413.9 227.4 425.5 279.3 425.5V407.2ZM437.8 334.8C440.8 352.1 431.6 369.3 415.3 376.9L423.1 393.5C446.5 382.5 460.4 357.5 455.8 331.5L437.8 334.8ZM101.7 330.8C99.5558 343.269 101.577 356.098 107.451 367.303C113.325 378.509 122.725 387.47 134.2 392.8L142.1 376.3C134.267 372.688 127.84 366.599 123.81 358.973C119.78 351.347 118.371 342.606 119.8 334.1L101.6 330.9L101.7 330.8Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.4 361.6C157.5 355.1 148.1 338.2 153.6 321.9C170.6 271.7 220.2 235.4 278.7 235.4C337.2 235.4 386.8 271.7 403.8 321.9C409.4 338.2 399.9 355.1 384 361.6C350.559 375.19 314.796 382.153 278.7 382.1C241.5 382.1 205.9 374.8 173.4 361.6Z" fill="black"/>
|
||||
<path d="M320.9 338.1C337.8 338.1 351.5 327.176 351.5 313.7C351.5 300.224 337.8 289.3 320.9 289.3C304 289.3 290.3 300.224 290.3 313.7C290.3 327.176 304 338.1 320.9 338.1Z" fill="white"/>
|
||||
<path d="M233.4 338.1C250.3 338.1 264 327.176 264 313.7C264 300.224 250.3 289.3 233.4 289.3C216.5 289.3 202.8 300.224 202.8 313.7C202.8 327.176 216.5 338.1 233.4 338.1Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
const paymentOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const paymentOptionsSchema = z.array(paymentOptionSchema);
|
||||
|
||||
export const PaypalPaymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
];
|
||||
|
||||
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
PaypalPaymentOptions[0].value,
|
||||
...PaypalPaymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
|
@ -20,6 +20,7 @@ export const AppSettingsComponentsMap = {
|
|||
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
|
||||
};
|
||||
export const EventTypeAddonMap = {
|
||||
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
|
||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
|
@ -35,6 +36,7 @@ import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appKeysSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
|
@ -35,6 +36,7 @@ import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appDataSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
|
|
@ -27,6 +27,10 @@ export const projectHandler = async ({ ctx }: ProjectsHandlerOptions) => {
|
|||
throw new TRPCError({ code: "FORBIDDEN", message: "No credential found for user" });
|
||||
}
|
||||
let credentialKey = credential.key as BasecampToken;
|
||||
if (!credentialKey.account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialKey.expires_at < Date.now()) {
|
||||
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 1.jpg
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
Example questions:
|
||||
|
||||
- Send an email with the title: What's my schedule?
|
||||
- Forward an email from someone looking to meet you
|
||||
-
|
|
@ -18,7 +18,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||
const caller = viewerRouter.createCaller(ctx);
|
||||
|
||||
const apiKey = await caller.apiKeys.create({
|
||||
note: "Cal AI",
|
||||
note: "Cal.ai",
|
||||
expiresAt: null,
|
||||
appId: "cal-ai",
|
||||
});
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Cal AI",
|
||||
"name": "Cal.ai",
|
||||
"slug": "cal-ai",
|
||||
"type": "cal-ai_automation",
|
||||
"logo": "icon.png",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://cal.ai",
|
||||
"variant": "automation",
|
||||
"categories": ["automation"],
|
||||
"publisher": "Rubric Labs",
|
||||
"email": "hi@cal.ai",
|
||||
"description": "Cal AI is a scheduling assistant powered by GPT. Email hi@cal.ai to chat with your calendar or book, edit and cancel meetings.",
|
||||
"publisher": "Cal.com, Inc.",
|
||||
"email": "support@cal.com",
|
||||
"description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you.",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
|
|
|
@ -10,5 +10,5 @@
|
|||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Cal AI is a scheduling assistant powered by GPT. Email hi@cal.ai to chat with your calendar or book, edit and cancel meetings"
|
||||
"description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you."
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
Before Width: | Height: | Size: 158 KiB |
Binary file not shown.
Before Width: | Height: | Size: 274 KiB |
|
@ -0,0 +1,461 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2598_16651)">
|
||||
<rect width="512" height="512" rx="116" fill="url(#paint0_linear_2598_16651)"/>
|
||||
<g opacity="0.8" filter="url(#filter0_f_2598_16651)">
|
||||
<g filter="url(#filter1_f_2598_16651)">
|
||||
<path d="M459.752 -356.036C371.918 -541.357 282.211 -564.038 262.704 -534.284C11.9602 -151.825 113.607 182.645 160.925 501.689C163.832 817.711 296.855 745.291 323.502 677.669C371.853 554.968 594.826 -71.0432 459.752 -356.036Z" fill="url(#paint1_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_2598_16651)">
|
||||
<path d="M386.061 10.9878C291.362 -138.674 274.942 -157.222 266.009 -135.187C200.258 53.5336 83.5663 451.975 142.806 535.978C272.543 715.699 304.478 714.254 327.559 673.264C350.64 632.275 504.435 198.065 386.061 10.9878Z" fill="url(#paint2_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_f_2598_16651)">
|
||||
<path d="M352.81 181.717C284.932 74.4428 273.162 61.1484 266.759 76.9425C219.631 212.213 135.989 497.806 178.451 558.016C271.443 686.836 294.333 685.8 310.877 656.42C327.421 627.04 437.657 315.809 352.81 181.717Z" fill="url(#paint3_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_f_2598_16651)">
|
||||
<path d="M224.077 683.611C109.625 539.5 -152.5 -24.9997 109.625 -175.07C121.524 -61.6035 222.881 395.531 261.837 585.217C282.871 420.872 380.08 89.0844 388.862 11.4926C388.862 11.4926 395.384 -3.03499 403.845 16.7034C693.501 -113 436.5 519.5 302.69 659.209C291.265 671.138 282.325 677.145 275.536 678.084C271.094 706.093 248.08 699.83 224.077 683.611Z" fill="url(#paint4_radial_2598_16651)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g filter="url(#filter5_d_2598_16651)">
|
||||
<circle cx="87" cy="444" r="1" fill="white" fill-opacity="0.78" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<g filter="url(#filter6_d_2598_16651)">
|
||||
<circle cx="106" cy="430" r="1" fill="white" fill-opacity="0.78" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<circle cx="85" cy="417" r="3" fill="white"/>
|
||||
<circle cx="162" cy="397" r="2" fill="white"/>
|
||||
<g filter="url(#filter7_f_2598_16651)">
|
||||
<circle cx="115.786" cy="262.889" r="1.58974" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter8_f_2598_16651)">
|
||||
<circle cx="443.803" cy="37.6751" r="1.58974" transform="rotate(180 443.803 37.6751)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter9_f_2598_16651)">
|
||||
<circle cx="144.59" cy="119.316" r="1.58974" transform="rotate(180 144.59 119.316)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter10_f_2598_16651)">
|
||||
<circle cx="160.487" cy="97.5897" r="1.58974" transform="rotate(180 160.487 97.5897)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter11_f_2598_16651)">
|
||||
<circle cx="58.0255" cy="53.0428" r="1.58974" transform="rotate(180 58.0255 53.0428)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter12_f_2598_16651)">
|
||||
<circle cx="148.111" cy="19.6581" r="1.58974" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter13_f_2598_16651)">
|
||||
<circle cx="411.479" cy="280.906" r="1.58974" transform="rotate(180 411.479 280.906)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter14_f_2598_16651)">
|
||||
<circle cx="75.889" cy="145.914" r="1.58974" transform="rotate(180 75.889 145.914)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<g filter="url(#filter15_f_2598_16651)">
|
||||
<circle cx="70.7435" cy="299.453" r="1.58974" transform="rotate(180 70.7435 299.453)" fill="#D9D9D9"/>
|
||||
</g>
|
||||
<circle cx="87.1709" cy="278.256" r="1.05983" transform="rotate(180 87.1709 278.256)" fill="#D9D9D9"/>
|
||||
<circle cx="26.2307" cy="296.803" r="1.05983" transform="rotate(180 26.2307 296.803)" fill="#D9D9D9"/>
|
||||
<circle cx="231.838" cy="360.393" r="1.05983" transform="rotate(180 231.838 360.393)" fill="#D9D9D9"/>
|
||||
<circle cx="70.0598" cy="130.017" r="1.05983" fill="#D9D9D9"/>
|
||||
<circle cx="1.05983" cy="1.05983" r="1.05983" transform="matrix(1 0 0 -1 98.2991 372.051)" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter16_d_2598_16651)">
|
||||
<circle cx="256" cy="152.667" r="1" fill="white" fill-opacity="0.78" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<circle cx="235" cy="139.667" r="3" fill="white"/>
|
||||
<circle cx="312" cy="119.667" r="2" fill="white"/>
|
||||
<circle cx="381.838" cy="83.0598" r="1.05983" transform="rotate(180 381.838 83.0598)" fill="#D9D9D9"/>
|
||||
<circle cx="1.05983" cy="1.05983" r="1.05983" transform="matrix(1 0 0 -1 248.299 94.718)" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter17_d_2598_16651)">
|
||||
<circle cx="311" cy="451.667" r="1" fill="white" fill-opacity="0.78" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<circle cx="290" cy="438.667" r="3" fill="white"/>
|
||||
<circle cx="367" cy="418.667" r="2" fill="white"/>
|
||||
<circle cx="436.838" cy="382.06" r="1.05983" transform="rotate(180 436.838 382.06)" fill="#D9D9D9"/>
|
||||
<circle cx="1.05983" cy="1.05983" r="1.05983" transform="matrix(1 0 0 -1 303.299 393.718)" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter18_ddiiii_2598_16651)">
|
||||
<path d="M471.721 191.552C471.721 191.553 471.721 191.554 471.722 191.555L474.18 194.555C474.731 195.228 474.056 196.202 473.232 195.922L469.559 194.672C467.869 194.098 466.062 193.963 464.306 194.281C462.55 194.599 460.904 195.359 459.524 196.49L456.518 198.951C455.845 199.502 454.871 198.827 455.152 198.003L456.402 194.329C456.976 192.639 457.111 190.831 456.793 189.075C456.475 187.32 455.714 185.674 454.584 184.293L452.123 181.288C451.572 180.615 452.247 179.641 453.071 179.921L456.745 181.171C458.435 181.746 460.243 181.88 461.998 181.562C463.754 181.244 465.4 180.484 466.781 179.354L469.786 176.893C470.459 176.342 471.433 177.017 471.153 177.84L469.902 181.515C469.328 183.204 469.193 185.012 469.511 186.768C469.829 188.523 470.589 190.169 471.719 191.549C471.72 191.55 471.72 191.551 471.721 191.552V191.552Z" fill="url(#paint5_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter19_ddiiii_2598_16651)">
|
||||
<path d="M420.325 201.047C420.325 201.048 420.325 201.049 420.326 201.049L422.167 203.297C422.58 203.801 422.074 204.531 421.457 204.321L418.705 203.385C417.44 202.954 416.086 202.854 414.771 203.092C413.455 203.33 412.222 203.899 411.188 204.746L408.937 206.59C408.433 207.003 407.703 206.497 407.913 205.88L408.85 203.127C409.28 201.862 409.381 200.508 409.143 199.192C408.904 197.877 408.335 196.644 407.488 195.61L405.645 193.359C405.232 192.855 405.738 192.125 406.355 192.335L409.107 193.272C410.373 193.702 411.727 193.803 413.042 193.564C414.357 193.326 415.59 192.757 416.624 191.91L418.876 190.066C419.38 189.654 420.109 190.16 419.899 190.776L418.963 193.529C418.533 194.794 418.432 196.149 418.67 197.464C418.908 198.779 419.477 200.011 420.324 201.045C420.324 201.046 420.325 201.047 420.325 201.047V201.047Z" fill="url(#paint6_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter20_ddiiii_2598_16651)">
|
||||
<path d="M443.787 222.011C443.786 222.012 443.785 222.013 443.785 222.015L442.347 227.047C442.026 228.17 440.435 228.17 440.114 227.047L438.676 222.012C438.016 219.702 436.778 217.599 435.079 215.9C433.38 214.201 431.277 212.963 428.967 212.303L423.927 210.862C422.804 210.541 422.805 208.95 423.927 208.629L428.964 207.191C431.274 206.53 433.377 205.292 435.076 203.594C436.775 201.895 438.013 199.791 438.673 197.481L440.114 192.441C440.435 191.319 442.026 191.319 442.347 192.442L443.785 197.478C444.446 199.788 445.684 201.892 447.382 203.59C449.081 205.289 451.185 206.527 453.495 207.187L458.535 208.628C459.657 208.949 459.657 210.54 458.535 210.861L453.498 212.3C451.188 212.96 449.084 214.198 447.386 215.897C445.687 217.595 444.45 219.698 443.789 222.007C443.789 222.009 443.788 222.01 443.787 222.011V222.011Z" fill="url(#paint7_linear_2598_16651)"/>
|
||||
</g>
|
||||
<g filter="url(#filter21_f_2598_16651)">
|
||||
<circle cx="441.23" cy="209.745" r="9.27931" fill="#BFACFF"/>
|
||||
</g>
|
||||
<g filter="url(#filter22_f_2598_16651)">
|
||||
<circle cx="463.766" cy="188.535" r="9.27931" fill="#BFACFF"/>
|
||||
</g>
|
||||
<g filter="url(#filter23_f_2598_16651)">
|
||||
<circle cx="412.73" cy="197.152" r="4.63965" fill="#BFACFF"/>
|
||||
</g>
|
||||
<g filter="url(#filter24_ddiiii_2598_16651)">
|
||||
<path d="M101.933 323.303C66.6396 323.303 40 295.815 40 261.879C40 227.773 65.2821 200.116 101.933 200.116C121.276 200.116 134.681 206.054 145.371 218.78L128.572 233.373C121.446 225.907 112.623 222.174 101.933 222.174C79.0261 222.174 64.6034 240.16 64.6034 261.879C64.6034 283.598 79.0261 301.244 102.442 301.244C113.98 301.244 122.634 297.342 129.59 289.706L146.898 304.468C137.905 315.328 122.634 323.303 101.933 323.303Z" fill="url(#paint8_linear_2598_16651)"/>
|
||||
<path d="M190.134 323.303C165.87 323.303 146.526 302.771 146.526 277.32C146.526 251.868 165.87 230.997 190.134 230.997C205.405 230.997 213.38 237.275 218.131 246.777V232.864H240.868V321.097H218.64V306.674C213.889 316.685 205.914 323.303 190.134 323.303ZM169.433 277.15C169.433 290.555 179.274 302.602 193.867 302.602C208.968 302.602 218.64 291.064 218.64 277.32C218.64 263.576 208.968 251.698 193.867 251.698C179.274 251.698 169.433 263.406 169.433 277.15Z" fill="url(#paint9_linear_2598_16651)"/>
|
||||
<path d="M254.478 321.097V197.231H277.385V321.097H254.478Z" fill="url(#paint10_linear_2598_16651)"/>
|
||||
<path d="M303.149 323.303C294.326 323.303 287.878 316.515 287.878 308.371C287.878 300.396 294.326 293.609 303.149 293.609C311.972 293.609 318.251 300.396 318.251 308.371C318.251 316.515 311.972 323.303 303.149 323.303Z" fill="url(#paint11_linear_2598_16651)"/>
|
||||
<path d="M365.612 323.303C341.348 323.303 322.005 302.771 322.005 277.32C322.005 251.868 341.348 230.997 365.612 230.997C380.883 230.997 388.858 237.275 393.609 246.777V232.864H416.346V321.097H394.118V306.674C389.367 316.685 381.392 323.303 365.612 323.303ZM344.911 277.15C344.911 290.555 354.753 302.602 369.345 302.602C384.447 302.602 394.118 291.064 394.118 277.32C394.118 263.576 384.447 251.698 369.345 251.698C354.753 251.698 344.911 263.406 344.911 277.15Z" fill="url(#paint12_linear_2598_16651)"/>
|
||||
<path d="M429.956 321.097V232.864H452.863V321.097H429.956Z" fill="url(#paint13_linear_2598_16651)"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="3" y="3" width="506" height="506" rx="113" stroke="url(#paint14_linear_2598_16651)" stroke-opacity="0.3" stroke-width="6"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_2598_16651" x="-59.0337" y="-601.152" width="651.125" height="1393.45" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="28" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_2598_16651" x="3.55102" y="-641.642" width="595.558" height="1474.43" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="48.245" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_2598_16651" x="65.4873" y="-203.921" width="425.913" height="960.806" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="30.5237" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter3_f_2598_16651" x="95.3067" y="-0.0484848" width="360.731" height="744.13" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="35.7406" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter4_f_2598_16651" x="-66.9637" y="-239" width="666.985" height="1000.36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="31.965" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter5_d_2598_16651" x="80" y="437" width="14" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.35 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2598_16651" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter6_d_2598_16651" x="99" y="423" width="14" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.35 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2598_16651" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter7_f_2598_16651" x="112.607" y="259.709" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter8_f_2598_16651" x="440.624" y="34.4957" width="6.35917" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter9_f_2598_16651" x="141.41" y="116.137" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter10_f_2598_16651" x="157.308" y="94.4103" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter11_f_2598_16651" x="54.846" y="49.8634" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter12_f_2598_16651" x="144.931" y="16.4786" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter13_f_2598_16651" x="408.299" y="277.726" width="6.35917" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter14_f_2598_16651" x="72.7096" y="142.735" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter15_f_2598_16651" x="67.5641" y="296.274" width="6.35893" height="6.35893" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.794872" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter16_d_2598_16651" x="249" y="145.667" width="14" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.35 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2598_16651" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter17_d_2598_16651" x="304" y="444.667" width="14" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.35 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2598_16651" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter18_ddiiii_2598_16651" x="433.678" y="164.584" width="58.9478" height="61.1052" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="8.29324"/>
|
||||
<feGaussianBlur stdDeviation="9.12257"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="3.45947"/>
|
||||
<feGaussianBlur stdDeviation="7.7838"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2598_16651" result="effect2_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2598_16651" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-3.3173"/>
|
||||
<feGaussianBlur stdDeviation="5.80527"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-6.05407"/>
|
||||
<feGaussianBlur stdDeviation="12.1081"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.6775 0 0 0 0 0.625 0 0 0 0 1 0 0 0 0.66 0"/>
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2598_16651" result="effect4_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="3.45947" operator="erode" in="SourceAlpha" result="effect5_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.1892"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2598_16651" result="effect5_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="3.45947" operator="erode" in="SourceAlpha" result="effect6_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3.8919"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_innerShadow_2598_16651" result="effect6_innerShadow_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter19_ddiiii_2598_16651" x="391.828" y="180.847" width="44.1563" height="45.7724" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="6.21227"/>
|
||||
<feGaussianBlur stdDeviation="6.8335"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2.5914"/>
|
||||
<feGaussianBlur stdDeviation="5.83066"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2598_16651" result="effect2_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2598_16651" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-2.48491"/>
|
||||
<feGaussianBlur stdDeviation="4.34859"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-4.53496"/>
|
||||
<feGaussianBlur stdDeviation="9.06991"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.6775 0 0 0 0 0.625 0 0 0 0 1 0 0 0 0.66 0"/>
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2598_16651" result="effect4_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="2.5914" operator="erode" in="SourceAlpha" result="effect5_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="3.88711"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2598_16651" result="effect5_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="2.5914" operator="erode" in="SourceAlpha" result="effect6_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2.91533"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_innerShadow_2598_16651" result="effect6_innerShadow_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter20_ddiiii_2598_16651" x="398.593" y="175.345" width="85.2761" height="88.1691" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="11.1329"/>
|
||||
<feGaussianBlur stdDeviation="12.2461"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.64399"/>
|
||||
<feGaussianBlur stdDeviation="10.449"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2598_16651" result="effect2_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2598_16651" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-4.45314"/>
|
||||
<feGaussianBlur stdDeviation="7.793"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-8.12698"/>
|
||||
<feGaussianBlur stdDeviation="16.254"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.6775 0 0 0 0 0.625 0 0 0 0 1 0 0 0 0.66 0"/>
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2598_16651" result="effect4_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4.64399" operator="erode" in="SourceAlpha" result="effect5_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6.96599"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2598_16651" result="effect5_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4.64399" operator="erode" in="SourceAlpha" result="effect6_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.22449"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_innerShadow_2598_16651" result="effect6_innerShadow_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter21_f_2598_16651" x="417.951" y="186.466" width="46.5586" height="46.5586" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter22_f_2598_16651" x="440.487" y="165.256" width="46.5586" height="46.5586" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter23_f_2598_16651" x="394.09" y="178.512" width="37.2793" height="37.2793" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7" result="effect1_foregroundBlur_2598_16651"/>
|
||||
</filter>
|
||||
<filter id="filter24_ddiiii_2598_16651" x="18.9041" y="183.231" width="455.055" height="170.756" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="9.58904"/>
|
||||
<feGaussianBlur stdDeviation="10.5479"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="9"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2598_16651" result="effect2_dropShadow_2598_16651"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2598_16651" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-3.83562"/>
|
||||
<feGaussianBlur stdDeviation="6.71233"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-7"/>
|
||||
<feGaussianBlur stdDeviation="23.5"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.6775 0 0 0 0 0.625 0 0 0 0 1 0 0 0 0.5 0"/>
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2598_16651" result="effect4_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect5_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2598_16651" result="effect5_innerShadow_2598_16651"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect6_innerShadow_2598_16651"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.5"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="effect5_innerShadow_2598_16651" result="effect6_innerShadow_2598_16651"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2598_16651" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3E3C77"/>
|
||||
<stop offset="1" stop-color="#53489C"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2598_16651" x1="312.663" y1="728.181" x2="121.664" y2="-496.004" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0006F7"/>
|
||||
<stop offset="0.445833" stop-color="#5B5D9D" stop-opacity="0.24"/>
|
||||
<stop offset="1" stop-color="#5B5EEE" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2598_16651" x1="318.863" y1="705.437" x2="259.753" y2="-200.23" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#721EF4"/>
|
||||
<stop offset="1" stop-color="#2E0CFF" stop-opacity="0.03"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2598_16651" x1="304.644" y1="679.481" x2="262.275" y2="30.3213" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.482292" stop-color="#7F30FF"/>
|
||||
<stop offset="1" stop-color="#9D9FFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint4_radial_2598_16651" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(205.161 699.575) rotate(-52.9732) scale(816.922 1694.58)">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.314134" stop-color="#9B8DFF"/>
|
||||
<stop offset="1" stop-color="#3518FF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint5_linear_2598_16651" x1="450.206" y1="178.946" x2="476.098" y2="196.897" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_2598_16651" x1="404.209" y1="191.605" x2="423.604" y2="205.051" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_2598_16651" x1="441.231" y1="188.535" x2="441.231" y2="230.955" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint10_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint11_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint12_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint13_linear_2598_16651" x1="247.534" y1="197.231" x2="247.534" y2="323.303" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E3E0F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint14_linear_2598_16651" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#D8CEFF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2598_16651">
|
||||
<rect width="512" height="512" rx="116" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 34 KiB |
|
@ -298,6 +298,11 @@ export default class GoogleCalendarService implements Calendar {
|
|||
conferenceDataVersion: 1,
|
||||
});
|
||||
|
||||
this.log.debug("Updated Google Calendar Event", {
|
||||
startTime: evt?.data.start,
|
||||
endTime: evt?.data.end,
|
||||
});
|
||||
|
||||
if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) {
|
||||
calendar.events.patch({
|
||||
// Update the same event but this time we know the hangout link
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const appStore = {
|
||||
// example: () => import("./example"),
|
||||
alby: () => import("./alby"),
|
||||
applecalendar: () => import("./applecalendar"),
|
||||
aroundvideo: () => import("./around"),
|
||||
caldavcalendar: () => import("./caldavcalendar"),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
|
@ -6,7 +6,7 @@ export interface IMakeSetupProps {
|
|||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let inviteLink = "";
|
||||
const appKeys = await getAppKeysFromSlug("make");
|
|
@ -1,4 +1,4 @@
|
|||
import type { InferGetStaticPropsType } from "next";
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
@ -9,11 +9,11 @@ import { trpc } from "@calcom/trpc/react";
|
|||
import { Button, Tooltip, showToast } from "@calcom/ui";
|
||||
import { Clipboard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getStaticProps } from "./_getStaticProps";
|
||||
import type { getServerSideProps } from "./_getServerSideProps";
|
||||
|
||||
const MAKE = "make";
|
||||
|
||||
export default function MakeSetup({ inviteLink }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function MakeSetup({ inviteLink }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { findPaymentCredentials } from "@calcom/app-store/paypal/api/webhook";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -25,19 +16,7 @@ export const config = {
|
|||
},
|
||||
};
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentSuccess(
|
||||
export async function handlePaypalPaymentSuccess(
|
||||
payload: z.infer<typeof eventSchema>,
|
||||
rawPayload: string,
|
||||
webhookHeaders: WebHookHeadersType
|
||||
|
@ -49,7 +28,6 @@ export async function handlePaymentSuccess(
|
|||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -60,31 +38,7 @@ export async function handlePaymentSuccess(
|
|||
id: payment.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -108,106 +62,7 @@ export async function handlePaymentSuccess(
|
|||
},
|
||||
});
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar
|
||||
? [booking.destinationCalendar]
|
||||
: user.destinationCalendar
|
||||
? [user.destinationCalendar]
|
||||
: [],
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
if (!payment?.success) {
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status === "PENDING") {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
return await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
@ -234,7 +89,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
|
||||
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
return await handlePaypalPaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
|
@ -25,6 +25,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
|
@ -55,7 +56,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading="$"
|
||||
addOnSuffix={currency || "No selected currency"}
|
||||
addOnSuffix={selectedCurrency.value || "No selected currency"}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
|
@ -64,8 +65,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
if (selectedCurrency) {
|
||||
setAppData("currency", selectedCurrency.value);
|
||||
}
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
|
|
|
@ -17,10 +17,15 @@ export const paypalCredentialKeysSchema = z.object({
|
|||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
|
||||
const keyParsing = paypalCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
|
@ -37,7 +42,7 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
if (!booking || !this.credentials) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
@ -113,7 +118,7 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
if (!booking || !this.credentials) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
@ -192,4 +197,8 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
deletePayment(paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
|
|||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({});
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useState } from "react";
|
|||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -11,9 +10,9 @@ import { Alert, Select, TextField } from "@calcom/ui";
|
|||
|
||||
import { paymentOptions } from "../lib/constants";
|
||||
import {
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
convertToSmallestCurrencyUnit,
|
||||
} from "../lib/currencyConvertions";
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
} from "../lib/currencyConversions";
|
||||
import { currencyOptions } from "../lib/currencyOptions";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
|
@ -26,13 +25,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
label: "",
|
||||
value: "",
|
||||
}
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
|
||||
const { enabled: requirePayment, updateEnabled: setRequirePayment } = useIsAppEnabled(app);
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
|
|
@ -14,7 +14,7 @@ import { createPaymentLink } from "./client";
|
|||
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
export const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
default_currency: z.string(),
|
||||
stripe_publishable_key: z.string(),
|
||||
|
@ -28,11 +28,15 @@ const stripeAppKeysSchema = z.object({
|
|||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private stripe: Stripe;
|
||||
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
|
||||
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
// parse credentials key
|
||||
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
|
||||
const keyParsing = stripeCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
@ -63,15 +67,9 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
|
@ -142,21 +140,15 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
try {
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
this.credentials.stripe_user_id
|
||||
|
@ -214,6 +206,10 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
|
||||
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
|
||||
try {
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
|
@ -385,4 +381,8 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
const targetUrl = "https://dashboard.stripe.com/settings/connect";
|
||||
return {
|
||||
redirect: {
|
||||
destination: targetUrl,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export default function StripePaymentSetup() {
|
||||
return null;
|
||||
}
|
|
@ -17,6 +17,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
|
|||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: paymentOptionEnum.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
|
@ -6,7 +6,7 @@ export interface IZapierSetupProps {
|
|||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let inviteLink = "";
|
||||
const appKeys = await getAppKeysFromSlug("zapier");
|
|
@ -1,16 +1,14 @@
|
|||
{
|
||||
"name": "Zoho Calendar",
|
||||
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
|
||||
"slug": "zohocalendar",
|
||||
"type": "zoho_calendar",
|
||||
"title": "Zoho Calendar",
|
||||
"variant": "calendar",
|
||||
"category": "calendar",
|
||||
"categories": [
|
||||
"calendar"
|
||||
],
|
||||
"logo": "icon.svg",
|
||||
"publisher": "Cal.com",
|
||||
"url": "https://cal.com/",
|
||||
"email": "help@cal.com"
|
||||
}
|
||||
"name": "Zoho Calendar",
|
||||
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
|
||||
"slug": "zohocalendar",
|
||||
"type": "zoho_calendar",
|
||||
"title": "Zoho Calendar",
|
||||
"variant": "calendar",
|
||||
"category": "calendar",
|
||||
"categories": ["calendar"],
|
||||
"logo": "icon.svg",
|
||||
"publisher": "Cal.com",
|
||||
"url": "https://cal.com/",
|
||||
"email": "help@cal.com"
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import getApps from "@calcom/app-store/utils";
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { getUid } from "@calcom/lib/CalEventParser";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
|
@ -225,6 +226,12 @@ export const createEvent = async (
|
|||
let success = true;
|
||||
let calError: string | undefined = undefined;
|
||||
|
||||
log.debug(
|
||||
"Creating calendar event",
|
||||
JSON.stringify({
|
||||
calEvent: getPiiFreeCalendarEvent(calEvent),
|
||||
})
|
||||
);
|
||||
// Check if the disabledNotes flag is set to true
|
||||
if (calEvent.hideCalendarNotes) {
|
||||
calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
|
||||
|
@ -280,9 +287,19 @@ export const updateEvent = async (
|
|||
let success = false;
|
||||
let calError: string | undefined = undefined;
|
||||
let calWarnings: string[] | undefined = [];
|
||||
|
||||
log.debug(
|
||||
"Updating calendar event",
|
||||
JSON.stringify({
|
||||
bookingRefUid,
|
||||
calEvent: getPiiFreeCalendarEvent(calEvent),
|
||||
})
|
||||
);
|
||||
if (bookingRefUid === "") {
|
||||
log.error("updateEvent failed", "bookingRefUid is empty", calEvent, credential);
|
||||
log.error(
|
||||
"updateEvent failed",
|
||||
"bookingRefUid is empty",
|
||||
JSON.stringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
|
||||
);
|
||||
}
|
||||
const updatedResult: NewCalendarEventType | NewCalendarEventType[] | undefined =
|
||||
calendar && bookingRefUid
|
||||
|
@ -296,7 +313,10 @@ export const updateEvent = async (
|
|||
// @TODO: This code will be off till we can investigate an error with it
|
||||
// @see https://github.com/calcom/cal.com/issues/3949
|
||||
// await sendBrokenIntegrationEmail(calEvent, "calendar");
|
||||
log.error("updateEvent failed", e, calEvent);
|
||||
log.error(
|
||||
"updateEvent failed",
|
||||
JSON.stringify({ e, calEvent: getPiiFreeCalendarEvent(calEvent) })
|
||||
);
|
||||
if (e?.calError) {
|
||||
calError = e.calError;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { subtract } from "@calcom/lib/date-ranges";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeBooking } from "@calcom/lib/piiFreeData";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { SelectedCalendar } from "@calcom/prisma/client";
|
||||
|
@ -89,7 +90,6 @@ export async function getBusyTimes(params: {
|
|||
in: [BookingStatus.ACCEPTED],
|
||||
},
|
||||
};
|
||||
|
||||
// INFO: Refactored to allow this method to take in a list of current bookings for the user.
|
||||
// Will keep support for retrieving a user's bookings if the caller does not already supply them.
|
||||
// This function is called from multiple places but we aren't refactoring all of them at this moment
|
||||
|
@ -179,7 +179,13 @@ export async function getBusyTimes(params: {
|
|||
[]
|
||||
);
|
||||
|
||||
logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`);
|
||||
logger.debug(
|
||||
`Busy Time from Cal Bookings ${JSON.stringify({
|
||||
busyTimes,
|
||||
bookings: bookings?.map((booking) => getPiiFreeBooking(booking)),
|
||||
numCredentials: credentials?.length,
|
||||
})}`
|
||||
);
|
||||
performance.mark("prismaBookingGetEnd");
|
||||
performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");
|
||||
if (credentials?.length > 0) {
|
||||
|
@ -195,7 +201,10 @@ export async function getBusyTimes(params: {
|
|||
logger.debug(
|
||||
`Connected Calendars get took ${
|
||||
endConnectedCalendarsGet - startConnectedCalendarsGet
|
||||
} ms for user ${username}`
|
||||
} ms for user ${username}`,
|
||||
JSON.stringify({
|
||||
calendarBusyTimes,
|
||||
})
|
||||
);
|
||||
|
||||
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
|
||||
|
@ -241,6 +250,12 @@ export async function getBusyTimes(params: {
|
|||
busyTimes.push(...videoBusyTimes);
|
||||
*/
|
||||
}
|
||||
logger.debug(
|
||||
"getBusyTimes:",
|
||||
JSON.stringify({
|
||||
allBusyTimes: busyTimes,
|
||||
})
|
||||
);
|
||||
return busyTimes;
|
||||
}
|
||||
|
||||
|
|
|
@ -244,7 +244,6 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||
const workingHours = getWorkingHours({ timeZone }, availability);
|
||||
|
||||
const endGetWorkingHours = performance.now();
|
||||
logger.debug(`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`);
|
||||
|
||||
const dateOverrides = availability
|
||||
.filter((availability) => !!availability.date)
|
||||
|
@ -269,10 +268,23 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||
end: dayjs(busy.end),
|
||||
}));
|
||||
|
||||
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
||||
|
||||
logger.debug(
|
||||
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
|
||||
JSON.stringify({
|
||||
workingHoursInUtc: workingHours,
|
||||
dateOverrides,
|
||||
dateRangesAsPerAvailability: dateRanges,
|
||||
dateRangesInWhichUserIsAvailable,
|
||||
detailedBusyTimes,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
busy: detailedBusyTimes,
|
||||
timeZone,
|
||||
dateRanges: subtract(dateRanges, formattedBusyTimes),
|
||||
dateRanges: dateRangesInWhichUserIsAvailable,
|
||||
workingHours,
|
||||
dateOverrides,
|
||||
currentSeats,
|
||||
|
|
|
@ -6,6 +6,7 @@ import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKey
|
|||
import { sendBrokenIntegrationEmail } from "@calcom/emails";
|
||||
import { getUid } from "@calcom/lib/CalEventParser";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
@ -95,7 +96,7 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv
|
|||
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
|
||||
} catch (err) {
|
||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||
console.error("createMeeting failed", err, calEvent);
|
||||
log.error("createMeeting failed", JSON.stringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
|
||||
|
||||
// Default to calVideo
|
||||
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { formatPrice } from "@calcom/lib/price";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
|
@ -87,11 +88,12 @@ export const BaseScheduledEmail = (
|
|||
<UserFieldsResponses calEvent={props.calEvent} />
|
||||
{props.calEvent.paymentInfo?.amount && (
|
||||
<Info
|
||||
label={props.calEvent.paymentInfo?.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
description={new Intl.NumberFormat(props.attendee.language.locale, {
|
||||
style: "currency",
|
||||
currency: props.calEvent.paymentInfo?.currency || "USD",
|
||||
}).format(props.calEvent.paymentInfo?.amount / 100.0)}
|
||||
label={props.calEvent.paymentInfo.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
description={formatPrice(
|
||||
props.calEvent.paymentInfo.amount,
|
||||
props.calEvent.paymentInfo.currency,
|
||||
props.attendee.language.locale
|
||||
)}
|
||||
withSpacer
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -4,14 +4,15 @@ import React from "react";
|
|||
import classNames from "@calcom/lib/classNames";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Clock, CheckSquare, RefreshCcw, CreditCard } from "@calcom/ui/components/icon";
|
||||
import { Clock, CheckSquare, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PublicEvent } from "../../types";
|
||||
import { EventDetailBlocks } from "../../types";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
import { EventDuration } from "./Duration";
|
||||
import { EventOccurences } from "./Occurences";
|
||||
import { EventPrice } from "./Price";
|
||||
import { Price } from "./Price";
|
||||
import { getPriceIcon } from "./getPriceIcon";
|
||||
|
||||
type EventDetailsPropsBase = {
|
||||
event: PublicEvent;
|
||||
|
@ -156,8 +157,12 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
|||
if (event.price <= 0 || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock key={block} icon={CreditCard}>
|
||||
<EventPrice event={event} />
|
||||
<EventMetaBlock key={block} icon={getPriceIcon(event.currency)}>
|
||||
<Price
|
||||
price={paymentAppData.price}
|
||||
currency={event.currency}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</EventMetaBlock>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,28 @@
|
|||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { PublicEvent } from "../../types";
|
||||
import { formatPrice } from "@calcom/lib/price";
|
||||
|
||||
export const EventPrice = ({ event }: { event: PublicEvent }) => {
|
||||
const stripeAppData = getPaymentAppData(event);
|
||||
import type { EventPrice } from "../../types";
|
||||
|
||||
if (stripeAppData.price === 0) return null;
|
||||
const AlbyPriceComponent = dynamic(
|
||||
() => import("@calcom/app-store/alby/components/AlbyPriceComponent").then((m) => m.AlbyPriceComponent),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: stripeAppData.currency.toUpperCase(),
|
||||
}).format(stripeAppData.price / 100.0)}
|
||||
</>
|
||||
export const Price = ({ price, currency, displayAlternateSymbol = true }: EventPrice) => {
|
||||
if (price === 0) return null;
|
||||
|
||||
const formattedPrice = formatPrice(price, currency);
|
||||
|
||||
return currency !== "BTC" ? (
|
||||
<>{formattedPrice}</>
|
||||
) : (
|
||||
<AlbyPriceComponent
|
||||
displaySymbol={displayAlternateSymbol}
|
||||
price={price}
|
||||
formattedPrice={formattedPrice}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { CreditCard, Zap } from "lucide-react";
|
||||
|
||||
export function getPayIcon(currency: string): React.FC<{ className: string }> | string {
|
||||
return currency !== "BTC" ? CreditCard : Zap;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user