Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3f676e57 | ||
|
|
3e17b1c5ef | ||
|
|
a466e71d4c | ||
|
|
5a7030d277 | ||
|
|
e72c25f02e | ||
|
|
06bc5facf3 | ||
|
|
9c8dc1b92c | ||
|
|
cfef92a7bc | ||
|
|
26149e2b04 | ||
|
|
3ca43ce3f3 | ||
|
|
7603ea6184 | ||
|
|
9c45da565e | ||
|
|
f84f1f5dda | ||
|
|
07f803e758 | ||
|
|
268ab882cd | ||
|
|
8052e6f6d4 | ||
|
|
99e9425257 | ||
|
|
471b2687c4 | ||
|
|
9f7152c4eb | ||
|
|
e819a31dd6 | ||
|
|
8356cb7d34 | ||
|
|
ceb18167cf | ||
|
|
4d5697f5a5 | ||
|
|
afe180a0ec | ||
|
|
851a388853 | ||
|
|
07e20144df | ||
|
|
1535e48ce5 | ||
|
|
0dce391f46 | ||
|
|
cfe7ea45ec | ||
|
|
c244f0fc8e | ||
|
|
5301af2e9f | ||
|
|
a4e7ca91b5 | ||
|
|
64e6e1a19a | ||
|
|
a6daf17909 | ||
|
|
c7dfa7bc89 | ||
|
|
633d85e81b | ||
|
|
49a61207d5 | ||
|
|
f3eb490e3b | ||
|
|
2de5f57437 | ||
|
|
8bdd835a68 | ||
|
|
9c7d0ea604 | ||
|
|
a3aa53fe6e | ||
|
|
57384eb921 | ||
|
|
a153f9627d | ||
|
|
a54539d211 | ||
|
|
8d736cfb67 | ||
|
|
3c3564f884 | ||
|
|
cc75899b01 | ||
|
|
1a818ab0c3 | ||
|
|
7dafaf1381 | ||
|
|
74f354abd2 | ||
|
|
f281131bf6 | ||
|
|
22b4bcdeeb | ||
|
|
5f59ef2acf | ||
|
|
d6d4e246f9 | ||
|
|
a49c34e733 | ||
|
|
37ce8860b5 | ||
|
|
4dfbc46247 | ||
|
|
4c38ea3329 | ||
|
|
24e60498f4 | ||
|
|
fa9a8cca89 | ||
|
|
38bb01a9f0 | ||
|
|
3cf6fef159 | ||
|
|
26dac453e9 | ||
|
|
52db023f76 | ||
|
|
df9e02d138 | ||
|
|
d7be40f67b | ||
|
|
68d2a97e92 | ||
|
|
cde9b24a9b | ||
|
|
cf89598279 | ||
|
|
fcd892bfa0 | ||
|
|
bee5011ec1 |
@@ -51,9 +51,9 @@
|
||||
# Scheduling infrastructure for absolutely everyone
|
||||
|
||||
The open source Calendly alternative. You are in charge
|
||||
of your own data, workflow and appearance.
|
||||
of your own data, workflow, and appearance.
|
||||
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations.
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes, and even calls with our families. However, most tools are very limited in terms of control and customization.
|
||||
|
||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||
|
||||
@@ -98,7 +98,7 @@ Meet our sales team for any commercial inquiries.
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
Cal.com officially launched as v.1.0 on 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
Cal.com officially launched as v.1.0 on the 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
|
||||

|
||||
|
||||
@@ -128,7 +128,7 @@ Here is what you need to be able to run Cal.com.
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
> If you are on windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||
|
||||
1. Go to the project folder
|
||||
@@ -254,7 +254,7 @@ Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
# To open the last HTML report run:
|
||||
yarn playwright show-report test-results/reports/playwright-html-report
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
|
||||
@@ -17,7 +18,7 @@ export const dateNotInPast = function (date: Date) {
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && process.env.NODE_ENV !== "development")
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||
if (isCustomPrisma && isAdmin) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
// If user is not ADMIN, return only his data.
|
||||
if (!isAdmin) where.id = userId;
|
||||
const [total, data] = await prisma.$transaction([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
const users = schemaUsersReadPublic.parse(data);
|
||||
|
||||
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{team.name} {isInstalled && `(${t("installed")})`}
|
||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
@@ -12,7 +12,7 @@ type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -22,7 +22,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
@@ -38,7 +38,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
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";
|
||||
@@ -462,7 +463,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
defaultValue={eventType.offsetStart}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
@@ -477,11 +477,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
label: `Per ${intervalLimitKeyToUnit(key)}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
@@ -590,8 +588,8 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -99,6 +101,7 @@ const CheckedHostField = ({
|
||||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
...rest
|
||||
}: {
|
||||
labelText: string;
|
||||
@@ -107,6 +110,7 @@ const CheckedHostField = ({
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
helperText?: React.ReactNode | string;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
@@ -136,11 +140,24 @@ const CheckedHostField = ({
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FixedHostHelper = (
|
||||
<Trans i18nKey="fixed_host_helper">
|
||||
Add anyone who needs to attend the event.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
@@ -167,6 +184,7 @@ const RoundRobinHosts = ({
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
helperText={FixedHostHelper}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
@@ -175,6 +193,7 @@ const RoundRobinHosts = ({
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
helperText={t("round_robin_helper")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
@@ -247,7 +247,7 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isPublicPage = usePublicPage();
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const { pageProps, ...rest } = props;
|
||||
const { _nonce, ...restPageProps } = pageProps;
|
||||
const propsWithoutNonce = {
|
||||
@@ -267,7 +267,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
themeBasis={props.pageProps.themeBasis}
|
||||
nonce={props.pageProps.nonce}
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
@@ -281,7 +281,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
</EventCollectionProvider>
|
||||
);
|
||||
|
||||
if (isPublicPage) {
|
||||
if (isBookingPage) {
|
||||
return RemainingProviders;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function useIsBookingPage() {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const userParam = searchParams.get("user");
|
||||
const teamParam = searchParams.get("team");
|
||||
|
||||
return !!(isBookingPage || userParam || teamParam);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function usePublicPage() {
|
||||
const pathname = usePathname();
|
||||
const isPublicPage = ["/[user]", "/booking", "/cancel", "/reschedule"].find((route) =>
|
||||
pathname?.startsWith(route)
|
||||
);
|
||||
return isPublicPage;
|
||||
}
|
||||
@@ -25,8 +25,10 @@ if (!process.env.NEXTAUTH_URL && process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||
if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
}
|
||||
|
||||
if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") {
|
||||
if (
|
||||
process.env.CSP_POLICY === "strict" &&
|
||||
(process.env.CALCOM_ENV === "production" || process.env.NODE_ENV === "production")
|
||||
) {
|
||||
throw new Error(
|
||||
"Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode"
|
||||
);
|
||||
@@ -127,7 +129,10 @@ const matcherConfigUserTypeEmbedRoute = {
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
i18n: {
|
||||
...i18n,
|
||||
localeDetection: false,
|
||||
},
|
||||
productionBrowserSourceMaps: true,
|
||||
/* We already do type check on GH actions */
|
||||
typescript: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.1.7",
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Head from "next/head";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { Copy } from "@calcom/ui/components/icon";
|
||||
@@ -21,14 +21,11 @@ export default function Error500() {
|
||||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="font-cal text-emphasis text-6xl">500</h1>
|
||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">It's not you, it's us.</h2>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||
away for you.
|
||||
</p>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
|
||||
{searchParams?.get("error") && (
|
||||
<div className="mb-8 flex flex-col">
|
||||
<p className="text-default mb-4 max-w-2xl text-sm">
|
||||
Please provide the following text when contacting support to better help you:
|
||||
{t("please_provide_following_text_to_suppport")}:
|
||||
</p>
|
||||
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
|
||||
{searchParams?.get("error")}
|
||||
@@ -46,9 +43,9 @@ export default function Error500() {
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,11 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import {
|
||||
getBookingForReschedule,
|
||||
getBookingForSeatedEvent,
|
||||
getMultipleDurationValue,
|
||||
} from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -28,6 +32,7 @@ export default function Type({
|
||||
isBrandingHidden,
|
||||
rescheduleUid,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -45,6 +50,7 @@ export default function Type({
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -55,7 +61,7 @@ Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
@@ -111,6 +117,11 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
@@ -127,7 +138,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
@@ -177,6 +188,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
@@ -49,7 +51,7 @@ class MyDocument extends Document<Props> {
|
||||
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
|
||||
{(process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview") && (
|
||||
{(!IS_PRODUCTION || process.env.VERCEL_ENV === "preview") && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
<script
|
||||
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { renderEmail } from "@calcom/emails";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (process.env.NODE_ENV !== "development") return res.write("Only for development purposes"), res.end();
|
||||
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
|
||||
const t = await getTranslation("en", "common");
|
||||
const language = { translate: t, locale: "en" };
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
|
||||
|
||||
export default createNextApiHandler(kycVerificationRouter);
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -38,7 +39,7 @@ const sourceSchema = z.object({
|
||||
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
// If it's not production environment, it would be a better idea to inform that the App is disabled.
|
||||
if (props.isAppDisabled) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (!IS_PRODUCTION) {
|
||||
// TODO: Improve disabled App UI. This is just a placeholder.
|
||||
return (
|
||||
<div className="p-2">
|
||||
@@ -123,7 +124,7 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
const isAppAvailableInFileSystem = appMeta;
|
||||
const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled);
|
||||
|
||||
if (process.env.NODE_ENV !== "production" && isAppDisabled) {
|
||||
if (!IS_PRODUCTION && isAppDisabled) {
|
||||
return {
|
||||
props: {
|
||||
isAppDisabled: true as const,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
@@ -94,6 +95,7 @@ export default function Apps({
|
||||
}
|
||||
|
||||
Apps.PageWrapper = PageWrapper;
|
||||
Apps.getLayout = getLayout;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
@@ -74,7 +74,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
<DateOverrideInputDialog
|
||||
workingHours={workingHours}
|
||||
excludedDates={excludedDates}
|
||||
onChange={(ranges) => append({ ranges })}
|
||||
onChange={(ranges) => ranges.forEach((range) => append({ ranges: [range] }))}
|
||||
Trigger={
|
||||
<Button color="secondary" StartIcon={Plus} data-testid="add-override">
|
||||
{t("add_an_override")}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -130,15 +131,17 @@ export default function AvailabilityPage() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("availability")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<NewScheduleButton />}>
|
||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AvailabilityPage.getLayout = getLayout;
|
||||
|
||||
AvailabilityPage.PageWrapper = PageWrapper;
|
||||
|
||||
@@ -314,7 +314,7 @@ export default function Success(props: SuccessProps) {
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
<ChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
<ChevronLeft className="h-5 w-5 rtl:rotate-180" /> {t("back_to_bookings")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,7 +394,7 @@ export default function Success(props: SuccessProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
@@ -715,7 +715,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
{isGmail && (
|
||||
<Alert
|
||||
className="main -mb-20 mt-4 inline-block text-left sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
className="main -mb-20 mt-4 inline-block ltr:text-right rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
severity="warning"
|
||||
message={
|
||||
<div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -32,6 +37,28 @@ type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
|
||||
@@ -112,90 +139,101 @@ export default function Bookings() {
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<div className="mb-6 pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
|
||||
<Fragment>
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfoToday}
|
||||
{...booking}
|
||||
/>
|
||||
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-wrap lg:flex-row">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
<div className="max-w-full overflow-x-auto xl:ml-auto">
|
||||
<FiltersContainer />
|
||||
</div>
|
||||
</div>
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<div className="mb-6 pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
|
||||
<Fragment>
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfoToday}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
||||
const recurringInfo = page.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
||||
const recurringInfo = page.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BookingLayout>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
Bookings.PageWrapper = PageWrapper;
|
||||
Bookings.getLayout = getLayout;
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
@@ -26,6 +26,7 @@ export default function Type({
|
||||
isBrandingHidden,
|
||||
isTeamEvent,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -44,6 +45,7 @@ export default function Type({
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -54,7 +56,7 @@ Type.isBookingPage = true;
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
@@ -133,6 +135,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
|
||||
@@ -258,6 +258,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
offsetStart: eventType.offsetStart,
|
||||
bookingFields: eventType.bookingFields,
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { FC } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
@@ -15,7 +16,7 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -715,10 +716,7 @@ const EventTypeListHeading = ({
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={
|
||||
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${teamId ? "team/" : ""}${profile.slug}/avatar.png` ||
|
||||
undefined
|
||||
}
|
||||
imageSrc={`${orgBranding?.fullDomain ?? WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
@@ -925,32 +923,35 @@ const EventTypesPage = () => {
|
||||
if (searchParams?.get("openIntercom") === "true") {
|
||||
open();
|
||||
}
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
}, [orgBranding, user]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ShellMain
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
<Shell
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
<Main data={data} status={status} error={error} filters={filters} />
|
||||
</Shell>
|
||||
</div>
|
||||
<Main data={data} status={status} error={error} filters={filters} />
|
||||
</ShellMain>
|
||||
);
|
||||
};
|
||||
|
||||
EventTypesPage.getLayout = getLayout;
|
||||
|
||||
EventTypesPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default EventTypesPage;
|
||||
|
||||
@@ -121,10 +121,10 @@ const OnboardingPage = () => {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-6 md:py-24">
|
||||
<div className="mx-auto py-6 sm:px-4 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
<div className="mx-auto px-4 sm:max-w-[520px]">
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
|
||||
{headers[currentStepIndex]?.title || "Undefined title"}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -56,7 +57,7 @@ export default function InsightsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell hideHeadingOnMobile>
|
||||
<ShellMain hideHeadingOnMobile>
|
||||
<UpgradeTip
|
||||
title={t("make_informed_decisions")}
|
||||
description={t("make_informed_decisions_description")}
|
||||
@@ -111,12 +112,13 @@ export default function InsightsPage() {
|
||||
</FiltersProvider>
|
||||
)}
|
||||
</UpgradeTip>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InsightsPage.PageWrapper = PageWrapper;
|
||||
InsightsPage.getLayout = getLayout;
|
||||
|
||||
// If feature flag is disabled, return not found on getServerSideProps
|
||||
export const getServerSideProps = async () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||
|
||||
import KYCVerificationView from "./kycVerificationView";
|
||||
|
||||
const KYCVerificationPage = () => <KYCVerificationView />;
|
||||
|
||||
KYCVerificationPage.getLayout = getLayout;
|
||||
KYCVerificationPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default KYCVerificationPage;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
|
||||
|
||||
type FormValues = {
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export default function KYCVerificationView() {
|
||||
const teamForm = useForm<FormValues>();
|
||||
const userForm = useForm<FormValues>();
|
||||
|
||||
const mutation = trpc.viewer.kycVerification.verify.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Verification failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Meta
|
||||
title="KYC Verification"
|
||||
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 font-medium">Verify Team</div>
|
||||
|
||||
<Form
|
||||
form={teamForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: true,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...teamForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="team slug"
|
||||
className="-mt-2 "
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 mt-6 font-medium">Verify User</div>
|
||||
<Form
|
||||
form={userForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
name: values.name || "",
|
||||
isTeam: false,
|
||||
});
|
||||
}}>
|
||||
<div className="flex space-x-2">
|
||||
<TextField
|
||||
{...userForm.register("name")}
|
||||
label=""
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="user name"
|
||||
className="-mt-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit">Verify</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,10 +77,9 @@ type FormValues = {
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: session, update } = useSession();
|
||||
const { data: _session, update } = useSession();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { data: avatar, isLoading: isLoadingAvatar } = trpc.viewer.avatar.useQuery();
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
@@ -205,14 +204,14 @@ const ProfileView = () => {
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
if (isLoading || !user || isLoadingAvatar || !avatar)
|
||||
if (isLoading || !user)
|
||||
return (
|
||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
);
|
||||
|
||||
const defaultValues = {
|
||||
username: user.username || "",
|
||||
avatar: avatar.avatar || "",
|
||||
avatar: user.avatar || "",
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
bio: user.bio || "",
|
||||
|
||||
@@ -272,6 +272,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
username = available ? username : suggestion || username;
|
||||
}
|
||||
|
||||
// Transform all + to - in username
|
||||
username = username.replace(/\+/g, "-");
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -18,7 +18,16 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isEmbed, isBrandingHidden, entity }: PageProps) {
|
||||
export default function Type({
|
||||
slug,
|
||||
user,
|
||||
booking,
|
||||
away,
|
||||
isEmbed,
|
||||
isBrandingHidden,
|
||||
entity,
|
||||
duration,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<BookerSeo
|
||||
@@ -37,6 +46,7 @@ export default function Type({ slug, user, booking, away, isEmbed, isBrandingHid
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -55,7 +65,7 @@ const paramsSchema = z.object({
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
@@ -104,6 +114,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -17,7 +18,7 @@ function Teams() {
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("teams")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
@@ -33,7 +34,7 @@ function Teams() {
|
||||
)
|
||||
}>
|
||||
<TeamsListing />
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,5 +47,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
Teams.requiresLicense = false;
|
||||
Teams.PageWrapper = PageWrapper;
|
||||
|
||||
Teams.getLayout = getLayout;
|
||||
export default Teams;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Messages } from "mailhog";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
@@ -10,30 +13,47 @@ import {
|
||||
testName,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
const freeUserObj = { name: `Free-user-${randomString(3)}` };
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(async ({ users }) => users.deleteAll());
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("free user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const free = await users.create();
|
||||
const free = await users.create(freeUserObj);
|
||||
await page.goto(`/${free.username}`);
|
||||
});
|
||||
|
||||
test("cannot book same slot multiple times", async ({ page }) => {
|
||||
test("cannot book same slot multiple times", async ({ page, users, emails }) => {
|
||||
const [user] = users.get();
|
||||
const bookerObj = { email: `testEmail-${randomString(4)}@example.com`, name: "testBooker" };
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
await bookTimeSlot(page, bookerObj);
|
||||
|
||||
// save booking url
|
||||
const bookingUrl: string = page.url();
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
const { title: eventTitle } = await user.getFirstEventAsOwner();
|
||||
// TODO: follow DRY
|
||||
const emailsOrganiserReceived = await emails.search(user.email, "to");
|
||||
const emailsBookerReceived = await emails.search(bookerObj.email, "to");
|
||||
expect(emailsOrganiserReceived?.total).toBe(1);
|
||||
expect(emailsBookerReceived?.total).toBe(1);
|
||||
|
||||
const [organizerFirstEmail] = (emailsOrganiserReceived as Messages).items;
|
||||
const [bookerFirstEmail] = (emailsBookerReceived as Messages).items;
|
||||
const emailSubject = `${eventTitle} between ${user.name} and ${bookerObj.name}`;
|
||||
|
||||
expect(organizerFirstEmail.subject).toBe(emailSubject);
|
||||
expect(bookerFirstEmail.subject).toBe(emailSubject);
|
||||
|
||||
// return to same time spot booking page
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
// book same time spot again
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
// Due to some reason for Dynamic booking cancellation, daily video api_key is not set which causes cancellation to fail.
|
||||
// This test is skipped until the issue is resolved in GH actions.
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip("dynamic booking", async ({ page, users }) => {
|
||||
test("dynamic booking", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -20,10 +17,13 @@ test.skip("dynamic booking", async ({ page, users }) => {
|
||||
await page.goto(`/${pro.username}+${free.username}`);
|
||||
|
||||
await test.step("book an event first day in next month", async () => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Fill what is this meeting about? title
|
||||
await page.locator('[name="title"]').fill("Test meeting");
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -37,7 +37,8 @@ test.skip("dynamic booking", async ({ page, users }) => {
|
||||
return !!bookingId;
|
||||
});
|
||||
await selectSecondAvailableTimeSlotNextMonth(page);
|
||||
// --- fill form
|
||||
|
||||
// No need to fill fields since they should be already filled
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page, WorkerInfo } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
import { Prisma as PrismaType } from "@prisma/client";
|
||||
import { hashSync as hash } from "bcryptjs";
|
||||
import type { API } from "mailhog";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
@@ -116,7 +117,7 @@ const createTeamAndAddUser = async (
|
||||
};
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
export const createUsersFixture = (page: Page, emails: API, workerInfo: WorkerInfo) => {
|
||||
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
|
||||
return {
|
||||
create: async (
|
||||
@@ -330,7 +331,19 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
deleteAll: async () => {
|
||||
const emailMessageIds: string[] = [];
|
||||
const ids = store.users.map((u) => u.id);
|
||||
for (const user of store.users) {
|
||||
const emailMessages = await emails.search(user.email);
|
||||
if (emailMessages && emailMessages.count > 0) {
|
||||
emailMessages.items.forEach((item) => {
|
||||
emailMessageIds.push(item.ID);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of emailMessageIds) {
|
||||
await emails.deleteMessage(id);
|
||||
}
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
store.users = [];
|
||||
},
|
||||
@@ -387,6 +400,12 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
include: { team: { select: { children: true, metadata: true, name: true } } },
|
||||
});
|
||||
},
|
||||
getFirstEventAsOwner: async () =>
|
||||
prisma.eventType.findFirstOrThrow({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}),
|
||||
getFirstTeamEvent: async (teamId: number) => {
|
||||
return prisma.eventType.findFirstOrThrow({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base } from "@playwright/test";
|
||||
import type { API } from "mailhog";
|
||||
import mailhog from "mailhog";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface Fixtures {
|
||||
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails: API;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -40,8 +43,8 @@ declare global {
|
||||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
users: async ({ page, context }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, workerInfo);
|
||||
users: async ({ page, context, emails }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, emails, workerInfo);
|
||||
await use(usersFixture);
|
||||
},
|
||||
bookings: async ({ page }, use) => {
|
||||
@@ -67,4 +70,8 @@ export const test = base.extend<Fixtures>({
|
||||
prisma: async ({}, use) => {
|
||||
await use(prisma);
|
||||
},
|
||||
emails: async ({}, use) => {
|
||||
const mailhogAPI = mailhog();
|
||||
await use(mailhogAPI);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -316,8 +316,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
|
||||
const page = previewTabPage;
|
||||
await rescheduleFromTheLinkOnPage({ page });
|
||||
// eslint-disable-next-line playwright/no-page-pause
|
||||
await page.pause();
|
||||
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -239,4 +240,33 @@ test.describe("Reschedule Tests", async () => {
|
||||
expect(newBooking).not.toBeNull();
|
||||
expect(newBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
test("Should be able to book slot that overlaps with original rescheduled booking", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes[0];
|
||||
|
||||
let firstOfNextMonth = dayjs().add(1, "month").startOf("month");
|
||||
|
||||
// find first available slot of next month (available monday-friday)
|
||||
while (firstOfNextMonth.day() < 1 || firstOfNextMonth.day() > 5) {
|
||||
firstOfNextMonth = firstOfNextMonth.add(1, "day");
|
||||
}
|
||||
|
||||
// set startTime to first available slot
|
||||
const startTime = firstOfNextMonth.set("hour", 9).set("minute", 0).toDate();
|
||||
const endTime = firstOfNextMonth.set("hour", 9).set("minute", 30).toDate();
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {}, startTime, endTime);
|
||||
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Lesen Sie unsere Neuigkeiten und Artikel",
|
||||
"join_our_community": "Treten Sie unserer Community bei",
|
||||
"join_our_discord": "Treten Sie unserem Discord-Server bei",
|
||||
"join_our_discord": "Treffe uns im Discord",
|
||||
"404_claim_entity_user": "Benutzername registrieren und Termine vergeben",
|
||||
"popular_pages": "Beliebte Seiten",
|
||||
"register_now": "Jetzt registrieren",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"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>",
|
||||
"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.",
|
||||
"finish": "Finish",
|
||||
"organization_general_description": "Manage settings for your team language and timezone",
|
||||
@@ -1718,7 +1720,7 @@
|
||||
"identifier": "Identifier",
|
||||
"duplicate_email": "Email is duplicate",
|
||||
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed",
|
||||
"not_enough_seats": "Not enough seats",
|
||||
@@ -1850,6 +1852,8 @@
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
"card_details": "Card details",
|
||||
"something_went_wrong_on_our_end":"Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right away for you.",
|
||||
"please_provide_following_text_to_suppport":"Please provide the following text when contacting support to better help you",
|
||||
"seats_and_no_show_fee_error": "Currently cannot enable seats and charge a no-show fee",
|
||||
"complete_your_booking": "Complete your booking",
|
||||
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
|
||||
@@ -1899,6 +1903,7 @@
|
||||
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||
"install_app_on": "Install app on",
|
||||
"create_for": "Create for",
|
||||
"currency": "Currency",
|
||||
"setup_organization": "Setup an Organization",
|
||||
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
|
||||
"organization_banner_title": "Manage organizations with multiple teams",
|
||||
@@ -1977,6 +1982,13 @@
|
||||
"what_is_this_meeting_about": "What is this meeting about?",
|
||||
"requires_booker_email_verification": "Requires booker email verification",
|
||||
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
|
||||
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
|
||||
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
|
||||
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
|
||||
"verify_team_or_account": "Verify {{teamOrAccount}}",
|
||||
"verify_account": "Verify Account",
|
||||
"kyc_verification": "KYC Verification",
|
||||
"organizations": "Organizations",
|
||||
"org_admin_other_teams": "Other teams",
|
||||
"org_admin_other_teams_description": "Here you can see teams inside your organization but that you are not part of. You can add yourself to them if needed.",
|
||||
"no_other_teams_found": "No other teams found",
|
||||
@@ -1985,5 +1997,7 @@
|
||||
"attendee_last_name_variable": "Attendee last name",
|
||||
"attendee_first_name_info": "The person booking's first name",
|
||||
"attendee_last_name_info": "The person booking's last name",
|
||||
"me": "Me",
|
||||
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"create_account": "Créer un compte",
|
||||
"confirm_password": "Confirmer le mot de passe",
|
||||
"confirm_auth_change": "Cela changera la façon dont vous vous connectez",
|
||||
"confirm_auth_email_change": "La modification de l'adresse e-mail déconnectera votre méthode d'authentification actuelle pour vous connecter à Cal.com. Nous vous demanderons de vérifier votre nouvelle adresse e-mail. Ensuite, vous serez déconnecté et devrez utiliser votre nouvelle adresse e-mail pour vous connecter après avoir défini votre mot de passe en suivant les instructions qui seront envoyées à votre adresse e-mail.",
|
||||
"reset_your_password": "Définissez votre nouveau mot de passe avec les instructions envoyées à votre adresse e-mail.",
|
||||
"email_change": "Reconnectez-vous avec votre nouvelle adresse e-mail et votre nouveau mot de passe.",
|
||||
"create_your_account": "Créez votre compte",
|
||||
@@ -255,6 +256,8 @@
|
||||
"available_apps": "Applications disponibles",
|
||||
"available_apps_lower_case": "Applications disponibles",
|
||||
"available_apps_desc": "Aucune application installée. Consultez les applications populaires ci-dessous et découvrez-en plus dans notre <1>App Store</1>.",
|
||||
"fixed_host_helper": "Ajouter toute personne qui doit assister à l'événement. <1>En savoir plus</1>",
|
||||
"round_robin_helper": "Les membres du groupe se relaient et une seule personne se présentera à l'événement.",
|
||||
"check_email_reset_password": "Vérifiez vos e-mails. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe.",
|
||||
"finish": "Terminer",
|
||||
"organization_general_description": "Gérez les paramètres de langue et de fuseau horaire de votre équipe.",
|
||||
@@ -1847,6 +1850,8 @@
|
||||
"insights_no_data_found_for_filter": "Aucune donnée trouvée pour le filtre ou les dates sélectionnés.",
|
||||
"acknowledge_booking_no_show_fee": "Je reconnais que si je ne participe pas à cet événement, des frais d'absence de {{amount, currency}} seront appliqués à ma carte.",
|
||||
"card_details": "Détails de la carte",
|
||||
"something_went_wrong_on_our_end": "Quelque chose s'est mal passé de notre côté. Contactez notre équipe de support, et nous le réparerons immédiatement.",
|
||||
"please_provide_following_text_to_suppport": "Veuillez fournir le texte suivant lorsque vous contactez le support pour mieux vous aider",
|
||||
"seats_and_no_show_fee_error": "Il n'est pas possible d'activer les places et de facturer des frais d'absence pour le moment",
|
||||
"complete_your_booking": "Terminez votre réservation",
|
||||
"complete_your_booking_subject": "Terminez votre réservation : {{title}} le {{date}}",
|
||||
@@ -1896,6 +1901,7 @@
|
||||
"first_event_type_webhook_description": "Créez votre premier webhook pour ce type d'événement",
|
||||
"install_app_on": "Installer l’application sur",
|
||||
"create_for": "Créer pour",
|
||||
"currency": "Devise",
|
||||
"setup_organization": "Configurer une organisation",
|
||||
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.",
|
||||
"organization_banner_title": "Gérer les organisations avec plusieurs équipes",
|
||||
@@ -1972,11 +1978,20 @@
|
||||
"org_team_names_example_5": "p. ex. Équipe d'analyse de données",
|
||||
"org_max_team_warnings": "Vous pourrez ajouter plus d'équipes plus tard.",
|
||||
"what_is_this_meeting_about": "En quoi consiste ce rendez-vous ?",
|
||||
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
||||
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
||||
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
||||
"verify_team_or_account": "Vérifier {{teamOrAccount}}",
|
||||
"verify_account": "Vérifier le compte",
|
||||
"kyc_verification": "Vérification KYC",
|
||||
"organizations": "Organisations",
|
||||
"org_admin_other_teams": "Autres équipes",
|
||||
"org_admin_other_teams_description": "Ici, vous pouvez voir les équipes au sein de votre organisation dont vous ne faites pas partie. Vous pouvez vous y ajouter si nécessaire.",
|
||||
"no_other_teams_found": "Aucune autre équipe trouvée",
|
||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||
"attendee_first_name_variable": "Prénom du participant",
|
||||
"attendee_last_name_variable": "Nom du participant",
|
||||
"me": "Moi",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לצוותים. שמנו לב שהצוות שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"upgrade_banner_action": "כאן משדרגים",
|
||||
"team_upgraded_successfully": "הצוות שלך שודרג בהצלחה!",
|
||||
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שהארגון שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שה-Organization שלך, \"{{teamName}}\", זקוק לשדרוג.",
|
||||
"org_upgraded_successfully": "השדרוג של הארגון שלך בוצע בהצלחה!",
|
||||
"use_link_to_reset_password": "נא להשתמש בקישור הבא כדי לאפס את הסיסמה",
|
||||
"hey_there": "שלום,",
|
||||
@@ -551,7 +551,7 @@
|
||||
"team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.",
|
||||
"org_description": "מספר משפטים אודות הארגון. הם יופיעו בדף עם כתובת ה-URL של הארגון.",
|
||||
"members": "חברים",
|
||||
"organization_members": "חברי הארגון",
|
||||
"organization_members": "חברי Organization",
|
||||
"member": "חבר/ת",
|
||||
"number_member_one": "חבר {{count}}",
|
||||
"number_member_other": "{{count}} חברים",
|
||||
@@ -699,7 +699,7 @@
|
||||
"create_team_to_get_started": "צור צוות כדי להתחיל",
|
||||
"teams": "צוותים",
|
||||
"team": "צוות",
|
||||
"organization": "ארגון",
|
||||
"organization": "Organization",
|
||||
"team_billing": "חיוב צוותים",
|
||||
"team_billing_description": "ניהול החיוב עבור הצוות",
|
||||
"upgrade_to_flexible_pro_title": "שינינו את החיוב בעבור צוותים",
|
||||
@@ -1151,7 +1151,7 @@
|
||||
"custom": "התאמה אישית",
|
||||
"reminder": "תזכורת",
|
||||
"rescheduled": "נקבע מועד חדש",
|
||||
"completed": "הושלם",
|
||||
"completed": "הושלמה",
|
||||
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
|
||||
"minute_one": "{{count}} דקה",
|
||||
@@ -1938,7 +1938,7 @@
|
||||
"insights_all_org_filter": "כל האפליקציות",
|
||||
"insights_team_filter": "צוות: {{teamName}}",
|
||||
"insights_user_filter": "משתמש: {{userName}}",
|
||||
"insights_subtitle": "הצגת תובנות הזמנה מתוך האירועים שלך",
|
||||
"insights_subtitle": "הצגת insights לגבי הזמנות מתוך האירועים שלך",
|
||||
"custom_plan": "חבילה בהתאמה אישית",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Informazioni sul team",
|
||||
"request_another_invitation_email": "Se preferisci non usare {{toEmail}} come email {{appName}} o già hai un account {{appName}}, per favore richiedi un altro invito a quella email.",
|
||||
"you_have_been_invited": "Sei stato invitato ad unirti al team {{teamName}}",
|
||||
"user_invited_you": "{{user}} ti ha invitato a unirti al {{team}} {{entity}} su {{appName}}",
|
||||
"user_invited_you": "{{user}} ti ha invitato a unirti a {{entity}} {{team}} su {{appName}}",
|
||||
"hidden_team_member_title": "Sei nascosto in questo team",
|
||||
"hidden_team_member_message": "Il tuo posto non è pagato. Passa a Pro oppure informa il proprietario del team che può pagare il tuo posto.",
|
||||
"hidden_team_owner_message": "Per utilizzare i team ti occorre un account Pro. Sarai nascosto finché non cambierai piano.",
|
||||
@@ -129,8 +129,8 @@
|
||||
"team_upgrade_banner_description": "Grazie per aver provato il nostro piano team. Abbiamo notato che è necessario effettuare l'upgrade del tuo team \"{{teamName}}\".",
|
||||
"upgrade_banner_action": "Effettua l'upgrade",
|
||||
"team_upgraded_successfully": "Il tuo team è stato aggiornato!",
|
||||
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade per la tua organizzazione \"{{teamName}}\".",
|
||||
"org_upgraded_successfully": "È stato eseguito l'upgrade della tua organizzazione!",
|
||||
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade del piano Organization per \"{{teamName}}\".",
|
||||
"org_upgraded_successfully": "È stato eseguito l'upgrade del piano Organization!",
|
||||
"use_link_to_reset_password": "Usa il link qui sotto per reimpostare la tua password",
|
||||
"hey_there": "Ciao,",
|
||||
"forgot_your_password_calcom": "Hai dimenticato la password? - {{appName}}",
|
||||
@@ -555,7 +555,7 @@
|
||||
"member": "Membri",
|
||||
"number_member_one": "{{count}} membro",
|
||||
"number_member_other": "{{count}} membri",
|
||||
"number_selected": "{{count}} selezionati",
|
||||
"number_selected": "{{count}} selezionato/i",
|
||||
"owner": "Proprietario",
|
||||
"admin": "Amministratore",
|
||||
"administrator_user": "Utente amministratore",
|
||||
@@ -1912,7 +1912,7 @@
|
||||
"organization_admin_invited_body": "Unisciti al tuo team su {{orgName}} per concentrarti sulle riunioni, non sulla loro pianificazione!",
|
||||
"duplicated_slugs_warning": "Non è stato possibile creare i seguenti team a causa di slug duplicati: {{slugs}}",
|
||||
"team_names_empty": "I nomi di team non possono essere vuoti",
|
||||
"team_names_repeated": "I nomi di team non possono essere ripetuti",
|
||||
"team_names_repeated": "I nomi di team devono essere unici",
|
||||
"user_belongs_organization": "L'utente appartiene a un'organizzazione",
|
||||
"org_no_teams_yet": "Questa organizzazione non ha ancora nessun team",
|
||||
"org_no_teams_yet_description": "Se sei un amministratore, assicurati di creare dei team da mostrare qui.",
|
||||
@@ -1934,7 +1934,7 @@
|
||||
"404_the_org": "L'organizzazione",
|
||||
"404_the_team": "Il team",
|
||||
"404_claim_entity_org": "Richiedi il sottodominio per la tua organizzazione",
|
||||
"404_claim_entity_team": "Richiedi questo team e inizia a gestire le pianificazioni collettivamente",
|
||||
"404_claim_entity_team": "Richiedi l'accesso a questo team per iniziare a gestire le pianificazioni collettivamente",
|
||||
"insights_all_org_filter": "Tutte le app",
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Utente: {{userName}}",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
|
||||
"verify_email_subject": "{{appName}}:アカウントを確認",
|
||||
"check_your_email": "メールを確認してください",
|
||||
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダーが確実に連携するようにするには、メールアドレスの確認が重要です。",
|
||||
"verify_email_banner_body": "メール配信とカレンダーが確実に連携するようにするには、メールアドレスを確認してください。",
|
||||
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダー通知が確実に連携するようにするには、メールアドレスの確認が重要です。",
|
||||
"verify_email_banner_body": "メール配信とカレンダー通知が確実に連携するようにするには、メールアドレスを確認してください。",
|
||||
"verify_email_email_header": "メールアドレスの確認",
|
||||
"verify_email_email_button": "メールを確認",
|
||||
"verify_email_email_body": "以下のボタンをクリックして、メールアドレスを確認してください。",
|
||||
@@ -1934,7 +1934,7 @@
|
||||
"404_the_org": "組織",
|
||||
"404_the_team": "チーム",
|
||||
"404_claim_entity_org": "組織のサブドメインを取得",
|
||||
"404_claim_entity_team": "このチームを取得して、これからはスケジュールをまとめて管理しましょう",
|
||||
"404_claim_entity_team": "このチームの一員になって、これからはスケジュールをまとめて管理しましょう",
|
||||
"insights_all_org_filter": "すべて",
|
||||
"insights_team_filter": "チーム:{{teamName}}",
|
||||
"insights_user_filter": "ユーザー:{{userName}}",
|
||||
|
||||
@@ -13,13 +13,19 @@
|
||||
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
|
||||
"verify_email_subject": "{{appName}}: Verificar sua conta",
|
||||
"check_your_email": "Verifique seu e-mail",
|
||||
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário de {{appName}}.",
|
||||
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o melhor envio de e-mail e calendário do {{appName}}.",
|
||||
"verify_email_banner_body": "Verifique seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário",
|
||||
"verify_email_email_header": "Verifique seu endereço de e-mail",
|
||||
"verify_email_email_button": "Verificar e-mail",
|
||||
"copy_somewhere_safe": "Salve esta chave de API em um lugar seguro. Você não será capaz de vê-la novamente.",
|
||||
"verify_email_email_body": "Por favor, verifique seu endereço de e-mail clicando no botão abaixo.",
|
||||
"verify_email_by_code_email_body": "Por favor, verifique seu endereço de e-mail usando o código abaixo.",
|
||||
"verify_email_email_link_text": "Aqui está o link caso você não goste de usar botões:",
|
||||
"email_verification_code": "Insira o código de verificação",
|
||||
"email_verification_code_placeholder": "Insira o código de verificação enviado para seu e-mail",
|
||||
"incorrect_email_verification_code": "O código de verificação está incorreto.",
|
||||
"email_sent": "E-mail enviado com sucesso",
|
||||
"email_not_sent": "Ocorreu um erro ao enviar o e-mail",
|
||||
"event_declined_subject": "Recusado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "Sua solicitação de reunião foi recusada",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Um evento recorrente aguarda a sua aprovação",
|
||||
"someone_requested_an_event": "Alguém solicitou o agendamento de um evento em sua agenda.",
|
||||
"someone_requested_password_reset": "Alguém solicitou um link para alterar sua senha.",
|
||||
"password_reset_email_sent": "Se estee-mail existir em nosso sistema, você receberá um e-mail de redefinição.",
|
||||
"password_reset_instructions": "Se você não fez essa solicitação, você pode ignorar este e-mail e sua senha não será alterada.",
|
||||
"event_awaiting_approval_subject": "Aguardando aprovação: {{title}} em {{date}}",
|
||||
"event_still_awaiting_approval": "Um evento está aguardando a sua aprovação",
|
||||
@@ -217,6 +224,9 @@
|
||||
"already_have_an_account": "Já tem uma conta?",
|
||||
"create_account": "Criar Conta",
|
||||
"confirm_password": "Confirmar senha",
|
||||
"confirm_auth_change": "Isso alterará a forma como você faz login",
|
||||
"reset_your_password": "Defina sua nova senha com as instruções enviadas para seu endereço de e-mail.",
|
||||
"email_change": "Volte a entrar com seu novo endereço de e-mail e senha.",
|
||||
"create_your_account": "Criar sua conta",
|
||||
"sign_up": "Cadastro",
|
||||
"youve_been_logged_out": "A sessão foi encerrada",
|
||||
@@ -243,6 +253,9 @@
|
||||
"all": "Todos os apps",
|
||||
"yours": "Sua conta",
|
||||
"available_apps": "Aplicativos disponíveis",
|
||||
"available_apps_lower_case": "Aplicativos disponíveis",
|
||||
"available_apps_desc": "Você não tem nenhum aplicativo instalado. Veja apps populares abaixo e explore mais em nossa <1>App Store</1>",
|
||||
"fixed_host_helper": "Adicione qualquer pessoa que precise participar do evento. <1>Saiba mais</1>",
|
||||
"check_email_reset_password": "Verifique o seu email. Enviamos um link para redefinir a sua senha.",
|
||||
"finish": "Finalizar",
|
||||
"organization_general_description": "Gerencie as configurações para o idioma e o fuso horário da sua equipe",
|
||||
@@ -406,6 +419,7 @@
|
||||
"booking_requested": "Reserva solicitada",
|
||||
"meeting_ended": "Reunião encerrada",
|
||||
"form_submitted": "Formulário enviado",
|
||||
"booking_paid": "Reserva paga",
|
||||
"event_triggers": "Gatilhos de eventos",
|
||||
"subscriber_url": "URL do assinante",
|
||||
"create_new_webhook": "Criar um novo webhook",
|
||||
@@ -506,6 +520,7 @@
|
||||
"your_name": "Seu nome",
|
||||
"your_full_name": "Seu nome completo",
|
||||
"no_name": "Sem nome",
|
||||
"enter_number_between_range": "Por favor, insira um número entre 1 e {{maxOccurences}}",
|
||||
"email_address": "Endereço de e-mail",
|
||||
"enter_valid_email": "Insira um e-mail válido",
|
||||
"location": "Local",
|
||||
@@ -842,6 +857,7 @@
|
||||
"team_view_user_availability_disabled": "O usuário precisa aceitar o convite para ver a disponibilidade",
|
||||
"set_as_away": "Definir-se como indisponível",
|
||||
"set_as_free": "Desativar status indisponível",
|
||||
"toggle_away_error": "Erro ao atualizar status de ausência",
|
||||
"user_away": "Este usuário está indisponível no momento.",
|
||||
"user_away_description": "A pessoa que você está tentando realizar uma reserva está indisponível no momento e, portanto, não está aceitando novas reservas.",
|
||||
"meet_people_with_the_same_tokens": "Conheça pessoas com os mesmos tokens",
|
||||
@@ -1865,6 +1881,9 @@
|
||||
"open_dialog_with_element_click": "Abra uma caixa de diálogo quando alguém clicar em um elemento.",
|
||||
"need_help_embedding": "Precisa de ajuda? Confira nosso guia para incorporar o Cal no Wix, Squarespace ou WordPress. Consulte as perguntas frequentes ou explore opções de incorporação avançadas.",
|
||||
"book_my_cal": "Reservar meu Cal",
|
||||
"first_name": "Primeiro nome",
|
||||
"last_name": "Sobrenome",
|
||||
"first_last_name": "Primeiro nome, Sobrenome",
|
||||
"invite_as": "Convidar como",
|
||||
"form_updated_successfully": "Formulário atualizado com êxito.",
|
||||
"disable_attendees_confirmation_emails": "Desative os e-mails de confirmação padrão para participantes",
|
||||
@@ -1878,6 +1897,7 @@
|
||||
"first_event_type_webhook_description": "Crie seu primeiro webhook para este tipo de evento",
|
||||
"install_app_on": "Instalar aplicativo em",
|
||||
"create_for": "Criar para",
|
||||
"currency": "Moeda",
|
||||
"setup_organization": "Definir uma Organização",
|
||||
"organization_banner_description": "Crie um ambiente onde suas equipes podem criar tipos de evento, fluxos de trabalho e aplicativos compartilhados com agendamento coletivo e round robin.",
|
||||
"organization_banner_title": "Gerenciar organizações com múltiplas equipes",
|
||||
@@ -1940,5 +1960,18 @@
|
||||
"insights_user_filter": "Usuário: {{userName}}",
|
||||
"insights_subtitle": "Veja insights de reserva em seus eventos",
|
||||
"custom_plan": "Plano personalizado",
|
||||
"select_time": "Selecione o horário",
|
||||
"select_date": "Selecione a data",
|
||||
"see_all_available_times": "Veja todos os horários disponíveis",
|
||||
"org_team_names_example": "ex.: Time de Marketing",
|
||||
"org_team_names_example_1": "ex.: Time de Marketing",
|
||||
"org_team_names_example_2": "ex: Time de Vendas",
|
||||
"org_team_names_example_3": "ex: Time de Design",
|
||||
"org_team_names_example_4": "ex: Time de Engenharia",
|
||||
"org_team_names_example_5": "ex: Time de Análise de Dados",
|
||||
"what_is_this_meeting_about": "De que se trata esta reunião?",
|
||||
"requires_booker_email_verification": "Requer verificação de e-mail do reservante",
|
||||
"verify_team_or_account": "Verificar {{teamOrAccount}}",
|
||||
"verify_account": "Verificar Conta",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
"bookerlayout_user_settings_title": "Disposição da reserva",
|
||||
"bookerlayout_user_settings_description": "Pode selecionar várias e os clientes podem alternar entre vistas. Isto pode ser sobreposto para cada evento.",
|
||||
"bookerlayout_month_view": "Mês",
|
||||
"bookerlayout_week_view": "Semanal",
|
||||
"bookerlayout_week_view": "Semana",
|
||||
"bookerlayout_column_view": "Coluna",
|
||||
"bookerlayout_error_min_one_enabled": "Tem de estar ativa pelo menos uma disposição.",
|
||||
"bookerlayout_error_default_not_enabled": "A disposição selecionada como a vista predefinida não faz parte das disposições ativas.",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Informații echipă",
|
||||
"request_another_invitation_email": "Dacă preferați să nu utilizați {{toEmail}} ca e-mail {{appName}} sau aveți deja un cont {{appName}}, vă rugăm să solicitați o altă invitație la acel e-mail.",
|
||||
"you_have_been_invited": "Ați fost invitat să vă alăturați echipei {{teamName}}",
|
||||
"user_invited_you": "{{user}} v-a invitat să vă alăturați echipei {{entity}} {{team}} pe {{appName}}",
|
||||
"user_invited_you": "{{user}} v-a invitat să vă alăturați la {{entity}} {{team}} de pe {{appName}}",
|
||||
"hidden_team_member_title": "Sunteți ascuns în această echipă",
|
||||
"hidden_team_member_message": "Licența dvs. nu este plătită. Fie faceți upgrade la Pro, fie anunțați proprietarul echipei că vă poate plăti licența.",
|
||||
"hidden_team_owner_message": "Aveți nevoie de un cont Pro pentru a utiliza echipe. Sunteți ascuns până când faceți upgrade.",
|
||||
@@ -129,8 +129,8 @@
|
||||
"team_upgrade_banner_description": "Vă mulțumim că ați încercat noul nostru plan pentru echipe. Am observat că planul echipei dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"upgrade_banner_action": "Realizați upgrade aici",
|
||||
"team_upgraded_successfully": "Echipa dvs. a fost actualizată cu succes!",
|
||||
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organizație. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"org_upgraded_successfully": "Upgrade-ul la organizația dvs. a fost realizat cu succes!",
|
||||
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organization. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
|
||||
"org_upgraded_successfully": "Upgrade-ul pentru Organization a fost realizat cu succes!",
|
||||
"use_link_to_reset_password": "Utilizați linkul de mai jos pentru a vă reseta parola",
|
||||
"hey_there": "Bună,",
|
||||
"forgot_your_password_calcom": "Ți-ai uitat parola? - {{appName}}",
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Team. Для работы с командой «{{teamName}}» необходимо изменить тариф.",
|
||||
"upgrade_banner_action": "Изменить",
|
||||
"team_upgraded_successfully": "Вы успешно перешли на новый тариф для своей команды!",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы с «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgrade_banner_description": "Спасибо, что попробовали наш новый тариф Organization. Для работы «{{teamName}}» нужно перейти на тариф Organization.",
|
||||
"org_upgraded_successfully": "Вы успешно перешли на тариф Organization!",
|
||||
"use_link_to_reset_password": "Используйте ссылку ниже, чтобы сбросить пароль",
|
||||
"hey_there": "Привет,",
|
||||
@@ -307,15 +307,15 @@
|
||||
"layout": "Макет",
|
||||
"bookerlayout_default_title": "Представление по умолчанию",
|
||||
"bookerlayout_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления.",
|
||||
"bookerlayout_user_settings_title": "Макет забронированного события",
|
||||
"bookerlayout_user_settings_title": "Представление забронированного события",
|
||||
"bookerlayout_user_settings_description": "Вы можете выбрать несколько представлений; пользователи, оформляющие бронирование, могут переключать представления. Эту настройку можно переопределять для каждого события.",
|
||||
"bookerlayout_month_view": "Месяц",
|
||||
"bookerlayout_week_view": "Еженедельно",
|
||||
"bookerlayout_week_view": "Неделя",
|
||||
"bookerlayout_column_view": "Столбец",
|
||||
"bookerlayout_error_min_one_enabled": "Нужно выбрать по крайней мере один макет.",
|
||||
"bookerlayout_error_default_not_enabled": "Макет, который вы выбрали как представление по умолчанию, не входит в число доступных макетов.",
|
||||
"bookerlayout_error_unknown_layout": "Вы выбрали недопустимый макет.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>переопределить только для этого события</6>.",
|
||||
"bookerlayout_override_global_settings": "Эту настройку можно указать для всех типов событий в <2>настройках / оформление</2> или <6>указать только для этого события</6>.",
|
||||
"unexpected_error_try_again": "Произошла непредвиденная ошибка. Попробуйте еще раз.",
|
||||
"sunday_time_error": "Недопустимое время в воскресенье",
|
||||
"monday_time_error": "Недопустимое время в понедельник",
|
||||
@@ -551,11 +551,11 @@
|
||||
"team_description": "Краткое описание вашей команды. Этот текст будет показываться на странице вашей команды.",
|
||||
"org_description": "Краткое описание вашей организации. Этот текст будет виден на странице вашей организации.",
|
||||
"members": "Участники",
|
||||
"organization_members": "Сотрудники организации",
|
||||
"organization_members": "Участники, подключенные к тарифному плану Organization",
|
||||
"member": "Участник",
|
||||
"number_member_one": "Участники: {{count}}",
|
||||
"number_member_other": "Участники: {{count}}",
|
||||
"number_selected": "Выбрано: {{count}}",
|
||||
"number_selected": "выбрано: {{count}}",
|
||||
"owner": "Владелец",
|
||||
"admin": "Админ",
|
||||
"administrator_user": "Администратор",
|
||||
@@ -1151,7 +1151,7 @@
|
||||
"custom": "Пользовательский",
|
||||
"reminder": "Напоминание",
|
||||
"rescheduled": "Перенесено",
|
||||
"completed": "Выполнено",
|
||||
"completed": "Завершено",
|
||||
"reminder_email": "Напоминание: {{eventType}} с {{name}} {{date}}",
|
||||
"not_triggering_existing_bookings": "Не работает для уже созданных бронирований, так как при бронировании события пользователь должен будет ввести номер телефона.",
|
||||
"minute_one": "{{count}} минута",
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"download_responses_description": "Скачать все ответы пользователей, заполнивших форму, в формате CSV.",
|
||||
"download": "Скачать",
|
||||
"download_recording": "Скачать запись",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвана в {{appName}} готова для скачивания",
|
||||
"recording_from_your_recent_call": "Запись вашего недавнего созвона в {{appName}} готова для скачивания",
|
||||
"create_your_first_form": "Создайте первую форму",
|
||||
"create_your_first_form_description": "Формы маршрутизации позволяют задавать пользователям уточняющие вопросы, чтобы перенаправлять их к соответствующему лицу или типу события.",
|
||||
"create_your_first_webhook": "Создайте свой первый вебхук",
|
||||
@@ -1659,9 +1659,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Удалить конфигурацию {{connectionType}}? Участники команды, входящие в Cal.com через {{connectionType}}, больше не смогут получить к нему доступ.",
|
||||
"organizer_timezone": "Часовой пояс организатора",
|
||||
"email_user_cta": "Посмотреть приглашение",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{appName}} {{entity}}",
|
||||
"email_no_user_invite_heading": "Вас пригласили в {{entity}} {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} пригласил(-а) вас в команду в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого пользователи и целые команды могут планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша компания {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} пригласил(а) вас в команду {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша {{entity}} можете планировать встречи без утомительной переписки по электронной почте.",
|
||||
"email_no_user_invite_steps_intro": "Всего несколько шагов — и вы сможете оперативно и без стресса планировать встречи с {{entity}}.",
|
||||
"email_no_user_step_one": "Выберите имя пользователя",
|
||||
"email_no_user_step_two": "Подключите аккаунт календаря",
|
||||
@@ -1687,7 +1687,7 @@
|
||||
"attendee_no_longer_attending": "Участник отказался от посещения вашего мероприятия",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} отказался(-ась) от посещения мероприятия. Этот промежуток времени снова свободен",
|
||||
"create_event_on": "Создать событие в календаре",
|
||||
"create_routing_form_on": "Создать форму маршрутизации",
|
||||
"create_routing_form_on": "Создать форму маршрутизации в",
|
||||
"default_app_link_title": "Установить ссылку на приложение по умолчанию",
|
||||
"default_app_link_description": "Если установить ссылку на приложение по умолчанию, она будет присваиваться всем вновь создаваемым событиям.",
|
||||
"organizer_default_conferencing_app": "Приложение организатора по умолчанию",
|
||||
@@ -1876,16 +1876,16 @@
|
||||
"connect_google_workspace": "Подключить Google Workspace",
|
||||
"google_workspace_admin_tooltip": "Эту функцию может использовать только администратор Workspace",
|
||||
"first_event_type_webhook_description": "Создайте первый вебхук для этого типа событий",
|
||||
"install_app_on": "Установить приложение на",
|
||||
"install_app_on": "Установить приложение в",
|
||||
"create_for": "Создать для",
|
||||
"setup_organization": "Настроить организацию",
|
||||
"organization_banner_description": "Создайте среду, в которой ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_description": "Создавайте рабочие среды, в которых ваши команды смогут открывать совместный доступ к приложениям, рабочим процессам и типам событий, составляя расписание совместно или по очереди.",
|
||||
"organization_banner_title": "Управляйте организациями с несколькими командами",
|
||||
"set_up_your_organization": "Настройте организацию",
|
||||
"organizations_description": "Организация – это общая рабочая среда, в которой команды могут создавать общие типы событий, приложения, рабочие процессы и многое другое.",
|
||||
"must_enter_organization_name": "Необходимо ввести название организации",
|
||||
"must_enter_organization_admin_email": "Необходимо ввести адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты вашей организации",
|
||||
"admin_email": "Адрес электронной почты в организации",
|
||||
"admin_username": "Имя пользователя администратора",
|
||||
"organization_name": "Название организации",
|
||||
"organization_url": "URL-адрес организации",
|
||||
@@ -1912,7 +1912,7 @@
|
||||
"organization_admin_invited_body": "Станьте частью команды {{orgName}} – и вы сможете уделять внимание встречам, а не их планированию!",
|
||||
"duplicated_slugs_warning": "Не удалось создать следующие команды из-за дублирующихся слагов: {{slugs}}",
|
||||
"team_names_empty": "Имена команд не могут быть пустыми",
|
||||
"team_names_repeated": "Имена команд не могут быть повторяться",
|
||||
"team_names_repeated": "Имена команд должны быть уникальными",
|
||||
"user_belongs_organization": "Пользователь относится к организации",
|
||||
"org_no_teams_yet": "У этой организации еще нет команд",
|
||||
"org_no_teams_yet_description": "Если вы администратор, не забудьте создать команды, которые будут отображаться здесь.",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"upgrade_banner_action": "Nadogradite ovde",
|
||||
"team_upgraded_successfully": "Vaš tim je uspešno pretplaćen!",
|
||||
"org_upgrade_banner_description": "Hvala što isprobavate plan naše organizacije. Primetili smo da tim vaše organizacije „{{teamName}}” treba da se nadogradi.",
|
||||
"org_upgraded_successfully": "Vaša organizacija je uspešno nadograđena!",
|
||||
"org_upgraded_successfully": "Vaš Organization je uspešno nadograđen!",
|
||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||
"hey_there": "Zdravo,",
|
||||
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
||||
|
||||
@@ -129,8 +129,8 @@
|
||||
"team_upgrade_banner_description": "Tack för att du testade vår nya teamplan. Vi märkte att ditt team \"{{teamName}}\" måste uppgraderas.",
|
||||
"upgrade_banner_action": "Uppgradera här",
|
||||
"team_upgraded_successfully": "Ditt team har uppgraderats!",
|
||||
"org_upgrade_banner_description": "Tack för att du testade vår nya organisationsplan. Vi märkte att din organisation \"{{teamName}}\" måste uppgraderas.",
|
||||
"org_upgraded_successfully": "Din organisation har uppgraderats!",
|
||||
"org_upgrade_banner_description": "Tack för att du testade vår nya Organization-plan. Vi märkte att din Organization \"{{teamName}}\" måste uppgraderas.",
|
||||
"org_upgraded_successfully": "Din Organization har uppgraderats!",
|
||||
"use_link_to_reset_password": "Använd länken nedan för att återställa ditt lösenord",
|
||||
"hey_there": "Hallå där,",
|
||||
"forgot_your_password_calcom": "Glömt ditt lösenord? - {{appName}}",
|
||||
@@ -551,7 +551,7 @@
|
||||
"team_description": "Några meningar om ditt team. Detta visas på ditt teams URL-sida.",
|
||||
"org_description": "Några meningar om din organisation. Detta kommer att visas på din organisations webbsida.",
|
||||
"members": "Medlemmar",
|
||||
"organization_members": "Organisationens medlemmar",
|
||||
"organization_members": "Organization-medlemmar",
|
||||
"member": "Medlem",
|
||||
"number_member_one": "{{count}} medlem",
|
||||
"number_member_other": "{{count}} medlemmar",
|
||||
@@ -699,7 +699,7 @@
|
||||
"create_team_to_get_started": "Skapa ett team för att komma igång",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
"organization": "Organisation",
|
||||
"organization": "Organization",
|
||||
"team_billing": "Team-fakturering",
|
||||
"team_billing_description": "Hantera fakturering för ditt team",
|
||||
"upgrade_to_flexible_pro_title": "Vi har ändrat fakturering för team",
|
||||
@@ -1938,7 +1938,7 @@
|
||||
"insights_all_org_filter": "Alla",
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Användare: {{userName}}",
|
||||
"insights_subtitle": "Visa bokningsinsikter för dina händelser",
|
||||
"insights_subtitle": "Visa Insights-bokningar för dina händelser",
|
||||
"custom_plan": "Anpassad plan",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Lägg till dina nya strängar här ovanför ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1938,7 +1938,7 @@
|
||||
"insights_all_org_filter": "Tümü",
|
||||
"insights_team_filter": "Ekip: {{teamName}}",
|
||||
"insights_user_filter": "Kullanıcı: {{userName}}",
|
||||
"insights_subtitle": "Etkinlikleriniz için rezervasyon analizlerini görüntüleyin",
|
||||
"insights_subtitle": "Etkinlikleriniz için rezervasyon Insights'lerini görüntüleyin",
|
||||
"custom_plan": "Özel Plan",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Про команду",
|
||||
"request_another_invitation_email": "Якщо ви не хочете використовувати {{toEmail}} в {{appName}} або вже маєте обліковий запис {{appName}}, надішліть запит на запрошення на потрібну електронну адресу.",
|
||||
"you_have_been_invited": "Вас запрошено приєднатися до команди «{{teamName}}»",
|
||||
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися до команди «{{team}}» користувача {{entity}} в {{appName}}",
|
||||
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися сюди: {{entity}} «{{team}}» в {{appName}}",
|
||||
"hidden_team_member_title": "У цій команді вас приховано",
|
||||
"hidden_team_member_message": "Ваше місце не оплачено. Перейдіть на версію Pro або попросіть власника команди оплатити ваше місце.",
|
||||
"hidden_team_owner_message": "Щоб користуватися функціями команди, потрібен обліковий запис версії Pro. До переходу на цю версію вас буде приховано.",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"upgrade_banner_action": "在此处升级",
|
||||
"team_upgraded_successfully": "您的团队升级成功!",
|
||||
"org_upgrade_banner_description": "感谢您试用我们的 Organization 计划。我们注意到您的组织“{{teamName}}”需要升级。",
|
||||
"org_upgraded_successfully": "您的组织已成功升级!",
|
||||
"org_upgraded_successfully": "您的 Organization 已成功升级!",
|
||||
"use_link_to_reset_password": "使用下面的链接重置您的密码",
|
||||
"hey_there": "嘿,您好!",
|
||||
"forgot_your_password_calcom": "忘记密码?- {{appName}}",
|
||||
@@ -555,7 +555,7 @@
|
||||
"team_description": "请写一段简单的团队介绍,该介绍将会显示在您的团队链接页面上。",
|
||||
"org_description": "关于您的组织的几句话。这将显示在您组织的链接页面上。",
|
||||
"members": "成员",
|
||||
"organization_members": "组织成员",
|
||||
"organization_members": "Organization 成员",
|
||||
"member": "成员",
|
||||
"number_member_one": "{{count}} 个成员",
|
||||
"number_member_other": "{{count}} 个成员",
|
||||
@@ -1331,7 +1331,7 @@
|
||||
"download_responses_description": "以 CSV 格式下载对表格的所有回复。",
|
||||
"download": "下载",
|
||||
"download_recording": "下载录制内容",
|
||||
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录音可供下载",
|
||||
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录制内容可供下载",
|
||||
"create_your_first_form": "创建您的第一个表格",
|
||||
"create_your_first_form_description": "利用途径表格,您可以提出符合条件的问题,并可根据途径找到正确的人或活动类型。",
|
||||
"create_your_first_webhook": "创建您的第一个 Webhook",
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"attendee_no_longer_attending": "一名参与者不再参加您的活动",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} 已取消。这意味着该时段有一个位置空出来",
|
||||
"create_event_on": "创建活动于",
|
||||
"create_routing_form_on": "创建途径表格于",
|
||||
"create_routing_form_on": "创建途径表格的账户",
|
||||
"default_app_link_title": "设置默认应用链接",
|
||||
"default_app_link_description": "设置默认应用链接可以让所有新创建的活动类型使用您设置的应用链接。",
|
||||
"organizer_default_conferencing_app": "组织者的默认应用",
|
||||
@@ -1880,7 +1880,7 @@
|
||||
"connect_google_workspace": "连接 Google Workspace",
|
||||
"google_workspace_admin_tooltip": "您必须是 Workspace 管理员才能使用此功能",
|
||||
"first_event_type_webhook_description": "为此活动类型创建第一个 Webhook",
|
||||
"install_app_on": "安装应用在",
|
||||
"install_app_on": "安装应用的账户",
|
||||
"create_for": "创建",
|
||||
"setup_organization": "设置组织",
|
||||
"organization_banner_description": "创建一个环境,让团队可以在其中通过轮流和集体日程安排来创建共享的应用、工作流程和活动类型。",
|
||||
@@ -1942,7 +1942,7 @@
|
||||
"insights_all_org_filter": "所有应用",
|
||||
"insights_team_filter": "团队:{{teamName}}",
|
||||
"insights_user_filter": "用户:{{userName}}",
|
||||
"insights_subtitle": "查看您的活动的预约洞察",
|
||||
"insights_subtitle": "查看您的活动的预约 insights",
|
||||
"custom_plan": "自定义计划",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "團隊資訊",
|
||||
"request_another_invitation_email": "如果不想要使用 {{toEmail}} 來當作用在 {{appName}} 的電子郵件,或已有 {{appName}} 帳號,請另外索取該電信箱的邀請。",
|
||||
"you_have_been_invited": "收到加入 {{teamName}} 團隊的邀請",
|
||||
"user_invited_you": "{{user}} 已邀請您加入 {{appName}} 上的 {{entity}}{{team}} 團隊",
|
||||
"user_invited_you": "{{user}} 已邀請您加入 {{appName}} 上的 {{entity}}{{team}}",
|
||||
"hidden_team_member_title": "在團隊裡是隱藏的",
|
||||
"hidden_team_member_message": "此座位尚未付費。請升級至 Pro,或通知團隊主持人協助付費。",
|
||||
"hidden_team_owner_message": "要有 Pro 帳號才可以使用團隊,在升級之前都是隱藏的。",
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "謝謝您試用我們新的團隊方案。我們注意到您的團隊「{{teamName}}」需要升級。",
|
||||
"upgrade_banner_action": "在此處升級",
|
||||
"team_upgraded_successfully": "團隊成功升級!",
|
||||
"org_upgrade_banner_description": "謝謝您試用我們的團隊方案。我們注意到您的組織「{{teamName}}」需要升級。",
|
||||
"org_upgrade_banner_description": "謝謝您試用我們的 Organization 方案。我們注意到您的組織「{{teamName}}」需要升級。",
|
||||
"org_upgraded_successfully": "組織成功升級!",
|
||||
"use_link_to_reset_password": "使用以下連結重置密碼",
|
||||
"hey_there": "哈囉,",
|
||||
@@ -1661,7 +1661,7 @@
|
||||
"email_user_cta": "檢視邀請",
|
||||
"email_no_user_invite_heading": "您已獲邀加入 {{appName}} {{entity}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的團隊。{{appName}} 是活動多工排程工具,可讓您和您的團隊無須透過繁複的電子郵件往來就能輕鬆預定會議。",
|
||||
"email_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的 {{entity}}「{{teamName}}」團隊。{{appName}} 為一款活動多功能排程工具,可讓您和 {{entity}} 無須透過繁複的電子郵件往來,就能輕鬆預定會議。",
|
||||
"email_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的「{{teamName}}」{{entity}}。{{appName}} 為一款活動多功能排程工具,可讓您和 {{entity}} 無須透過繁複的電子郵件往來,就能輕鬆預定會議。",
|
||||
"email_no_user_invite_steps_intro": "我們會逐步引導您完成幾個簡短的步驟,然後您就能馬上與 {{entity}} 享受沒有壓力的排程預定功能。",
|
||||
"email_no_user_step_one": "選擇使用者名稱",
|
||||
"email_no_user_step_two": "連結行事曆帳號",
|
||||
@@ -1687,7 +1687,7 @@
|
||||
"attendee_no_longer_attending": "一位與會者不再參加您的活動",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} 已取消,這代表此時段空出了一個座位",
|
||||
"create_event_on": "活動建立日期",
|
||||
"create_routing_form_on": "建立引導表單於",
|
||||
"create_routing_form_on": "引導表單建立位置",
|
||||
"default_app_link_title": "設定預設應用程式連結",
|
||||
"default_app_link_description": "設定預設應用程式連結,即可讓所有全新建立的活動類型使用您設定的應用程式連結。",
|
||||
"organizer_default_conferencing_app": "主辦者的預設應用程式",
|
||||
@@ -1893,7 +1893,7 @@
|
||||
"organization_verify_email_body": "請使用以下驗證碼驗證您的電子郵件地址,以便繼續設定您的組織。",
|
||||
"additional_url_parameters": "其他網址參數",
|
||||
"about_your_organization": "關於您的組織",
|
||||
"about_your_organization_description": "您可在組織這個共用環境中建立多個團隊,包括共用成員、活動類型、應用程式、工作流程等。",
|
||||
"about_your_organization_description": "您可在組織這個共用環境中建立多個團隊,包括共享成員、活動類型、應用程式、工作流程等。",
|
||||
"create_your_teams": "建立您的團隊",
|
||||
"create_your_teams_description": "將團隊成員新增到組織,開始一起排程",
|
||||
"invite_organization_admins": "邀請組織管理員",
|
||||
@@ -1910,7 +1910,7 @@
|
||||
"email_already_used": "電子郵件已有人使用",
|
||||
"organization_admin_invited_heading": "您已獲邀加入 {{orgName}}",
|
||||
"organization_admin_invited_body": "加入您在 {{orgName}} 的團隊,開始專注於會議本身,不必費心於會議的前置作業!",
|
||||
"duplicated_slugs_warning": "由於重複的 slugs,下列團隊無法建立:{{slugs}}",
|
||||
"duplicated_slugs_warning": "由於 slug 重複,無法建立下列團隊:{{slugs}}",
|
||||
"team_names_empty": "團隊名稱不得留空",
|
||||
"team_names_repeated": "團隊名稱不得重複",
|
||||
"user_belongs_organization": "使用者屬於組織",
|
||||
|
||||
@@ -31,5 +31,7 @@ export async function ssrInit(context: GetServerSidePropsContext) {
|
||||
// Provides a better UX to the users who have already upgraded.
|
||||
await ssr.viewer.teams.hasTeamPlan.prefetch();
|
||||
|
||||
await ssr.viewer.public.session.prefetch();
|
||||
|
||||
return ssr;
|
||||
}
|
||||
|
||||
@@ -81,21 +81,6 @@ describe("Check Booking Limits Tests", () => {
|
||||
})
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it("Should return busyTimes when set", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(2);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
key: "PER_DAY",
|
||||
limitingNumber: 2,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
eventId: MOCK_DATA.id,
|
||||
returnBusyTimes: true,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
|
||||
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Booking limit validation", () => {
|
||||
|
||||
@@ -97,21 +97,6 @@ describe("Check Duration Limit Tests", () => {
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
it("Should return busyTimes when set and limit is reached", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
|
||||
await expect(
|
||||
checkDurationLimit({
|
||||
key: "PER_DAY",
|
||||
limitingNumber: 60,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
eventId: MOCK_DATA.id,
|
||||
returnBusyTimes: true,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
|
||||
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duration limit validation", () => {
|
||||
|
||||
@@ -274,6 +274,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
|
||||
@@ -358,6 +359,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
// getSchedule returns timeslots in GMT
|
||||
@@ -387,6 +389,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
|
||||
@@ -450,6 +453,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventWith30Length).toHaveTimeSlots(
|
||||
@@ -483,6 +487,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
// `slotInterval` takes precedence over `length`
|
||||
// 4:30 is utc so it is 10:00 in IST
|
||||
@@ -545,6 +550,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${minus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${todayDateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
|
||||
[
|
||||
@@ -563,6 +569,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${minus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${todayDateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
|
||||
[
|
||||
@@ -625,6 +632,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots(
|
||||
@@ -697,6 +705,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots(
|
||||
@@ -753,6 +762,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(schedule).toHaveTimeSlots(
|
||||
@@ -815,6 +825,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots(
|
||||
@@ -899,6 +910,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: false,
|
||||
});
|
||||
|
||||
expect(thisUserAvailability).toHaveTimeSlots(
|
||||
@@ -995,6 +1007,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${todayDateString}T18:30:00.000Z`,
|
||||
endTime: `${plus1DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots(
|
||||
@@ -1022,6 +1035,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
@@ -1128,6 +1142,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus1DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots(
|
||||
@@ -1153,6 +1168,7 @@ describe("getSchedule", () => {
|
||||
startTime: `${plus2DateString}T18:30:00.000Z`,
|
||||
endTime: `${plus3DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
});
|
||||
// A user with blocked time in another event, still affects Team Event availability
|
||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"jest-diff": "^29.5.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"lint-staged": "^12.5.0",
|
||||
"mailhog": "^4.16.0",
|
||||
"prettier": "^2.8.6",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^4.9.4",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const AppSetupMap = {
|
||||
zapier: dynamic(() => import("../../zapier/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
};
|
||||
|
||||
export const AppSetupPage = (props: { slug: string }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export const EventTypeAddonMap = {
|
||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
|
||||
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
|
||||
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
|
||||
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
|
||||
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
|
||||
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
|
||||
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appKeysSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -12,6 +12,7 @@ import cron_config_json from "./cron/config.json";
|
||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||
import discord_config_json from "./discord/config.json";
|
||||
import eightxeight_config_json from "./eightxeight/config.json";
|
||||
import element_call_config_json from "./element-call/config.json";
|
||||
import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata";
|
||||
import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata";
|
||||
import exchangecalendar_config_json from "./exchangecalendar/config.json";
|
||||
@@ -31,6 +32,7 @@ import mirotalk_config_json from "./mirotalk/config.json";
|
||||
import n8n_config_json from "./n8n/config.json";
|
||||
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
|
||||
import office365video_config_json from "./office365video/config.json";
|
||||
import paypal_config_json from "./paypal/config.json";
|
||||
import ping_config_json from "./ping/config.json";
|
||||
import pipedream_config_json from "./pipedream/config.json";
|
||||
import plausible_config_json from "./plausible/config.json";
|
||||
@@ -78,6 +80,7 @@ export const appStoreMetadata = {
|
||||
dailyvideo: dailyvideo__metadata_ts,
|
||||
discord: discord_config_json,
|
||||
eightxeight: eightxeight_config_json,
|
||||
"element-call": element_call_config_json,
|
||||
exchange2013calendar: exchange2013calendar__metadata_ts,
|
||||
exchange2016calendar: exchange2016calendar__metadata_ts,
|
||||
exchangecalendar: exchangecalendar_config_json,
|
||||
@@ -97,6 +100,7 @@ export const appStoreMetadata = {
|
||||
n8n: n8n_config_json,
|
||||
office365calendar: office365calendar__metadata_ts,
|
||||
office365video: office365video_config_json,
|
||||
paypal: paypal_config_json,
|
||||
ping: ping_config_json,
|
||||
pipedream: pipedream_config_json,
|
||||
plausible: plausible_config_json,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
|
||||
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
|
||||
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
|
||||
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
|
||||
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
|
||||
import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
|
||||
import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
|
||||
import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod";
|
||||
@@ -43,6 +44,7 @@ export const appDataSchemas = {
|
||||
metapixel: metapixel_zod_ts,
|
||||
office365calendar: office365calendar_zod_ts,
|
||||
office365video: office365video_zod_ts,
|
||||
paypal: paypal_zod_ts,
|
||||
plausible: plausible_zod_ts,
|
||||
qr_code: qr_code_zod_ts,
|
||||
"routing-forms": routing_forms_zod_ts,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const apiHandlers = {
|
||||
cron: import("./cron/api"),
|
||||
discord: import("./discord/api"),
|
||||
eightxeight: import("./eightxeight/api"),
|
||||
"element-call": import("./element-call/api"),
|
||||
exchange2013calendar: import("./exchange2013calendar/api"),
|
||||
exchange2016calendar: import("./exchange2016calendar/api"),
|
||||
exchangecalendar: import("./exchangecalendar/api"),
|
||||
@@ -31,6 +32,7 @@ export const apiHandlers = {
|
||||
n8n: import("./n8n/api"),
|
||||
office365calendar: import("./office365calendar/api"),
|
||||
office365video: import("./office365video/api"),
|
||||
paypal: import("./paypal/api"),
|
||||
ping: import("./ping/api"),
|
||||
pipedream: import("./pipedream/api"),
|
||||
plausible: import("./plausible/api"),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 3.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
- **Voice and Video Calls:** Users can make high-quality voice and video calls with their contacts or within specified rooms or groups.
|
||||
|
||||
- **End-to-End Encryption:** All communication is secured with end-to-end encryption, ensuring that messages and calls remain private and inaccessible to unauthorized parties.
|
||||
|
||||
- **Cross-Platform Compatibility:** Element supports various platforms, including web browsers, desktop applications, and mobile devices, allowing users to stay connected from anywhere.
|
||||
|
||||
- **Room-Based Communication:** Element utilizes "rooms" for organizing conversations, where users can join or create rooms to engage in group discussions or conferencing.
|
||||
|
||||
- **Message History and Synchronization:** Chat history is synchronized across devices, enabling users to seamlessly switch between platforms without losing past conversations.
|
||||
|
||||
- **Customizable Interface:** The interface can often be customized to suit individual preferences, offering a personalized user experience.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
|
||||
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
|
||||
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 }),
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Element Call",
|
||||
"slug": "element-call",
|
||||
"type": "element-call_conferencing",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://github.com/suyash5053/",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "Suyash Srivastava",
|
||||
"email": "suyashsrivastava50532gmail.com",
|
||||
"appData": {
|
||||
"location": {
|
||||
"type": "integrations:{SLUG}_video",
|
||||
"label": "{TITLE}",
|
||||
"linkType": "static",
|
||||
"organizerInputPlaceholder": "https://call.element.io/",
|
||||
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?call.element.io/[a-zA-Z0-9]*"
|
||||
}
|
||||
},
|
||||
"description": "Element is an open-source communication platform that provides messaging, voice calling, and video conferencing capabilities. It is based on the Matrix protocol, which is a decentralized and federated messaging protocol designed to enable secure and interoperable communication across different platforms and services.\"",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-location-video-static",
|
||||
"dirName": "element-call"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/element-call",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Element is an open-source communication platform that provides messaging, voice calling, and video conferencing capabilities. It is based on the Matrix protocol, which is a decentralized and federated messaging protocol designed to enable secure and interoperable communication across different platforms and services.\""
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_117_4)">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
<circle cx="64" cy="64" r="64" fill="#04BF8A"/>
|
||||
<mask id="path-2-inside-1_117_4" fill="white">
|
||||
<path d="M32.7099 74.7566C29.6119 75.9781 26.0314 74.4473 25.5846 71.1473C25.1747 68.1192 25.3114 65.0002 26.0042 61.9169C27.1525 56.8057 29.7838 51.9924 33.6065 48.0106C37.4291 44.0289 42.2937 41.0341 47.6612 39.3584C50.9977 38.3167 54.4475 37.8099 57.8599 37.8459C61.1841 37.8809 63.1528 41.2619 62.1562 44.4334V44.4334C61.2866 47.2009 58.433 48.7691 55.5372 48.9409C53.9259 49.0365 52.3131 49.3307 50.7418 49.8213C47.4989 50.8338 44.5597 52.6432 42.2501 55.0489C39.9405 57.4547 38.3507 60.3628 37.6569 63.4509C37.418 64.5141 37.2885 65.5844 37.2675 66.6474C37.2014 69.9769 35.8078 73.5351 32.7099 74.7566V74.7566Z"/>
|
||||
</mask>
|
||||
<path d="M32.7099 74.7566C29.6119 75.9781 26.0314 74.4473 25.5846 71.1473C25.1747 68.1192 25.3114 65.0002 26.0042 61.9169C27.1525 56.8057 29.7838 51.9924 33.6065 48.0106C37.4291 44.0289 42.2937 41.0341 47.6612 39.3584C50.9977 38.3167 54.4475 37.8099 57.8599 37.8459C61.1841 37.8809 63.1528 41.2619 62.1562 44.4334V44.4334C61.2866 47.2009 58.433 48.7691 55.5372 48.9409C53.9259 49.0365 52.3131 49.3307 50.7418 49.8213C47.4989 50.8338 44.5597 52.6432 42.2501 55.0489C39.9405 57.4547 38.3507 60.3628 37.6569 63.4509C37.418 64.5141 37.2885 65.5844 37.2675 66.6474C37.2014 69.9769 35.8078 73.5351 32.7099 74.7566V74.7566Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-2-inside-1_117_4)"/>
|
||||
<mask id="path-3-inside-2_117_4" fill="white">
|
||||
<path d="M52.9036 31.2935C51.7371 28.1744 53.331 24.6215 56.6383 24.2332C59.6732 23.8768 62.7894 24.0688 65.8599 24.8159C70.95 26.0545 75.716 28.7705 79.6295 32.663C83.543 36.5554 86.4513 41.4723 88.0318 46.8685C89.0144 50.2229 89.46 53.6812 89.3637 57.0924C89.2699 60.4155 85.8546 62.3241 82.7012 61.2715V61.2715C79.9496 60.353 78.4321 57.4722 78.3115 54.5738C78.2444 52.9611 77.9788 51.3433 77.5161 49.7636C76.5611 46.5033 74.804 43.5326 72.4395 41.1808C70.0749 38.829 67.1954 37.188 64.12 36.4396C63.0612 36.182 61.9934 36.0336 60.9309 35.9938C57.6031 35.8689 54.0701 34.4126 52.9036 31.2935V31.2935Z"/>
|
||||
</mask>
|
||||
<path d="M52.9036 31.2935C51.7371 28.1744 53.331 24.6215 56.6383 24.2332C59.6732 23.8768 62.7894 24.0688 65.8599 24.8159C70.95 26.0545 75.716 28.7705 79.6295 32.663C83.543 36.5554 86.4513 41.4723 88.0318 46.8685C89.0144 50.2229 89.46 53.6812 89.3637 57.0924C89.2699 60.4155 85.8546 62.3241 82.7012 61.2715V61.2715C79.9496 60.353 78.4321 57.4722 78.3115 54.5738C78.2444 52.9611 77.9788 51.3433 77.5161 49.7636C76.5611 46.5033 74.804 43.5326 72.4395 41.1808C70.0749 38.829 67.1954 37.188 64.12 36.4396C63.0612 36.182 61.9934 36.0336 60.9309 35.9938C57.6031 35.8689 54.0701 34.4126 52.9036 31.2935V31.2935Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-3-inside-2_117_4)"/>
|
||||
<mask id="path-4-inside-3_117_4" fill="white">
|
||||
<path d="M97.7468 52.0457C100.912 51.0099 104.395 52.7501 104.646 56.0708C104.876 59.1178 104.554 62.2233 103.68 65.2601C102.231 70.2944 99.3192 74.9433 95.2674 78.6916C91.2156 82.4399 86.1821 85.1412 80.7248 86.4961C77.3324 87.3383 73.8587 87.6398 70.4544 87.4017C67.1381 87.1698 65.3731 83.6782 66.5559 80.5713V80.5713C67.5879 77.8602 70.5294 76.4638 73.4303 76.4638C75.0444 76.4638 76.6718 76.2656 78.2694 75.869C81.5666 75.0504 84.6078 73.4183 87.0559 71.1536C89.5039 68.8889 91.2632 66.0801 92.1388 63.0385C92.4402 61.9913 92.6328 60.9306 92.7168 59.8706C92.98 56.551 94.5819 53.0816 97.7468 52.0457V52.0457Z"/>
|
||||
</mask>
|
||||
<path d="M97.7468 52.0457C100.912 51.0099 104.395 52.7501 104.646 56.0708C104.876 59.1178 104.554 62.2233 103.68 65.2601C102.231 70.2944 99.3192 74.9433 95.2674 78.6916C91.2156 82.4399 86.1821 85.1412 80.7248 86.4961C77.3324 87.3383 73.8587 87.6398 70.4544 87.4017C67.1381 87.1698 65.3731 83.6782 66.5559 80.5713V80.5713C67.5879 77.8602 70.5294 76.4638 73.4303 76.4638C75.0444 76.4638 76.6718 76.2656 78.2694 75.869C81.5666 75.0504 84.6078 73.4183 87.0559 71.1536C89.5039 68.8889 91.2632 66.0801 92.1388 63.0385C92.4402 61.9913 92.6328 60.9306 92.7168 59.8706C92.98 56.551 94.5819 53.0816 97.7468 52.0457V52.0457Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-4-inside-3_117_4)"/>
|
||||
<mask id="path-5-inside-4_117_4" fill="white">
|
||||
<path d="M75.895 95.1969C77.127 98.2907 75.6083 101.876 72.3099 102.334C69.2832 102.755 66.1637 102.628 63.0781 101.946C57.963 100.815 53.1408 98.2 49.1462 94.3909C45.1515 90.5817 42.1403 85.7273 40.4464 80.3655C39.3934 77.0326 38.8749 73.5845 38.8994 70.172C38.9232 66.8477 42.2974 64.8675 45.4723 65.8534V65.8534C48.2427 66.7137 49.8205 69.5619 50.0022 72.4571C50.1033 74.0681 50.4029 75.6799 50.8988 77.2495C51.9223 80.489 53.7416 83.422 56.1551 85.7234C58.5687 88.0249 61.4822 89.6048 64.5727 90.2882C65.6367 90.5235 66.7073 90.6493 67.7705 90.6668C71.1001 90.7215 74.663 92.1031 75.895 95.1969V95.1969Z"/>
|
||||
</mask>
|
||||
<path d="M75.895 95.1969C77.127 98.2907 75.6083 101.876 72.3099 102.334C69.2832 102.755 66.1637 102.628 63.0781 101.946C57.963 100.815 53.1408 98.2 49.1462 94.3909C45.1515 90.5817 42.1403 85.7273 40.4464 80.3655C39.3934 77.0326 38.8749 73.5845 38.8994 70.172C38.9232 66.8477 42.2974 64.8675 45.4723 65.8534V65.8534C48.2427 66.7137 49.8205 69.5619 50.0022 72.4571C50.1033 74.0681 50.4029 75.6799 50.8988 77.2495C51.9223 80.489 53.7416 83.422 56.1551 85.7234C58.5687 88.0249 61.4822 89.6048 64.5727 90.2882C65.6367 90.5235 66.7073 90.6493 67.7705 90.6668C71.1001 90.7215 74.663 92.1031 75.895 95.1969V95.1969Z" fill="white" stroke="white" stroke-width="2" mask="url(#path-5-inside-4_117_4)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_117_4">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -14,6 +14,7 @@ const appStore = {
|
||||
office365calendar: () => import("./office365calendar"),
|
||||
office365video: () => import("./office365video"),
|
||||
plausible: () => import("./plausible"),
|
||||
paypal: () => import("./paypal"),
|
||||
salesforce: () => import("./salesforce"),
|
||||
zohocrm: () => import("./zohocrm"),
|
||||
sendgrid: () => import("./sendgrid"),
|
||||
|
||||
@@ -133,6 +133,8 @@ export default class Office365CalendarService implements Calendar {
|
||||
dateFromParsed.toISOString()
|
||||
)}&endDateTime=${encodeURIComponent(dateToParsed.toISOString())}`;
|
||||
|
||||
const calendarSelectParams = "$select=showAs,start,end";
|
||||
|
||||
try {
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === this.integrationName)
|
||||
@@ -149,7 +151,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
const requests = ids.map((calendarId, id) => ({
|
||||
id,
|
||||
method: "GET",
|
||||
url: `/me/calendars/${calendarId}/calendarView${filter}`,
|
||||
url: `/me/calendars/${calendarId}/calendarView${filter}&${calendarSelectParams}`,
|
||||
}));
|
||||
const response = await this.apiGraphBatchCall(requests);
|
||||
const responseBody = await this.handleErrorJsonOffice365Calendar(response);
|
||||
@@ -181,8 +183,10 @@ export default class Office365CalendarService implements Calendar {
|
||||
const officeCalendars: OfficeCalendar[] = [];
|
||||
// List calendars from MS are paginated
|
||||
let finishedParsingCalendars = false;
|
||||
const calendarFilterParam = "$select=id,name,isDefaultCalendar,canEdit";
|
||||
|
||||
// Store @odata.nextLink if in response
|
||||
let requestLink = "/me/calendars";
|
||||
let requestLink = `/me/calendars?${calendarFilterParam}`;
|
||||
|
||||
while (!finishedParsingCalendars) {
|
||||
const response = await this.fetcher(requestLink);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- 1.jpeg
|
||||
- 2.jpeg
|
||||
- 3.jpeg
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import config from "../config.json";
|
||||
|
||||
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: "paypal",
|
||||
},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new Error("Unable to create user credential for Paypal");
|
||||
}
|
||||
} 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/paypal/setup" });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
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 { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
// Look if it's get
|
||||
if (req.method !== "GET") {
|
||||
throw new Error("Invalid method");
|
||||
}
|
||||
|
||||
// Look if bookingUid it's provided in query params using zod
|
||||
const parseRequest = captureRequestSchema.safeParse(req.query);
|
||||
if (!parseRequest.success) {
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
// Get bookingUid and token from query params
|
||||
const { bookingUid, token } = parseRequest.data;
|
||||
|
||||
// Get booking credentials
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
const credentials = await findPaymentCredentials(booking?.id);
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error("Credentials not found");
|
||||
}
|
||||
|
||||
// Get paypal instance
|
||||
const paypalClient = new Paypal(credentials);
|
||||
|
||||
// capture payment
|
||||
const capture = await paypalClient.captureOrder(token);
|
||||
|
||||
if (!capture) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
if (IS_PRODUCTION) {
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
} else {
|
||||
// For cal.dev, paypal sandbox doesn't send webhooks
|
||||
const updateBooking = prisma.booking.update({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
data: {
|
||||
paid: true,
|
||||
},
|
||||
});
|
||||
const updatePayment = prisma.payment.update({
|
||||
where: {
|
||||
id: booking?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
await Promise.all([updateBooking, updatePayment]);
|
||||
res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`);
|
||||
}
|
||||
return;
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
|
||||
res.status(200).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`);
|
||||
}
|
||||
}
|
||||
|
||||
const captureRequestSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/paypal/webhook";
|
||||
export { default as capture } from "./capture";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import { currencyOptions } from "@calcom/app-store/paypal/pages/setup";
|
||||
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 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) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
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;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
setAppData={setAppData}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
}}
|
||||
description={<>Add Paypal payment 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="$"
|
||||
addOnSuffix={currency || "No selected currency"}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
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) * 100);
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
}
|
||||
}}
|
||||
value={price > 0 ? price / 100 : 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;
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import z from "zod";
|
||||
|
||||
interface IPaypalPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentPaypalDataSchema = z.object({
|
||||
order: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
links: z.array(
|
||||
z.object({
|
||||
href: z.string(),
|
||||
rel: z.string(),
|
||||
method: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
capture: z.object({}).optional(),
|
||||
});
|
||||
|
||||
export const PaypalPaymentComponent = (props: IPaypalPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentPaypalDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.order?.links) {
|
||||
return wrongUrl;
|
||||
}
|
||||
const paymentUrl = parsedData.data.order.links.find(
|
||||
(link) => link.rel === "approve" || link.rel === "payer-action"
|
||||
)?.href;
|
||||
if (!paymentUrl) {
|
||||
return wrongUrl;
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||
<Link
|
||||
href={`${paymentUrl}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-[#ffc439] px-12 py-2 text-base
|
||||
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
Pay with
|
||||
<img src="/api/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
|
||||
<span />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Paypal",
|
||||
"slug": "paypal",
|
||||
"type": "paypal_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://example.com/link",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "Cal.com",
|
||||
"email": "support@cal.com",
|
||||
"description": "Paypal payment app by Cal.com",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"imageSrc": "icon.svg",
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "paypal"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
|
||||
export const paypalCredentialKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
secret_key: z.string(),
|
||||
webhook_id: z.string(),
|
||||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const orderResult = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/api/integrations/paypal/capture?bookingUid=${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
});
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
externalId: orderResult.id,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, { order: orderResult }) 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> {
|
||||
// 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");
|
||||
}
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const paypalClient = new Paypal({
|
||||
clientId: this.credentials.client_id,
|
||||
secretKey: this.credentials.secret_key,
|
||||
});
|
||||
const preference = await paypalClient.createOrder({
|
||||
referenceId: uid,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
returnUrl: `${WEBAPP_URL}/booking/${booking.uid}`,
|
||||
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
|
||||
intent: "AUTHORIZE",
|
||||
});
|
||||
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "paypal",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, preference) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
paymentOption: paymentOption || "ON_BOOKING",
|
||||
externalId: preference?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
class Paypal {
|
||||
url: string;
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
accessToken: string | null = null;
|
||||
expiresAt: number | null = null;
|
||||
|
||||
constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) {
|
||||
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
this.clientId = clientId;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<void> {
|
||||
if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) {
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.secretKey}`).toString("base64")}`,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.append("grant_type", "client_credentials");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (response.ok) {
|
||||
const { access_token, expires_in } = await response.json();
|
||||
this.accessToken = access_token;
|
||||
this.expiresAt = Date.now() + expires_in;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Orders
|
||||
async createOrder({
|
||||
referenceId,
|
||||
amount,
|
||||
currency,
|
||||
returnUrl,
|
||||
cancelUrl,
|
||||
intent = "CAPTURE",
|
||||
}: {
|
||||
referenceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
returnUrl: string;
|
||||
cancelUrl: string;
|
||||
intent?: "CAPTURE" | "AUTHORIZE";
|
||||
}): Promise<CreateOrderResponse> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"PayPal-Request-Id": uuidv4(),
|
||||
};
|
||||
|
||||
const createOrderRequestBody: CreateOrderRequestBody = {
|
||||
intent,
|
||||
purchase_units: [
|
||||
{
|
||||
reference_id: referenceId,
|
||||
amount: {
|
||||
currency_code: currency,
|
||||
value: (amount / 100).toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_source: {
|
||||
paypal: {
|
||||
experience_context: {
|
||||
user_action: "PAY_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(createOrderRequestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const createOrderResponse: CreateOrderResponse = await response.json();
|
||||
return createOrderResponse;
|
||||
} else {
|
||||
console.error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return {} as CreateOrderResponse;
|
||||
}
|
||||
|
||||
async captureOrder(orderId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (captureResult.ok) {
|
||||
const result = await captureResult.json();
|
||||
if (result.body.status === "COMPLETED") {
|
||||
// Get payment reference id
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: orderId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment?.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
data: Object.assign(
|
||||
{},
|
||||
{ ...(payment?.data as Record<string, string | number>), capture: result.body.id }
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Update booking as paid
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
data: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async createWebhook(): Promise<boolean | string> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
const body = {
|
||||
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
|
||||
event_types: [
|
||||
{
|
||||
name: "CHECKOUT.ORDER.APPROVED",
|
||||
},
|
||||
{
|
||||
name: "CHECKOUT.ORDER.COMPLETED",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result.id as string;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async listWebhooks(): Promise<string[]> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { webhooks } = await response.json();
|
||||
|
||||
return webhooks
|
||||
.filter((webhook: { id: string; url: string }) => {
|
||||
return webhook.url.includes("api/integrations/paypal/webhook");
|
||||
})
|
||||
.map((webhook: { id: string }) => webhook.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async deleteWebhook(webhookId: string): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
await this.getAccessToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async test(): Promise<boolean> {
|
||||
// Always get a new access token
|
||||
try {
|
||||
await this.getAccessToken();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyWebhook(options: WebhookEventVerifyRequest): Promise<void> {
|
||||
const url = `${this.url}/v1/notifications/verify-webhook-signature`;
|
||||
const parseRequest = webhookEventVerifyRequestSchema.safeParse(options);
|
||||
|
||||
// Webhook event should be parsable
|
||||
if (!parseRequest.success) {
|
||||
console.error(parseRequest.error);
|
||||
throw new Error("Request is malformed");
|
||||
}
|
||||
|
||||
const stringy = JSON.stringify({
|
||||
auth_algo: options.body.auth_algo,
|
||||
cert_url: options.body.cert_url,
|
||||
transmission_id: options.body.transmission_id,
|
||||
transmission_sig: options.body.transmission_sig,
|
||||
transmission_time: options.body.transmission_time,
|
||||
webhook_id: options.body.webhook_id,
|
||||
});
|
||||
|
||||
const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: bodyToString,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.verification_status !== "SUCCESS") {
|
||||
throw data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Paypal;
|
||||
|
||||
interface PurchaseUnit {
|
||||
amount: {
|
||||
currency_code: string;
|
||||
value: string;
|
||||
};
|
||||
reference_id: string;
|
||||
}
|
||||
|
||||
interface ExperienceContext {
|
||||
payment_method_preference?: string;
|
||||
payment_method_selected?: string;
|
||||
brand_name?: string;
|
||||
locale?: string;
|
||||
landing_page?: string;
|
||||
shipping_preference?: string;
|
||||
user_action: string;
|
||||
return_url: string;
|
||||
cancel_url: string;
|
||||
}
|
||||
|
||||
interface PaymentSource {
|
||||
paypal: {
|
||||
experience_context: ExperienceContext;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateOrderRequestBody {
|
||||
purchase_units: PurchaseUnit[];
|
||||
intent: string;
|
||||
payment_source?: PaymentSource;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
href: string;
|
||||
rel: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
interface CreateOrderResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
payment_source: PaymentSource;
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const webhookEventVerifyRequestSchema = z.object({
|
||||
body: z
|
||||
.object({
|
||||
auth_algo: z.string(),
|
||||
cert_url: z.string(),
|
||||
transmission_id: z.string(),
|
||||
transmission_sig: z.string(),
|
||||
transmission_time: z.string(),
|
||||
webhook_event: z.string(),
|
||||
webhook_id: z.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type WebhookEventVerifyRequest = z.infer<typeof webhookEventVerifyRequestSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./PaymentService";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/paypal",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Paypal payment app by Cal.com"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let clientId = "";
|
||||
let secretKey = "";
|
||||
const appKeys = await getAppKeysFromSlug("paypal");
|
||||
if (typeof appKeys.client_id === "string" && typeof appKeys.secret_key === "string") {
|
||||
clientId = appKeys.client_id;
|
||||
secretKey = appKeys.secret_key;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
clientId,
|
||||
secretKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
export interface PayPalSetupProps {
|
||||
public_key: string;
|
||||
access_token: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export const currencyOptions = [
|
||||
{ label: "United States dollar", value: "USD" },
|
||||
{ label: "Australian dollar", value: "AUD" },
|
||||
{ label: "Brazilian real 2", value: "BRL" },
|
||||
{ label: "Canadian dollar", value: "CAD" },
|
||||
{ label: "Chinese Renmenbi 3", value: "CNY" },
|
||||
{ label: "Czech koruna", value: "CZK" },
|
||||
{ label: "Danish krone", value: "DKK" },
|
||||
{ label: "Euro", value: "EUR" },
|
||||
{ label: "Hong Kong dollar", value: "HKD" },
|
||||
{ label: "Hungarian forint 1", value: "HUF" },
|
||||
{ label: "Israeli new shekel", value: "ILS" },
|
||||
{ label: "Japanese yen 1", value: "JPY" },
|
||||
{ label: "Malaysian ringgit 3", value: "MYR" },
|
||||
{ label: "Mexican peso", value: "MXN" },
|
||||
{ label: "New Taiwan dollar 1", value: "TWD" },
|
||||
{ label: "New Zealand dollar", value: "NZD" },
|
||||
{ label: "Norwegian krone", value: "NOK" },
|
||||
{ label: "Philippine peso", value: "PHP" },
|
||||
{ label: "Polish złoty", value: "PLN" },
|
||||
{ label: "Pound sterling", value: "GBP" },
|
||||
{ label: "Russian ruble", value: "RUB" },
|
||||
{ label: "Singapore dollar", value: "SGD" },
|
||||
{ label: "Swedish krona", value: "SEK" },
|
||||
{ label: "Swiss franc", value: "CHF" },
|
||||
{ label: "Thai baht", value: "THB" },
|
||||
];
|
||||
|
||||
export default function PayPalSetup(props: PayPalSetupProps) {
|
||||
const [newClientId, setNewClientId] = useState("");
|
||||
const [newSecretKey, setNewSecretKey] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" });
|
||||
const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment");
|
||||
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false];
|
||||
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 saveKeys = async (key: { clientId: string; secretKey: string }) => {
|
||||
if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) {
|
||||
return false;
|
||||
}
|
||||
if (typeof credentialId !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Test credentials before saving
|
||||
const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey });
|
||||
const test = await paypalClient.test();
|
||||
if (!test) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete all existing webhooks
|
||||
const webhooksToDelete = await paypalClient.listWebhooks();
|
||||
if (webhooksToDelete) {
|
||||
for (const webhook of webhooksToDelete) {
|
||||
await paypalClient.deleteWebhook(webhook);
|
||||
}
|
||||
}
|
||||
|
||||
// Create webhook for this installation
|
||||
const webhookId = await paypalClient.createWebhook();
|
||||
if (!webhookId) {
|
||||
// @TODO: make a button that tries to create the webhook again
|
||||
console.log("Failed to create webhook", webhookId);
|
||||
return false;
|
||||
}
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: {
|
||||
client_id: key.clientId,
|
||||
secret_key: key.secretKey,
|
||||
webhook_id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
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="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
|
||||
<p className="text-default mt-5 text-lg">Paypal</p>
|
||||
</div>
|
||||
<form autoComplete="off" className="mt-5">
|
||||
<TextField
|
||||
label="Client Id"
|
||||
type="text"
|
||||
name="client_id"
|
||||
id="client_id"
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Secret Key"
|
||||
type="password"
|
||||
name="access_token"
|
||||
id="access_token"
|
||||
value={newSecretKey}
|
||||
autoComplete="new-password"
|
||||
role="presentation"
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Button to submit */}
|
||||
<div className="mt-5 flex flex-row justify-end">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
saveKeys({
|
||||
clientId: newClientId,
|
||||
secretKey: newSecretKey,
|
||||
})
|
||||
}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
|
||||
|
||||
<ol className="text-default ml-1 list-decimal pl-2">
|
||||
{/* @TODO: translate */}
|
||||
<li>
|
||||
Log into your Paypal Developer account and create a new app{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://developer.paypal.com/dashboard/applications/"
|
||||
className="text-orange-600 underline">
|
||||
{t("here")}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Choose a name for your application.</li>
|
||||
<li>Select Online payments solution.</li>
|
||||
<li>Choose "No" for "Using online platform".</li>
|
||||
<li>CheckoutAPI as integration product.</li>
|
||||
<li>Accept terms and Create APP.</li>
|
||||
<li>Go back to dashboard, click on new app and copy the credentials.</li>
|
||||
<li>Paste them on the required field and save them.</li>
|
||||
<li>You're all setup.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>Paypal</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/paypal" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#001C64" d="M37.972 13.82c.107-5.565-4.485-9.837-10.799-9.837H14.115a1.278 1.278 0 0 0-1.262 1.079L7.62 37.758a1.038 1.038 0 0 0 1.025 1.2h7.737l-1.21 7.572a1.038 1.038 0 0 0 1.026 1.2H22.5c.305 0 .576-.11.807-.307.231-.198.269-.471.316-.772l1.85-10.885c.047-.3.2-.69.432-.888.231-.198.433-.306.737-.307H30.5c6.183 0 11.43-4.394 12.389-10.507.678-4.34-1.182-8.287-4.916-10.244Z"/>
|
||||
<path fill="#0070E0" d="m18.056 26.9-1.927 12.22-1.21 7.664a1.038 1.038 0 0 0 1.026 1.2h6.67a1.278 1.278 0 0 0 1.261-1.079l1.758-11.14a1.277 1.277 0 0 1 1.261-1.078h3.927c6.183 0 11.429-4.51 12.388-10.623.68-4.339-1.504-8.286-5.238-10.244-.01.462-.05.923-.121 1.38-.959 6.112-6.206 10.623-12.389 10.623h-6.145a1.277 1.277 0 0 0-1.261 1.077Z"/>
|
||||
<path fill="#003087" d="M16.128 39.12h-7.76a1.037 1.037 0 0 1-1.025-1.2l5.232-33.182a1.277 1.277 0 0 1 1.262-1.078h13.337c6.313 0 10.905 4.595 10.798 10.16-1.571-.824-3.417-1.295-5.44-1.295H21.413a1.278 1.278 0 0 0-1.261 1.078L18.057 26.9l-1.93 12.22Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="101" height="32" preserveAspectRatio="xMinYMin meet">
|
||||
<path fill="#003087" d="M12.237 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 20c-.1.4.2.7.6.7h3.7c.5 0 1-.4 1.1-.9l.8-5.4c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.1-1.3-3.1-2-5.7-2Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.7 1.1.5 2.1ZM35.437 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c.1-.2-.3-.6-.7-.6Zm-5.1 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .5.7.7 1.6.6 2.6ZM55.337 10h-3.7c-.4 0-.7.2-.9.5l-5.2 7.6-2.2-7.3c-.1-.5-.6-.8-1-.8h-3.7c-.4 0-.8.4-.6.9l4.1 12.1-3.9 5.4c-.3.4 0 1 .5 1h3.7c.4 0 .7-.2.9-.5l12.5-18c.3-.3 0-.9-.5-.9Z"/>
|
||||
<path fill="#009cde" d="M67.737 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 19.9c-.1.4.2.7.6.7h4c.4 0 .7-.3.7-.6l.9-5.7c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.2-1.2-3.1-1.9-5.7-1.9Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.6 1.1.5 2.1ZM90.937 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c0-.2-.3-.6-.7-.6Zm-5.2 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .6.7.8 1.6.6 2.6ZM95.337 3.3l-3.2 20.3c-.1.4.2.7.6.7h3.2c.5 0 1-.4 1.1-.9l3.2-19.9c.1-.4-.2-.7-.6-.7h-3.6c-.4 0-.6.2-.7.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |