Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9391bb5b64 | ||
|
|
d58924ecad | ||
|
|
fdef15712a | ||
|
|
a5b5382306 | ||
|
|
c2fceb2e0f | ||
|
|
a73065ba5d | ||
|
|
7b0f677bdb | ||
|
|
4166a0ab02 | ||
|
|
da80a2bae8 | ||
|
|
c6fd91d181 | ||
|
|
f62e6d9586 | ||
|
|
b03c6d5040 | ||
|
|
172d77dcc3 | ||
|
|
1a905aa330 | ||
|
|
a8da4b87f0 | ||
|
|
eb679a8e7d | ||
|
|
b364a85ed8 | ||
|
|
a2e0d44fb8 | ||
|
|
a9ee5d6f7f | ||
|
|
6d35750784 | ||
|
|
4f423ba692 | ||
|
|
d097c1fada | ||
|
|
fc049e12b3 | ||
|
|
267422a1f4 | ||
|
|
6e15de31aa | ||
|
|
2f6b5ced04 | ||
|
|
cf57be2aef | ||
|
|
c2996cd2f8 | ||
|
|
79944dfed1 | ||
|
|
e83647c652 | ||
|
|
556bf346f0 | ||
|
|
b98f2045d0 | ||
|
|
a13c050e9d | ||
|
|
e088196c8d | ||
|
|
9456ffbb48 | ||
|
|
6d19cc22a5 | ||
|
|
c69c9d6314 | ||
|
|
e0ef30eb52 | ||
|
|
045828d4ab | ||
|
|
354d57f393 | ||
|
|
650d0082a3 | ||
|
|
7c4b7209c2 | ||
|
|
5736821466 | ||
|
|
75f76c130a | ||
|
|
936432a730 | ||
|
|
4fda760e5e | ||
|
|
c467fd41ac | ||
|
|
0969d5960a | ||
|
|
cbe1907118 | ||
|
|
afd41475c9 | ||
|
|
f07c0b09fa | ||
|
|
d0707422b9 | ||
|
|
cc0c686e65 | ||
|
|
7057da7327 | ||
|
|
fbb27ceab0 | ||
|
|
01a5d588f3 | ||
|
|
5003ada671 | ||
|
|
54374cf43e | ||
|
|
55bca88d9b | ||
|
|
f755312ed7 | ||
|
|
207e0aac1f | ||
|
|
c8f7b36307 | ||
|
|
b5f0f0c0de | ||
|
|
816bfdd8f7 | ||
|
|
499b433d4d | ||
|
|
7b1fbd2853 | ||
|
|
9c2e15f016 | ||
|
|
4ac2c52c4e | ||
|
|
4a4cc7c766 | ||
|
|
648886f630 | ||
|
|
8d7b2cfe51 | ||
|
|
bfc2b8df34 | ||
|
|
a36f2aa871 | ||
|
|
6d8a576f60 | ||
|
|
b5e54d6cf9 | ||
|
|
ea20fa9f35 | ||
|
|
c3b2a52815 | ||
|
|
6bd97065e6 | ||
|
|
878a7cbfc5 | ||
|
|
9c84c3db0a | ||
|
|
1cf8ef8155 | ||
|
|
14aa0a2f2d | ||
|
|
5d73095060 | ||
|
|
08683a4883 | ||
|
|
2f3cc03255 | ||
|
|
b5fd33d689 | ||
|
|
6799e5f214 | ||
|
|
63e93fb12c | ||
|
|
94289a84f1 | ||
|
|
0f332cc35e |
+1
-6
@@ -121,6 +121,7 @@ TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
@@ -214,9 +215,3 @@ PROJECT_ID_VERCEL=
|
||||
TEAM_ID_VERCEL=
|
||||
# Get it from: https://vercel.com/account/tokens
|
||||
AUTH_BEARER_TOKEN_VERCEL=
|
||||
|
||||
#Enables New booker for Embed only
|
||||
NEW_BOOKER_ENABLED_FOR_EMBED=0
|
||||
|
||||
#Enables New booker for All but Embed requests
|
||||
NEW_BOOKER_ENABLED_FOR_NON_EMBED=0
|
||||
|
||||
@@ -20,8 +20,20 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Actual Results
|
||||
|
||||
- What's happening right now that is different from what is expected
|
||||
|
||||
### Expected Results
|
||||
|
||||
- This is an ideal result that the system should get after the tests are performed
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
### Evidence
|
||||
|
||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://cal.com/slack
|
||||
about: Ask a general question about the project on our Slack workspace
|
||||
url: https://go.cal.com/discord
|
||||
about: Ask a general question about the project on our discord community
|
||||
|
||||
@@ -39,3 +39,11 @@ assignees: ""
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Requirement/Document
|
||||
|
||||
<!--
|
||||
Is there any type of document that could support that feature?
|
||||
-->
|
||||
|
||||
(Share it here.)
|
||||
|
||||
@@ -8,26 +8,34 @@ Fixes # (issue)
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Requirement/Documentation
|
||||
|
||||
<!-- Please provide all documents that are important to understand the reason of that PR. -->
|
||||
- If there is a requirement document, please, share it here.
|
||||
- If there is ab UI/UX design document, please, share it here.
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Chore (refactoring code, technical debt, workflow improvements)
|
||||
- New feature (non-breaking change which adds functionality)
|
||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- This change requires a documentation update
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
- Are there environment variables that should be set?
|
||||
- What are the minimal test data to have?
|
||||
- What is expected (happy path) to have (input and output)?
|
||||
- Any other important info that could help to test that PR
|
||||
|
||||
## Mandatory Tasks
|
||||
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - scheduleWhatsappReminders
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron-scheduleWhatsappReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/workflows/scheduleWhatsappReminders \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
uses: crowdin/github-action@1.5.1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
push_translations: true
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Assign PR team labels
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
team-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: equitybee/team-label-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "app-store, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
@@ -23,6 +23,6 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [slack](https://calendso.slack.com/)
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
|
||||
@@ -17,7 +17,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)
|
||||
if (!hasValidLicense && process.env.NODE_ENV !== "development")
|
||||
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) {
|
||||
|
||||
@@ -47,7 +47,6 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* slug:
|
||||
* type: string
|
||||
* description: Unique slug for the event type
|
||||
* example: my-event
|
||||
* hosts:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -168,9 +167,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* type: string
|
||||
* displayLocationPublicly:
|
||||
* type: boolean
|
||||
* example:
|
||||
* examples:
|
||||
* event-type:
|
||||
* summary: An example of event type POST request
|
||||
* summary: An example of an individual event type POST request
|
||||
* value:
|
||||
* title: Hello World
|
||||
* slug: hello-world
|
||||
@@ -205,6 +204,42 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* team-event-type:
|
||||
* summary: An example of a team event type POST request
|
||||
* value:
|
||||
* title: "Tennis class"
|
||||
* slug: "tennis-class-{{$guid}}"
|
||||
* length: 60
|
||||
* hidden: false
|
||||
* position: 0
|
||||
* teamId: 3
|
||||
* eventName: null
|
||||
* timeZone: null
|
||||
* periodType: "UNLIMITED"
|
||||
* periodStartDate: null
|
||||
* periodEndDate: null
|
||||
* periodDays: null
|
||||
* periodCountCalendarDays: null
|
||||
* requiresConfirmation: true
|
||||
* recurringEvent:
|
||||
* interval: 2
|
||||
* count: 10
|
||||
* freq: 2
|
||||
* disableGuests: false
|
||||
* hideCalendarNotes: false
|
||||
* minimumBookingNotice: 120
|
||||
* beforeEventBuffer: 0
|
||||
* afterEventBuffer: 0
|
||||
* schedulingType: "COLLECTIVE"
|
||||
* price: 0
|
||||
* currency: "usd"
|
||||
* slotInterval: null
|
||||
* successRedirectUrl: null
|
||||
* description: null
|
||||
* locations:
|
||||
* - address: "London"
|
||||
* type: "inPerson"
|
||||
* metadata: {}
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
|
||||
@@ -2,29 +2,36 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { publishHandler } from "@calcom/trpc/server/routers/viewer/teams/publish.handler";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId";
|
||||
|
||||
import authMiddleware, { checkPermissions } from "./_auth-middleware";
|
||||
|
||||
const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] });
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const user = ctx.user;
|
||||
if (!user) {
|
||||
throw new Error("Internal Error.");
|
||||
async function sessionGetter() {
|
||||
return {
|
||||
user: {
|
||||
id: req.userId,
|
||||
username: "" /* Not used in this context */,
|
||||
role: req.isAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER,
|
||||
},
|
||||
hasValidLicense: true /* To comply with TS signature */,
|
||||
expires: "" /* Not used in this context */,
|
||||
};
|
||||
}
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||
return await publishHandler({ input: { teamId }, ctx: { ...ctx, user } });
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@calcom/ui": "*",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText } from "@calcom/ui";
|
||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
||||
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||
@@ -19,6 +20,7 @@ type AppListCardProps = {
|
||||
isTemplate?: boolean;
|
||||
invalidCredential?: boolean;
|
||||
children?: ReactNode;
|
||||
credentialOwner?: CredentialOwner;
|
||||
} & ShouldHighlight;
|
||||
|
||||
const schema = z.object({ hl: z.string().optional() });
|
||||
@@ -36,6 +38,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
isTemplate,
|
||||
invalidCredential,
|
||||
children,
|
||||
credentialOwner,
|
||||
} = props;
|
||||
const {
|
||||
data: { hl },
|
||||
@@ -65,7 +68,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3 px-5 py-4">
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -85,6 +88,21 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{credentialOwner && (
|
||||
<div>
|
||||
<Badge variant="gray">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className="mr-2"
|
||||
alt={credentialOwner.name || "Nameless"}
|
||||
size="xs"
|
||||
imageSrc={credentialOwner.avatar}
|
||||
/>
|
||||
{credentialOwner.name}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -53,10 +53,6 @@ function PageWrapper(props: AppProps) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
// Canonical: Check if the URL is from cal.com so that we set the canonical conditionally
|
||||
const isCalcom =
|
||||
WEBAPP_URL &&
|
||||
(new URL(WEBAPP_URL).hostname.endsWith("cal.com") || new URL(WEBAPP_URL).hostname.endsWith("cal.dev"));
|
||||
const path = router.asPath;
|
||||
|
||||
return (
|
||||
@@ -70,7 +66,7 @@ function PageWrapper(props: AppProps) {
|
||||
<DefaultSeo
|
||||
// Set canonical to https://cal.com or self-hosted URL
|
||||
canonical={
|
||||
isCalcom
|
||||
IS_CALCOM
|
||||
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
|
||||
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
|
||||
}
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import type { AppFrontendPayload as App } from "@calcom/types/App";
|
||||
import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuLabel,
|
||||
DropdownItem,
|
||||
Avatar,
|
||||
} from "@calcom/ui";
|
||||
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon";
|
||||
|
||||
/* These app slugs all require Google Cal to be installed */
|
||||
@@ -60,11 +75,14 @@ const Component = ({
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
onSettled(data) {
|
||||
const credentialsCount = data?.credentials.length || 0
|
||||
setShowDisconnectIntegration(data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0);
|
||||
setExistingCredentials(data?.credentials.map(credential => credential.id) || []);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -141,7 +159,7 @@ const Component = ({
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
{!appCredentials.isLoading ? (
|
||||
{!appDbQuery.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
@@ -166,28 +184,19 @@ const Component = ({
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={Plus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base"
|
||||
data-testid="install-app-button">
|
||||
{t("install_another")}
|
||||
</Button>
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} multiInstall {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
) : showDisconnectIntegration ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
appCredentials.refetch();
|
||||
appDbQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -206,15 +215,7 @@ const Component = ({
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{type, variant, slug}} credentials={appDbQuery.data?.credentials} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -385,3 +386,98 @@ export default function App(props: {
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const InstallAppButtonChild = ({
|
||||
userAdminTeams,
|
||||
addAppMutationInput,
|
||||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: App["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!userAdminTeams?.length || appCategories.some((category) => category === "calendar")) {
|
||||
return <Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
|
||||
const isInstalled = credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={
|
||||
isInstalled
|
||||
}
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p>{team.name}{" "}
|
||||
{isInstalled &&
|
||||
`(${t("installed")})`}</p>
|
||||
</DropdownItem>
|
||||
)})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { Link } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
const excludeNullValues = (value: unknown) => !!value;
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const renderLocations = locations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
if (eventLocationType.variable === "hostDefault") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translatedLocation = getTranslatedLocation(location, eventLocationType, t);
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Link className="text-default ml-[2px] h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const filteredLocations = renderLocations.filter(excludeNullValues) as JSX.Element[];
|
||||
return filteredLocations.length ? (
|
||||
<div className="text-default mr-6 flex w-full flex-col space-y-4 break-words text-sm">
|
||||
{filteredLocations}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -66,6 +66,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
);
|
||||
|
||||
const reserveSlot = (slot: Slot) => {
|
||||
// Prevent double clicking
|
||||
if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) {
|
||||
return;
|
||||
}
|
||||
reserveSlotMutation.mutate({
|
||||
slotUtcStartDate: slot.time,
|
||||
eventTypeId,
|
||||
@@ -132,8 +136,20 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot) {
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
}
|
||||
|
||||
const isHalfFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
|
||||
const colorClass = isNearlyFull
|
||||
? "text-rose-600"
|
||||
: isHalfFull
|
||||
? "text-yellow-500"
|
||||
: "text-emerald-400";
|
||||
|
||||
return (
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
@@ -144,7 +160,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
className={classNames(
|
||||
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}>
|
||||
)}
|
||||
data-testid="time"
|
||||
data-disabled="true">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
@@ -165,14 +183,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
data-disabled="false">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
<p
|
||||
className={`${
|
||||
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
||||
? "text-rose-600"
|
||||
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-emerald-400"
|
||||
} text-sm`}>
|
||||
<p className={`${colorClass} text-sm`}>
|
||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||
{seatsPerTimeSlot}{" "}
|
||||
{t("seats_available", {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { CheckSquare, Clock } from "@calcom/ui/components/icon";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
|
||||
import type { BookPageProps } from "../../pages/[user]/book";
|
||||
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
|
||||
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
|
||||
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
|
||||
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
|
||||
interface Props {
|
||||
profile:
|
||||
| AvailabilityPageProps["profile"]
|
||||
| HashLinkPageProps["profile"]
|
||||
| TeamBookingPageProps["profile"]
|
||||
| BookPageProps["profile"]
|
||||
| AvailabilityTeamPageProps["profile"]
|
||||
| DynamicAvailabilityPageProps["profile"];
|
||||
eventType:
|
||||
| AvailabilityPageProps["eventType"]
|
||||
| HashLinkPageProps["eventType"]
|
||||
| TeamBookingPageProps["eventType"]
|
||||
| BookPageProps["eventType"]
|
||||
| AvailabilityTeamPageProps["eventType"]
|
||||
| DynamicAvailabilityPageProps["eventType"];
|
||||
isBookingPage?: boolean;
|
||||
children: ReactNode;
|
||||
isMobile?: boolean;
|
||||
rescheduleUid?: string;
|
||||
}
|
||||
|
||||
const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!duration ||
|
||||
isNaN(Number(duration)) ||
|
||||
(eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration.includes(Number(duration)))
|
||||
) {
|
||||
setDuration(eventType.length);
|
||||
}
|
||||
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
let requiresConfirmationText = t("requires_confirmation");
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (isBookingPage) {
|
||||
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
} else {
|
||||
requiresConfirmationText = t("requires_confirmation_threshold", {
|
||||
...rcThreshold,
|
||||
unit: rcThreshold.unit.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size="sm"
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="text-default mb-2 mt-1 break-words text-sm font-medium ">
|
||||
{eventType.team?.parent?.name} {profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className=" text-default flex flex-col space-y-4 text-sm font-medium">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
"scroll-bar scrollbar-track-w-20 -mx-5 flex max-h-[180px] overflow-y-scroll px-5 ",
|
||||
isBookingPage && "text-default text-sm font-medium"
|
||||
)}>
|
||||
{/* TODO: Fix colors when token is introdcued to DS */}
|
||||
<div className="max-w-full flex-shrink break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requiresConfirmation && (
|
||||
<div className={classNames("items-top flex", isBookingPage && "text-default text-sm font-medium")}>
|
||||
<div>
|
||||
<CheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
</div>
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations
|
||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap text-sm font-medium",
|
||||
isBookingPage && "text-default",
|
||||
!eventType.metadata?.multipleDuration && "items-center"
|
||||
)}>
|
||||
<Clock
|
||||
className={classNames(
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||
!isBookingPage ? (
|
||||
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||
<li key={idx}>
|
||||
<Badge
|
||||
variant="gray"
|
||||
className={classNames(
|
||||
duration === dur.toString()
|
||||
? "bg-emphasis border-emphasis text-emphasis "
|
||||
: "bg-subtle text-default border-transparent ",
|
||||
"cursor-pointer border"
|
||||
)}
|
||||
onClick={() => {
|
||||
setDuration(dur);
|
||||
}}>
|
||||
{dur} {t("minute_timeUnit")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
`${duration} ${t("minutes")}`
|
||||
)
|
||||
) : (
|
||||
`${eventType.length} ${t("minutes")}`
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescription;
|
||||
@@ -1,292 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { HeadSeo, NumberInput, useCalcomTheme } from "@calcom/ui";
|
||||
import { CreditCard, User, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
import { SlotPicker } from "@components/booking/SlotPicker";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
/*const SlotPicker = dynamic(() => import("../SlotPicker").then((mod) => mod.SlotPicker), {
|
||||
ssr: false,
|
||||
loading: () => <div className="mt-8 px-4 pb-4 sm:mt-0 sm:p-4 md:min-w-[300px] md:px-5 lg:min-w-[455px]" />,
|
||||
});*/
|
||||
const TimezoneDropdown = dynamic(() => import("../TimezoneDropdown").then((mod) => mod.TimezoneDropdown), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor: string; darkBrandColor: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
const { rescheduleUid } = query;
|
||||
useTheme(profile.theme);
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
const { t, i18n } = useLocale();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState<TimeFormat>(detectBrowserTimeFormat);
|
||||
|
||||
const onTimeFormatChange = (is24Hours: boolean) => {
|
||||
setTimeFormat(is24Hours ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR);
|
||||
setIs24hClockInLocalStorage(is24Hours);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
/*
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
}
|
||||
}, [telemetry]); */
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
|
||||
const timezoneDropdown = useMemo(
|
||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||
[timeZone]
|
||||
);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
|
||||
{
|
||||
seatReferenceUid: rescheduleUid,
|
||||
},
|
||||
{
|
||||
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
meeting={{
|
||||
title: eventType.title,
|
||||
profile: { name: `${profile.name}`, image: profile.image },
|
||||
users: [
|
||||
...(eventType.users || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
}}
|
||||
nextSeoProps={{
|
||||
nofollow: eventType.hidden,
|
||||
noindex: eventType.hidden,
|
||||
}}
|
||||
isBrandingHidden={restProps.isBrandingHidden}
|
||||
/>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
"flex flex-col md:mx-4",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("bg-subtle dark:bg-default mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
<div>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted pb-4 md:pb-0",
|
||||
"border-booker md:border-booker-width md:rounded-md",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="md:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
" border-subtle flex flex-col p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap items-center text-sm font-medium",
|
||||
" text-default",
|
||||
"ltr:mr-[10px] rtl:ml-[10px]"
|
||||
)}>
|
||||
<User
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"mt-[2px]"
|
||||
)}
|
||||
/>{" "}
|
||||
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<RefreshCcw className="float-left ml-[2px] mt-[7px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<div>
|
||||
<p className="-ml-2 mb-1 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
|
||||
<NumberInput
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
isFullWidth={false}
|
||||
className="me-2 inline w-16"
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<CreditCard className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>
|
||||
{t("no_show_fee_amount", {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-default"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-subtle line-through ">
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-subtle" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
)}
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? ([
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
onTimeFormatChange={onTimeFormatChange}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
bookingAttendees={bookingAttendees || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
|
||||
{!restProps.isBrandingHidden && !isEmbed && <PoweredBy />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailabilityPage;
|
||||
@@ -1,704 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { createBooking, createRecurringBooking } from "@calcom/features/bookings/lib";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import {
|
||||
getBookingFieldsWithSystemFields,
|
||||
SystemField,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import getBookingResponsesSchema, {
|
||||
getBookingResponsesPartialSchema,
|
||||
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseDate, parseDateTimeWithTimeZone, parseRecurringDates } from "@calcom/lib/parse-dates";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, Form, Tooltip, useCalcomTheme } from "@calcom/ui";
|
||||
import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
|
||||
import type { BookPageProps } from "../../../pages/[user]/book";
|
||||
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const BookingDescriptionPayment = dynamic(
|
||||
() => import("@components/booking/BookingDescriptionPayment")
|
||||
) as unknown as typeof import("@components/booking/BookingDescriptionPayment").default;
|
||||
|
||||
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
const BookingFields = ({
|
||||
fields,
|
||||
locations,
|
||||
rescheduleUid,
|
||||
isDynamicGroupBooking,
|
||||
}: {
|
||||
fields: BookingPageProps["eventType"]["bookingFields"];
|
||||
locations: LocationObject[];
|
||||
rescheduleUid?: string;
|
||||
isDynamicGroupBooking: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const locationResponse = watch("responses.location");
|
||||
const currentView = rescheduleUid ? "reschedule" : "";
|
||||
|
||||
return (
|
||||
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||
|
||||
let noLabel = false;
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
|
||||
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.smsReminderNumber) {
|
||||
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
|
||||
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
|
||||
if (locationResponse?.value === "phone") {
|
||||
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
|
||||
// Just don't render the field now, as the value is already connected to attendee phone location
|
||||
return null;
|
||||
}
|
||||
// `smsReminderNumber` can be edited during reschedule even though it's a system field
|
||||
readOnly = false;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.guests) {
|
||||
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
|
||||
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (
|
||||
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
|
||||
!!rescheduleUid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamically populate location field options
|
||||
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
|
||||
if (!field.optionsInputs) {
|
||||
throw new Error("radioInput must have optionsInputs");
|
||||
}
|
||||
const optionsInputs = field.optionsInputs;
|
||||
|
||||
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
||||
const options = getLocationOptionsForSelect(locations, t);
|
||||
options.forEach((option) => {
|
||||
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
||||
if (optionInput) {
|
||||
optionInput.placeholder = option.inputPlaceholder;
|
||||
}
|
||||
});
|
||||
field.options = options.filter(
|
||||
(location): location is NonNullable<(typeof options)[number]> => !!location
|
||||
);
|
||||
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
|
||||
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
|
||||
if (field.options.length === 1) {
|
||||
if (field.optionsInputs[field.options[0].value]) {
|
||||
noLabel = true;
|
||||
} else {
|
||||
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
|
||||
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
|
||||
|
||||
return (
|
||||
<FormBuilderField
|
||||
className="mb-4"
|
||||
field={{ ...field, label, placeholder, hidden }}
|
||||
readOnly={readOnly}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const routerQuerySchema = z
|
||||
.object({
|
||||
timeFormat: z.nativeEnum(TimeFormat),
|
||||
rescheduleUid: z.string().optional(),
|
||||
date: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((date) => {
|
||||
if (date === undefined) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
currentSlotBooking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
...restProps
|
||||
}: BookingPageProps) => {
|
||||
const removeSelectedSlotMarkMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation();
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
|
||||
const { t, i18n } = useLocale();
|
||||
const { duration: queryDuration } = useRouterQuery("duration");
|
||||
const { date: queryDate } = useRouterQuery("date");
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { timezone } = useTimePreferences();
|
||||
|
||||
const reserveSlot = () => {
|
||||
if (queryDuration) {
|
||||
reserveSlotMutation.mutate({
|
||||
eventTypeId: eventType.id,
|
||||
slotUtcStartDate: dayjs(queryDate).utc().format(),
|
||||
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
|
||||
bookingUid: currentSlotBooking?.uid,
|
||||
});
|
||||
}
|
||||
};
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (
|
||||
queryDuration &&
|
||||
!isNaN(Number(queryDuration)) &&
|
||||
eventType.metadata?.multipleDuration &&
|
||||
eventType.metadata?.multipleDuration.includes(Number(queryDuration))
|
||||
) {
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
/* if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
);
|
||||
} */
|
||||
reserveSlot();
|
||||
const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
removeSelectedSlotMarkMutation.mutate();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid } = responseData;
|
||||
|
||||
if ("paymentUid" in responseData && !!responseData.paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid: responseData.paymentUid,
|
||||
date,
|
||||
name: bookingForm.getValues("responses.name"),
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
};
|
||||
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { uid } = responseData[0] || {};
|
||||
const query = {
|
||||
isSuccessBookingPage: true,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
};
|
||||
return bookingSuccessRedirect({
|
||||
router,
|
||||
successRedirectUrl: eventType.successRedirectUrl,
|
||||
query,
|
||||
bookingUid: uid,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: { timeFormat, rescheduleUid, date },
|
||||
} = useTypedQuery(routerQuerySchema);
|
||||
|
||||
useTheme(profile.theme);
|
||||
useBrandColors({
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
});
|
||||
|
||||
const querySchema = getBookingResponsesPartialSchema({
|
||||
eventType: {
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
},
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = querySchema.parse({
|
||||
...router.query,
|
||||
// `guest` because we need to support legacy URL with `guest` query param support
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: router.query.guests || router.query.guest,
|
||||
});
|
||||
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: LocationObject[] = useMemo(
|
||||
() => (eventType.locations as LocationObject[]) || [],
|
||||
[eventType.locations]
|
||||
);
|
||||
|
||||
const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
// THis is to fix hydration error that comes because of different timezone on server and client
|
||||
setIsClientTimezoneAvailable(true);
|
||||
}, []);
|
||||
|
||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||
|
||||
// There should only exists one default userData variable for primaryAttendee.
|
||||
const defaultUserValues = {
|
||||
email: rescheduleUid ? booking?.attendees[0].email : parsedQuery["email"],
|
||||
name: rescheduleUid ? booking?.attendees[0].name : parsedQuery["name"],
|
||||
};
|
||||
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: parsedQuery[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||
};
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (!booking || !booking.attendees.length) {
|
||||
return {};
|
||||
}
|
||||
const primaryAttendee = booking.attendees[0];
|
||||
if (!primaryAttendee) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
|
||||
};
|
||||
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: booking.responses[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
...responses,
|
||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||
};
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
responses: getBookingResponsesSchema({
|
||||
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type BookingFormValues = {
|
||||
locationType?: EventLocationType["type"];
|
||||
responses: z.infer<typeof bookingFormSchema>["responses"];
|
||||
};
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||
});
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
selectedTimeFormat: timeFormat,
|
||||
},
|
||||
i18n.language
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (bookingValues: BookingFormValues) => {
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
);
|
||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||
|
||||
// @TODO: move to metadata
|
||||
const metadata = Object.keys(router.query)
|
||||
.filter((key) => key.startsWith("metadata"))
|
||||
.reduce(
|
||||
(metadata, key) => ({
|
||||
...metadata,
|
||||
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...bookingValues,
|
||||
start: dayjs(recurringDate).utc().format(),
|
||||
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
// Added to track down the number of actual occurrences selected by the user
|
||||
recurringCount: recurringDates.length,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...bookingValues,
|
||||
start: dayjs(date).utc().format(),
|
||||
end: dayjs(date).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: (router.query.bookingUid as string) || booking?.uid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
seatReferenceUid: router.query.seatReferenceUid as string,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid
|
||||
? t("booking_reschedule_confirmation", {
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})
|
||||
: t("booking_confirmation", {
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})}{" "}
|
||||
| {APP_NAME}
|
||||
</title>
|
||||
<link rel="icon" href="/favico.ico" />
|
||||
</Head>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"main",
|
||||
isBackgroundTransparent ? "" : "bg-default dark:bg-muted",
|
||||
"border-booker sm:border-booker-width rounded-md"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:border-subtle text-default flex flex-col px-6 pb-0 pt-6 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="text-default items-start text-sm font-medium">
|
||||
<RefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Calendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-sm font-medium">
|
||||
{isClientTimezoneAvailable &&
|
||||
(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
`${parseDate(date, i18n.language, { selectedTimeFormat: timeFormat })}`}
|
||||
{isClientTimezoneAvailable &&
|
||||
!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType?.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className=" text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mb-2 mt-8 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Calendar className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{isClientTimezoneAvailable &&
|
||||
typeof booking.startTime === "string" &&
|
||||
parseDateTimeWithTimeZone(booking.startTime, i18n.language, timezone, {
|
||||
selectedTimeFormat: timeFormat,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<User
|
||||
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot}{" "}
|
||||
{t("seats_available", {
|
||||
count: currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||
<Form form={bookingForm} noValidate handleSubmit={bookEvent}>
|
||||
<BookingFields
|
||||
isDynamicGroupBooking={isDynamicGroupBooking}
|
||||
fields={eventType.bookingFields}
|
||||
locations={locations}
|
||||
rescheduleUid={rescheduleUid}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-end space-x-2 rtl:space-x-reverse",
|
||||
// HACK: If the last field is guests, we need to move Cancel, Submit buttons up because "Add Guests" being present on the left and the buttons on the right, spacing is not required
|
||||
eventType.bookingFields[eventType.bookingFields.length - 1].name ===
|
||||
SystemField.Enum.guests
|
||||
? "-mt-4"
|
||||
: ""
|
||||
)}>
|
||||
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{mutation.isError || recurringMutation.isError ? (
|
||||
<ErrorMessage error={mutation.error || recurringMutation.error} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingPage;
|
||||
|
||||
function ErrorMessage({ error }: { error: unknown }) {
|
||||
const { t } = useLocale();
|
||||
const { query: { rescheduleUid } = {} } = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-blue-400 bg-blue-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ms-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{error instanceof HttpError || error instanceof Error ? (
|
||||
<>
|
||||
{t("can_you_try_again")}{" "}
|
||||
<span className="cursor-pointer underline" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</span>
|
||||
.
|
||||
</> /* t(error.message) */
|
||||
) : (
|
||||
"Unknown error"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getHumanReadableLocationValue,
|
||||
getMessageForOrganizer,
|
||||
LocationType,
|
||||
OrganizerDefaultConferencingAppType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -33,12 +34,12 @@ interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: LocationOption;
|
||||
booking?: BookingItem;
|
||||
isTeamEvent?: boolean;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: LocationOption | undefined) => void;
|
||||
setEditingLocationType?: (param: string) => void;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
const LocationInput = (props: {
|
||||
@@ -78,15 +79,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
isTeamEvent,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
setEditingLocationType,
|
||||
teamId,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId });
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
@@ -102,6 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
locationType: z.string(),
|
||||
phone: z.string().optional().nullable(),
|
||||
locationAddress: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -172,8 +174,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(defaultLocation);
|
||||
|
||||
const LocationOptions = (() => {
|
||||
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
||||
if (!eventLocationType.variable) {
|
||||
@@ -297,6 +297,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (values.credentialId) {
|
||||
details = { ...details, credentialId: values.credentialId };
|
||||
}
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
@@ -311,8 +314,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
query={locationsQuery}
|
||||
success={({ data }) => {
|
||||
if (!data.length) return null;
|
||||
const locationOptions = [...data].filter((option) => {
|
||||
return !isTeamEvent ? option.label !== "Conferencing" : true;
|
||||
const locationOptions = [...data].map((option) => {
|
||||
if (teamId) {
|
||||
// Let host's Default conferencing App option show for Team Event
|
||||
return option;
|
||||
}
|
||||
return {
|
||||
...option,
|
||||
options: option.options.filter((o) => o.value !== OrganizerDefaultConferencingAppType),
|
||||
};
|
||||
});
|
||||
if (booking) {
|
||||
locationOptions.map((location) =>
|
||||
|
||||
@@ -16,12 +16,16 @@ export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
|
||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
||||
const { data: eventTypeApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
teamId: eventType.team?.id || eventType.parent?.teamId,
|
||||
});
|
||||
|
||||
const methods = useFormContext<FormValues>();
|
||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||
const installedApps =
|
||||
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
|
||||
const notInstalledApps =
|
||||
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
|
||||
const allAppsData = methods.watch("metadata")?.apps || {};
|
||||
|
||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||
@@ -62,11 +66,54 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const appsWithTeamCredentials = eventTypeApps?.items.filter((app) => app.teams.length) || [];
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug + team?.credentialId}
|
||||
app={{
|
||||
...app,
|
||||
// credentialIds: team?.credentialId ? [team.credentialId] : [],
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logo,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
},
|
||||
}}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{!installedApps?.length && isManagedEventType && (
|
||||
{isManagedEventType && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
className="mb-2"
|
||||
@@ -92,15 +139,20 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{installedApps?.map((app) => (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
/>
|
||||
))}
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{installedApps.map((app) => {
|
||||
if (!app.teams.length)
|
||||
return (
|
||||
<EventTypeAppCard
|
||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||
key={app.slug}
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
|
||||
@@ -530,7 +530,6 @@ export const EventSetupTab = (
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isTeamEvent={!!team}
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
@@ -553,6 +552,7 @@ export const EventSetupTab = (
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -210,7 +211,7 @@ function EventTypeSingleLayout({
|
||||
}
|
||||
if (isManagedEventType || isChildrenManagedEventType) {
|
||||
// Removing apps and workflows for manageg event types by admins v1
|
||||
navigation.splice(-2, 1);
|
||||
navigation.splice(0, 1);
|
||||
} else {
|
||||
navigation.push({
|
||||
name: "webhooks",
|
||||
@@ -233,9 +234,11 @@ function EventTypeSingleLayout({
|
||||
formMethods,
|
||||
]);
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const isOrgEvent = orgBranding?.fullDomain;
|
||||
const permalink = `${orgBranding?.fullDomain ?? CAL_URL}/${
|
||||
team ? `${!isOrgEvent ? "team/" : ""}${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
|
||||
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||
|
||||
@@ -20,7 +20,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.credentialIds.length > 0
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -38,7 +38,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
logo={item.logo}
|
||||
installed={item.credentialIds.length > 0}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -22,15 +22,13 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { nextStep } = props;
|
||||
|
||||
const router = useRouter();
|
||||
let queryAvailability;
|
||||
if (defaultScheduleId) {
|
||||
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId },
|
||||
{
|
||||
enabled: router.isReady,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId! },
|
||||
{
|
||||
enabled: router.isReady && !!defaultScheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
const availabilityForm = useForm({
|
||||
defaultValues: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
@@ -12,7 +11,7 @@ type TeamType = NonNullable<TeamWithMembers>;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number] & { safeBio: string | null };
|
||||
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const { t } = useLocale();
|
||||
@@ -25,11 +24,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
return (
|
||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || ""}
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
/>
|
||||
<Avatar size="md" alt={member.name || ""} imageSrc={"/" + member.username + "/avatar.png"} />
|
||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -116,7 +117,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
||||
inputUsernameValue || usernameFromStripe
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`;
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${router.asPath}`;
|
||||
|
||||
const ActionButtons = () => {
|
||||
if (paymentRequired) {
|
||||
|
||||
@@ -135,7 +135,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
||||
<span className={classNames("mx-2 py-2.5")}>
|
||||
<span className={classNames("mx-2 py-3.5")}>
|
||||
{usernameIsAvailable ? <Check className="h-4 w-4" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -40,11 +40,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
name="location"
|
||||
id="location-select"
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
),
|
||||
Option: (props) => {
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
);
|
||||
},
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
|
||||
@@ -10,10 +10,6 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
/**
|
||||
* We are using env variable to toggle new-booker because using flags would be an unnecessary delay for booking pages
|
||||
* Also, we can't easily identify the booker page requests here(to just fetch the flags for those requests)
|
||||
*/
|
||||
|
||||
if (isIpInBanlist(req) && url.pathname !== "/api/nope") {
|
||||
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
|
||||
|
||||
+67
-124
@@ -4,8 +4,12 @@ const os = require("os");
|
||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
const { pages } = require("./pages");
|
||||
const { getSubdomainRegExp } = require("./getSubdomainRegExp");
|
||||
const {
|
||||
orgHostPath,
|
||||
orgUserRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
orgUserTypeEmbedRoutePath,
|
||||
} = require("./pagesAndRewritePaths");
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
@@ -81,52 +85,44 @@ if (process.env.ANALYZE === "true") {
|
||||
}
|
||||
|
||||
plugins.push(withAxiom);
|
||||
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
||||
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
|
||||
|
||||
// Important Note: When modifying these RegExps update apps/web/test/lib/next-config.test.ts as well
|
||||
const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
|
||||
const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
|
||||
const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
|
||||
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
|
||||
const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
|
||||
const subdomainRegExp = getSubdomainRegExp(process.env.NEXT_PUBLIC_WEBAPP_URL);
|
||||
// Important Note: Do update the RegExp in apps/web/test/lib/next-config.test.ts when changing it.
|
||||
const orgHostRegExp = `^(?<orgSlug>${subdomainRegExp})\\..*`;
|
||||
|
||||
const matcherConfigRootPath = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostRegExp,
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: "/",
|
||||
};
|
||||
|
||||
const matcherConfigOrgMemberPath = {
|
||||
const matcherConfigUserRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostRegExp,
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: `/:user((?!${pages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`,
|
||||
source: orgUserRoutePath,
|
||||
};
|
||||
|
||||
const matcherConfigUserPath = {
|
||||
const matcherConfigUserTypeRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: `^(?<orgSlug>${subdomainRegExp}[^.]+)\\..*`,
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: `/:user((?!${pages.join("|")}|_next|public))/:path*`,
|
||||
source: orgUserTypeRoutePath,
|
||||
};
|
||||
|
||||
const matcherConfigUserTypeEmbedRoute = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: orgUserTypeEmbedRoutePath,
|
||||
};
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
@@ -220,6 +216,7 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(process.env.ORGANIZATIONS_ENABLED
|
||||
? [
|
||||
{
|
||||
@@ -227,12 +224,16 @@ const nextConfig = {
|
||||
destination: "/team/:orgSlug",
|
||||
},
|
||||
{
|
||||
...matcherConfigOrgMemberPath,
|
||||
...matcherConfigUserRoute,
|
||||
destination: "/org/:orgSlug/:user",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserPath,
|
||||
destination: "/:user/:path*",
|
||||
...matcherConfigUserTypeRoute,
|
||||
destination: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
destination: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -243,115 +244,48 @@ const nextConfig = {
|
||||
source: "/org/:slug",
|
||||
destination: "/team/:slug",
|
||||
},
|
||||
{
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
},
|
||||
{
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
// Keep cookie based booker enabled just in case we disable new-booker globally
|
||||
|
||||
// When updating this also update pagesAndRewritePaths.js
|
||||
...[
|
||||
{
|
||||
source: userTypeRouteRegExp,
|
||||
destination: "/new-booker/:user/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: teamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: privateLinkRouteRegExp,
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
],
|
||||
// Keep cookie based booker enabled to test new-booker embed in production
|
||||
...[
|
||||
{
|
||||
source: embedUserTypeRouteRegExp,
|
||||
destination: "/new-booker/:user/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: embedTeamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
],
|
||||
|
||||
/* TODO: have these files being served from another deployment or CDN {
|
||||
source: "/embed/embed.js",
|
||||
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
||||
}, */
|
||||
|
||||
/**
|
||||
* Enables new booker using cookie. It works even if NEW_BOOKER_ENABLED_FOR_NON_EMBED, NEW_BOOKER_ENABLED_FOR_EMBED are disabled
|
||||
*/
|
||||
];
|
||||
|
||||
// Enable New Booker for all Embed Requests
|
||||
if (process.env.NEW_BOOKER_ENABLED_FOR_EMBED === "1") {
|
||||
console.log("Enabling New Booker for Embed");
|
||||
afterFiles.push(
|
||||
...[
|
||||
{
|
||||
source: embedUserTypeRouteRegExp,
|
||||
destination: "/new-booker/:user/:type/embed",
|
||||
},
|
||||
{
|
||||
source: embedTeamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type/embed",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Enable New Booker for All but embed Requests
|
||||
if (process.env.NEW_BOOKER_ENABLED_FOR_NON_EMBED === "1") {
|
||||
console.log("Enabling New Booker for Non-Embed");
|
||||
afterFiles.push(
|
||||
...[
|
||||
{
|
||||
source: userTypeRouteRegExp,
|
||||
destination: "/new-booker/:user/:type",
|
||||
},
|
||||
{
|
||||
source: teamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
},
|
||||
{
|
||||
source: privateLinkRouteRegExp,
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
return {
|
||||
beforeFiles,
|
||||
afterFiles,
|
||||
@@ -401,7 +335,7 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigOrgMemberPath,
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
@@ -410,11 +344,20 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserPath,
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/:user/:path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.0.11",
|
||||
"version": "3.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
const glob = require("glob");
|
||||
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
const pages = glob
|
||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
||||
.map((filename) =>
|
||||
filename
|
||||
.substr(6)
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
|
||||
|
||||
exports.pages = pages;
|
||||
+34
-16
@@ -4,10 +4,10 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { DOCS_URL, JOIN_DISCORD, WEBSITE_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { BookOpen, Check, ChevronRight, FileText, Slack, Shield } from "@calcom/ui/components/icon";
|
||||
import { BookOpen, Check, ChevronRight, FileText, Shield } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
@@ -58,16 +58,21 @@ export default function Custom404() {
|
||||
// Accessing a non-existent team
|
||||
setUsername(splitPath[2]);
|
||||
setCurrentPageType(pageType.TEAM);
|
||||
setUrl(`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${username.replace("/", "")}`);
|
||||
setUrl(
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${splitPath[2].replace("/", "")}`
|
||||
);
|
||||
} else {
|
||||
setUsername(routerUsername);
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
|
||||
}
|
||||
} else {
|
||||
setUsername(currentOrgDomain);
|
||||
setCurrentPageType(pageType.ORG);
|
||||
setUrl(
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${username.replace("/", "")}`
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
|
||||
"/",
|
||||
""
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
@@ -75,7 +80,6 @@ export default function Custom404() {
|
||||
const isSuccessPage = router.asPath.startsWith("/booking");
|
||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = router.asPath.startsWith("/signup");
|
||||
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
||||
/**
|
||||
* If we're on 404 and the route is insights it means it is disabled
|
||||
* TODO: Abstract this for all disabled features
|
||||
@@ -197,18 +201,25 @@ export default function Custom404() {
|
||||
</li>
|
||||
<li className="px-4 py-2">
|
||||
<a
|
||||
href={JOIN_SLACK}
|
||||
href={JOIN_DISCORD}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Slack strokeWidth="1.5" fill="currentColor" className="h-6 w-6" />
|
||||
<svg
|
||||
className="text-default h-6 w-6"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 127.14 96.36">
|
||||
<title>Discord Logo</title>
|
||||
<path d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Slack
|
||||
Discord
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||
@@ -242,7 +253,7 @@ export default function Custom404() {
|
||||
<span className="mt-2 inline-block text-lg ">
|
||||
{t("check_spelling_mistakes_or_go_back")}
|
||||
</span>
|
||||
) : isCalcom ? (
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-blue-500">
|
||||
@@ -260,7 +271,7 @@ export default function Custom404() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
{((!isSubpage && isCalcom) ||
|
||||
{((!isSubpage && IS_CALCOM) ||
|
||||
currentPageType === pageType.ORG ||
|
||||
currentPageType === pageType.TEAM) && (
|
||||
<ul role="list" className="my-4">
|
||||
@@ -310,7 +321,7 @@ export default function Custom404() {
|
||||
.filter((_, idx) => currentPageType === pageType.ORG || idx !== 0)
|
||||
.map((link, linkIdx) => (
|
||||
<li key={linkIdx} className="px-4 py-2">
|
||||
<Link
|
||||
<a
|
||||
href={link.href}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -330,23 +341,30 @@ export default function Custom404() {
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li className="px-4 py-2">
|
||||
<a
|
||||
href={JOIN_SLACK}
|
||||
href={JOIN_DISCORD}
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Slack strokeWidth="1.5" fill="currentColor" className="h-6 w-6" />
|
||||
<svg
|
||||
className="text-default h-6 w-6"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 127.14 96.36">
|
||||
<title>Discord Logo</title>
|
||||
<path d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-base font-medium">
|
||||
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Slack
|
||||
Discord
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-subtle text-base">{t("join_our_community")}</p>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import defaultEvents, {
|
||||
getDynamicEventDescription,
|
||||
getGroupName,
|
||||
@@ -291,7 +290,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const users = usersWithoutAvatar.map((user) => ({
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
avatar: `/${user.username}/avatar.png`,
|
||||
}));
|
||||
|
||||
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
||||
|
||||
+122
-320
@@ -1,281 +1,60 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } 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 { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return props.away ? (
|
||||
<div className="dark:bg-inverted h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
😴{" " + t("user_away")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : props.isDynamic && !props.profile.allowDynamicBooking ? (
|
||||
<div className="dark:bg-darkgray-50 h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("unavailable")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : !props.isValidOrgDomain && props.organizationContext ? (
|
||||
<div className="dark:bg-darkgray-50 h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("unavailable")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_belongs_organization")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<AvailabilityPage {...props} />
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden, rescheduleUid }: PageProps) {
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
return (
|
||||
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={rescheduleUid ?? undefined}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.isBookingPage = true;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
const paramsSchema = z.object({ type: z.string(), user: z.string() });
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
// load server side dependencies
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
|
||||
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
|
||||
const { EventTypeMetaDataSchema, teamMetadataSchema } = await import("@calcom/prisma/zod-utils");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const ssr = await ssrInit(context);
|
||||
const { type: slug, user: username } = paramsSchema.parse(context.query);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
/** TODO: We should standarize this */
|
||||
username: username.toLowerCase().replace(/( |%20)/g, "+"),
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
away: true,
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
timeZone: true,
|
||||
theme: true,
|
||||
weekStart: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
organizationId: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
// Many-to-many relationship causes inclusion of the team events - cool -
|
||||
// but to prevent these from being selected, make sure the teamId is NULL.
|
||||
AND: [{ slug }, { teamId: null }],
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
hidden: true,
|
||||
recurringEvent: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
id: true,
|
||||
description: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
requiresConfirmation: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
team: {
|
||||
select: {
|
||||
logo: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user || !user.eventTypes.length) return { notFound: true };
|
||||
|
||||
const [eventType]: ((typeof user.eventTypes)[number] & {
|
||||
users: Pick<User, "name" | "username" | "hideBranding" | "timeZone">[];
|
||||
})[] = [
|
||||
{
|
||||
...user.eventTypes[0],
|
||||
users: [
|
||||
{
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
timeZone: user.timeZone,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (!eventType) return { notFound: true };
|
||||
|
||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
});
|
||||
// Check if the user you are logging into has any active teams or premium user name
|
||||
const hasActiveTeam =
|
||||
user.teams.filter((m) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
||||
return false;
|
||||
}).length > 0;
|
||||
|
||||
const hasPremiumUserName = hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventType: eventTypeObject,
|
||||
profile: {
|
||||
...eventType.users[0],
|
||||
theme: user.theme,
|
||||
allowDynamicBooking: false,
|
||||
weekStart: user.weekStart,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
slug: `${user.username}/${eventType.slug}`,
|
||||
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
},
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: user.username,
|
||||
organizationContext: user?.organizationId !== null,
|
||||
away: user?.away,
|
||||
isDynamic: false,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
|
||||
isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam || hasPremiumUserName),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
// load server side dependencies
|
||||
const { getDefaultEvent, getGroupName, getUsernameList } = await import("@calcom/lib/defaultEvents");
|
||||
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
|
||||
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const { EventTypeMetaDataSchema, userMetadata: userMetadataSchema } = await import(
|
||||
"@calcom/prisma/zod-utils"
|
||||
);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
||||
|
||||
const { type: typeParam, user: userParam } = paramsSchema.parse(context.params);
|
||||
const usernameList = getUsernameList(userParam);
|
||||
const length = parseInt(typeParam);
|
||||
const eventType = getDefaultEvent("" + length);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernameList,
|
||||
in: usernames,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
metadata: true,
|
||||
organizationId: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -285,86 +64,109 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
let sortedUsers: typeof users = [];
|
||||
if (users.length > 1) {
|
||||
sortedUsers = users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
let locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username: usernames.join("+"), eventSlug: slug });
|
||||
|
||||
// Get the prefered location type from the first user
|
||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||
|
||||
if (preferedLocationType?.appSlug) {
|
||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
if (appType) {
|
||||
// Replace the location with the prefered location type
|
||||
// This will still be default to daily if the app is not found
|
||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||
}
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: users.map((user) => {
|
||||
return {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicNames = users.map((user) => {
|
||||
return user.name || "";
|
||||
});
|
||||
|
||||
const profile = {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
slug: "" + length,
|
||||
theme: null as string | null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventType: eventTypeObject,
|
||||
profile,
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: null,
|
||||
isDynamic: true,
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
away: false,
|
||||
organizationContext: !users.some((user) => user.organizationId === null),
|
||||
trpcState: ssr.dehydrate(),
|
||||
isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""),
|
||||
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
|
||||
isBrandingHidden: false,
|
||||
themeBasis: null,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { user: userParam } = paramsSchema.parse(context.params);
|
||||
// dynamic groups are not generated at build time, but otherwise are probably cached until infinity.
|
||||
const isDynamicGroup = getUsernameList(userParam).length > 1;
|
||||
if (isDynamicGroup) {
|
||||
return await getDynamicGroupPageProps(context);
|
||||
} else {
|
||||
return await getUserPageProps(context);
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
themeBasis: username,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
user: z.string().transform((s) => getUsernameList(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { user } = paramsSchema.parse(context.params);
|
||||
const isDynamicGroup = user.length > 1;
|
||||
|
||||
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await _getServerSideProps(context);
|
||||
if (ssrResponse.notFound) {
|
||||
return ssrResponse;
|
||||
}
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
isEmbed: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
// Somehow these types don't accept the {notFound: true} return type.
|
||||
// Probably still need to fix this. I don't know why this isn't allowed yet.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import getBooking from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
getDefaultEvent,
|
||||
getDynamicEventName,
|
||||
getGroupName,
|
||||
getUsernameList,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import {
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Book(props: BookPageProps) {
|
||||
const { t } = useLocale();
|
||||
return props.away ? (
|
||||
<div className="dark:bg-inverted h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
|
||||
😴{" " + t("user_away")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
|
||||
<div className="dark:bg-inverted h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-default mb-2 text-3xl">
|
||||
{" " + t("unavailable")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<BookingPage {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
Book.isBookingPage = true;
|
||||
Book.PageWrapper = PageWrapper;
|
||||
|
||||
const querySchema = z.object({
|
||||
bookingUid: z.string().optional(),
|
||||
count: z.coerce.number().optional(),
|
||||
embed: z.string().optional(),
|
||||
rescheduleUid: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
/** This is the event "type" ID */
|
||||
type: z.coerce.number().optional(),
|
||||
user: z.string(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
duration: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const query = querySchema.parse(context.query);
|
||||
const usernameList = getUsernameList(query.user);
|
||||
const eventTypeSlug = query.slug;
|
||||
const recurringEventCountQuery = query.count;
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernameList,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
|
||||
|
||||
// Dynamic Group link doesn't need a type but it must have a slug
|
||||
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
const eventTypeRaw = isDynamicGroupBooking
|
||||
? getDefaultEvent(eventTypeSlug)
|
||||
: await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: query.type,
|
||||
},
|
||||
select: {
|
||||
...bookEventTypeSelect,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventTypeRaw),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
const getLocations = () => {
|
||||
let locations = eventTypeRaw.locations || [];
|
||||
if (!isDynamicGroupBooking) return locations;
|
||||
|
||||
let sortedUsers: typeof users = [];
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
if (users.length > 1) {
|
||||
sortedUsers = users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Get the prefered location type from the first user
|
||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||
|
||||
if (preferedLocationType?.appSlug) {
|
||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
if (appType) {
|
||||
// Replace the location with the prefered location type
|
||||
// This will still be default to daily if the app is not found
|
||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||
}
|
||||
}
|
||||
return locations;
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
let locations = getLocations();
|
||||
locations = privacyFilteredLocations(locations as LocationObject[]);
|
||||
return {
|
||||
...e,
|
||||
locations: locations,
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
schedulingType: null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
image: u.avatar,
|
||||
slug: u.username,
|
||||
theme: u.theme,
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
};
|
||||
})[0];
|
||||
|
||||
// If rescheduleUid and event has seats lets convert Uid to bookingUid
|
||||
let rescheduleUid = query.rescheduleUid;
|
||||
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
|
||||
let attendeeEmail: string;
|
||||
let bookingUidWithSeats: string | null = null;
|
||||
|
||||
if (rescheduleEventTypeHasSeats) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
referenceUid: query.rescheduleUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attendee: true,
|
||||
booking: {
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
attendeeEmail = bookingSeat.attendee.email;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.duration) {
|
||||
// If it's not reschedule but event Type has seats we should obtain
|
||||
// the bookingUid regardless and use it to get the booking
|
||||
const currentSeats = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventTypeId: eventTypeRaw.id,
|
||||
startTime: dayjs(query.date).toISOString(),
|
||||
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
});
|
||||
if (currentSeats && currentSeats) {
|
||||
bookingUidWithSeats = currentSeats.uid;
|
||||
}
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
|
||||
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
|
||||
}
|
||||
|
||||
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
|
||||
const currentAttendee = booking?.attendees.find((attendee) => {
|
||||
return attendee.email === attendeeEmail;
|
||||
});
|
||||
if (currentAttendee) {
|
||||
booking.attendees = [currentAttendee] || [];
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
|
||||
|
||||
const profile = isDynamicGroupBooking
|
||||
? {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
slug: eventTypeSlug,
|
||||
theme: null,
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
|
||||
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
|
||||
}
|
||||
: {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
eventName: null,
|
||||
};
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(recurringEventCountQuery <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
profile,
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: isDynamicGroupBooking ? null : user.username,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
currentSlotBooking: currentSlotBooking,
|
||||
recurringEventCount,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
isEmbed: !!query.embed,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,13 @@ 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") && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
<script
|
||||
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
|
||||
<body
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -36,6 +37,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||
}
|
||||
|
||||
if (!validPassword(rawPassword)) {
|
||||
return res.status(400).json({ message: "Password does not meet the requirements" });
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(rawPassword);
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -47,9 +52,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
await expireResetPasswordRequest(rawRequestId);
|
||||
|
||||
return res.status(201).json({ message: "Password reset." });
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||
}
|
||||
}
|
||||
|
||||
async function expireResetPasswordRequest(rawRequestId: string) {
|
||||
await prisma.resetPasswordRequest.update({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
},
|
||||
data: {
|
||||
// We set the expiry to now to invalidate the request
|
||||
expires: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/api/scheduleWhatsappReminders";
|
||||
@@ -13,12 +13,14 @@ const defaultIntegrationAddHandler = async ({
|
||||
supportsMultipleInstalls,
|
||||
appType,
|
||||
user,
|
||||
teamId = undefined,
|
||||
createCredential,
|
||||
}: {
|
||||
slug: string;
|
||||
supportsMultipleInstalls: boolean;
|
||||
appType: string;
|
||||
user?: Session["user"];
|
||||
teamId?: number;
|
||||
createCredential: AppDeclarativeHandler["createCredential"];
|
||||
}) => {
|
||||
if (!user?.id) {
|
||||
@@ -28,21 +30,21 @@ const defaultIntegrationAddHandler = async ({
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
appId: slug,
|
||||
userId: user.id,
|
||||
...(teamId ? { AND: [{ userId: user.id }, { teamId }] } : { userId: user.id }),
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
throw new Error("App is already installed");
|
||||
}
|
||||
}
|
||||
await createCredential({ user: user, appType, slug });
|
||||
await createCredential({ user: user, appType, slug, teamId });
|
||||
};
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Check that user is authenticated
|
||||
req.session = await getServerSession({ req, res });
|
||||
|
||||
const { args } = req.query;
|
||||
const { args, teamId } = req.query;
|
||||
|
||||
if (!Array.isArray(args)) {
|
||||
return res.status(404).json({ message: `API route not found` });
|
||||
@@ -62,7 +64,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (typeof handler === "function") {
|
||||
await handler(req, res);
|
||||
} else {
|
||||
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
|
||||
await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler });
|
||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const newBookerSchema = z.object({
|
||||
status: z.enum(["enable", "disable"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Very basic temporary api route to enable/disable new booker access.
|
||||
*/
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { status } = newBookerSchema.parse(req.query);
|
||||
|
||||
if (status === "enable") {
|
||||
const expires = new Date();
|
||||
expires.setFullYear(expires.getFullYear() + 1);
|
||||
res.setHeader("Set-Cookie", `new-booker-enabled=true; path=/; expires=${expires.toUTCString()}`);
|
||||
} else {
|
||||
res.setHeader("Set-Cookie", "new-booker-enabled=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
}
|
||||
res.send({ status: 200, body: `Done – ${status}` });
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { adminRouter } from "@calcom/trpc/server/routers/viewer/admin/_router";
|
||||
|
||||
export default createNextApiHandler(adminRouter);
|
||||
@@ -46,7 +46,7 @@ function getRoute(appName: string, pages: string[]) {
|
||||
} as NotFound;
|
||||
}
|
||||
const mainPage = pages[0];
|
||||
const appPage = routingConfig[mainPage] as AppPageType;
|
||||
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
|
||||
|
||||
if (!appPage) {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
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";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
@@ -48,7 +50,11 @@ function AppsSearch({
|
||||
);
|
||||
}
|
||||
|
||||
export default function Apps({ categories, appStore }: inferSSRProps<typeof getServerSideProps>) {
|
||||
export default function Apps({
|
||||
categories,
|
||||
appStore,
|
||||
userAdminTeams,
|
||||
}: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const [searchText, setSearchText] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -80,6 +86,7 @@ export default function Apps({ categories, appStore }: inferSSRProps<typeof getS
|
||||
apps={appStore}
|
||||
searchText={searchText}
|
||||
categories={categories.map((category) => category.name)}
|
||||
userAdminTeams={userAdminTeams}
|
||||
/>
|
||||
</div>
|
||||
</AppsLayout>
|
||||
@@ -95,11 +102,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
let appStore;
|
||||
let appStore, userAdminTeams: UserAdminTeams;
|
||||
if (session?.user?.id) {
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id);
|
||||
userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true });
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
|
||||
} else {
|
||||
appStore = await getAppRegistry();
|
||||
userAdminTeams = [];
|
||||
}
|
||||
|
||||
const categoryQuery = appStore.map(({ categories }) => ({
|
||||
@@ -111,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
return c;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
props: {
|
||||
categories: Object.entries(categories)
|
||||
@@ -122,6 +132,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
@@ -52,16 +53,16 @@ import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||
|
||||
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||
credentialIds: number[];
|
||||
credentialId: number;
|
||||
type: App["type"];
|
||||
isGlobal?: boolean;
|
||||
installed?: boolean;
|
||||
invalidCredentialIds?: number[];
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
teamId?: number;
|
||||
handleDisconnect: (credentialId: number, teamId?: number) => void;
|
||||
}) {
|
||||
const { type, credentialIds, isGlobal, installed, handleDisconnect } = props;
|
||||
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
|
||||
const { t } = useLocale();
|
||||
const [credentialId] = credentialIds;
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const handleOpenChange = () => {
|
||||
@@ -73,7 +74,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => handleDisconnect(credentialId)}
|
||||
onClick={() => handleDisconnect(credentialId, teamId)}
|
||||
disabled={isGlobal}
|
||||
StartIcon={Trash}>
|
||||
{t("remove_app")}
|
||||
@@ -138,72 +139,113 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP
|
||||
},
|
||||
});
|
||||
|
||||
const ChildAppCard = ({
|
||||
item,
|
||||
}: {
|
||||
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
|
||||
credentialOwner?: CredentialOwner;
|
||||
};
|
||||
}) => {
|
||||
const appSlug = item?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||
return (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={appIsDefault}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
|
||||
credentialOwner={item?.credentialOwner}
|
||||
actions={
|
||||
!item.credentialOwner?.readOnly ? (
|
||||
<div className="flex justify-end">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={Video}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
setBulkUpdateModal(true);
|
||||
}
|
||||
}}>
|
||||
{t("set_as_default")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ConnectOrDisconnectIntegrationMenuItem
|
||||
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
);
|
||||
};
|
||||
|
||||
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(<ChildAppCard item={app} />);
|
||||
}
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<ChildAppCard
|
||||
item={{
|
||||
...app,
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logo,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
readOnly: !team.isAdmin,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => {
|
||||
const appSlug = item?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||
return (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={appIsDefault}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item.invalidCredentialIds.length > 0}
|
||||
actions={
|
||||
<div className="flex justify-end">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={Video}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(
|
||||
item?.locationOption?.value ?? ""
|
||||
);
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
setBulkUpdateModal(true);
|
||||
}
|
||||
}}>
|
||||
{t("set_as_default")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ConnectOrDisconnectIntegrationMenuItem
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
);
|
||||
if (!item.teams.length) return <ChildAppCard item={item} />;
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
@@ -227,7 +269,12 @@ const IntegrationsContainer = ({
|
||||
handleDisconnect,
|
||||
}: IntegrationsContainerProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true });
|
||||
const query = trpc.viewer.integrations.useQuery({
|
||||
variant,
|
||||
exclude,
|
||||
onlyInstalled: true,
|
||||
includeTeamInstalledApps: true,
|
||||
});
|
||||
|
||||
// TODO: Refactor and reuse getAppCategories?
|
||||
const emptyIcon: Record<AppCategories, LucideIcon> = {
|
||||
@@ -299,6 +346,7 @@ type querySchemaType = z.infer<typeof querySchema>;
|
||||
type ModalState = {
|
||||
isOpen: boolean;
|
||||
credentialId: null | number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export default function InstalledApps() {
|
||||
@@ -323,8 +371,8 @@ export default function InstalledApps() {
|
||||
updateData({ isOpen: false, credentialId: null });
|
||||
};
|
||||
|
||||
const handleDisconnect = (credentialId: number) => {
|
||||
updateData({ isOpen: true, credentialId });
|
||||
const handleDisconnect = (credentialId: number, teamId?: number) => {
|
||||
updateData({ isOpen: true, credentialId, teamId });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -346,6 +394,7 @@ export default function InstalledApps() {
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={data.isOpen}
|
||||
credentialId={data.credentialId}
|
||||
teamId={data.teamId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import { debounce } from "lodash";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Button, TextField } from "@calcom/ui";
|
||||
import { Button, PasswordField, Form } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
@@ -23,40 +23,22 @@ type Props = {
|
||||
|
||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
const { t } = useLocale();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
const formMethods = useForm<{ new_password: string }>();
|
||||
const success = formMethods.formState.isSubmitSuccessful;
|
||||
const loading = formMethods.formState.isSubmitting;
|
||||
|
||||
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestId: requestId, password: password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(json);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (reason) {
|
||||
setError({ message: t("unexpected_error_try_again") });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestId, password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
|
||||
};
|
||||
|
||||
const debouncedChangePassword = debounce(submitChangePassword, 250);
|
||||
|
||||
const Success = () => {
|
||||
return (
|
||||
<>
|
||||
@@ -109,8 +91,9 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
{isRequestExpired && <Expired />}
|
||||
{!isRequestExpired && !success && (
|
||||
<>
|
||||
<form
|
||||
<Form
|
||||
className="space-y-6"
|
||||
form={formMethods}
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
@@ -119,36 +102,26 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
|
||||
}}
|
||||
action="#">
|
||||
handleSubmit={async (values) => {
|
||||
await submitChangePassword({
|
||||
password: values.new_password,
|
||||
requestId: resetPasswordRequest.id,
|
||||
});
|
||||
}}>
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||
<div className="mt-1">
|
||||
<TextField
|
||||
<PasswordField
|
||||
{...formMethods.register("new_password", {
|
||||
minLength: {
|
||||
message: t("password_hint_min"),
|
||||
value: 7, // We don't have user here so we can't check if they are admin or not
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +135,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{!isRequestExpired && success && (
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function Success(props: SuccessProps) {
|
||||
// - It's a paid event and payment is pending.
|
||||
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation;
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
|
||||
const isLoggedIn = session?.user;
|
||||
const isCancelled =
|
||||
status === "CANCELLED" ||
|
||||
status === "REJECTED" ||
|
||||
@@ -310,7 +310,7 @@ export default function Success(props: SuccessProps) {
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
{userIsOwner && !isEmbed && (
|
||||
{isLoggedIn && !isEmbed && (
|
||||
<div className="-mb-4 ml-4 mt-2">
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { privacyFilteredLocations } from "@calcom/core/location";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export type DynamicAvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type(props: DynamicAvailabilityPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
|
||||
return (
|
||||
<main className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.isBookingPage = true;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
const querySchema = z.object({
|
||||
link: z.string().optional().default(""),
|
||||
slug: z.string().optional().default(""),
|
||||
date: z.union([z.string(), z.null()]).optional().default(null),
|
||||
});
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { link, slug, date } = querySchema.parse(context.query);
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
@@ -45,132 +53,87 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: availiblityPageEventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
|
||||
if (hashedLink?.eventType.slug !== slug)
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users || !users.length) {
|
||||
const username = hashedLink?.eventType.users[0]?.username;
|
||||
|
||||
if (!hashedLink || !username) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
}
|
||||
|
||||
const locations = hashedLink.eventType.locations
|
||||
? (hashedLink.eventType.locations as LocationObject[])
|
||||
: [];
|
||||
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: EventTypeMetaDataSchema.parse(hashedLink.eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent),
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
slug,
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: users.map((u) => ({
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
hideBranding: u.hideBranding,
|
||||
timeZone: u.timeZone,
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(hashedLink.eventType.description),
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [user] = users;
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = {
|
||||
...user.schedules.filter(
|
||||
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
schedule.availability || user.availability
|
||||
);
|
||||
eventTypeObject.schedule = null;
|
||||
eventTypeObject.availability = [];
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
|
||||
|
||||
const booking: GetBookingType | null = null;
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
weekStart: user.weekStart,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
themeBasis: user.username,
|
||||
isDynamicGroup: false,
|
||||
profile,
|
||||
date,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
trpcState: ssr.dehydrate(),
|
||||
previousPage: context.req.headers.referer ?? null,
|
||||
booking,
|
||||
users: [user.username],
|
||||
isBrandingHidden: user.hideBranding,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
// Sending the team event from the server, because this template file
|
||||
// is reused for both team and user events.
|
||||
isTeamEvent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) });
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
return await getUserPageProps(context);
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[slug]";
|
||||
|
||||
export { default } from "../[slug]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type HashLinkPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Book(props: HashLinkPageProps) {
|
||||
return <BookingPage {...props} />;
|
||||
}
|
||||
|
||||
Book.isBookingPage = true;
|
||||
Book.PageWrapper = PageWrapper;
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrThrow(context.query.link as string);
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: bookEventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const eventTypeRaw = hashedLink?.eventType;
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
schedulingType: null,
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
image: u.avatar,
|
||||
slug: u.username,
|
||||
theme: u.theme,
|
||||
email: u.email,
|
||||
brandColor: u.brandColor,
|
||||
darkBrandColor: u.darkBrandColor,
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
};
|
||||
})[0];
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
eventName: null,
|
||||
};
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventTypeObject.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
|
||||
? parseInt(recurringEventCountQuery)
|
||||
: eventType.recurringEvent?.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
profile,
|
||||
themeBasis: user.username,
|
||||
eventType: eventTypeObject,
|
||||
booking: null,
|
||||
currentSlotBooking: null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
recurringEventCount,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: link,
|
||||
isEmbed: typeof context.query.embed === "string",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export type FormValues = {
|
||||
displayLocationPublicly?: boolean;
|
||||
phone?: string;
|
||||
hostDefault?: string;
|
||||
credentialId?: number;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
@@ -161,8 +162,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
data: { tabName },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const { data: eventTypeApps } = trpc.viewer.apps.useQuery({
|
||||
const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
teamId: props.eventType.team?.id || props.eventType.parent?.teamId,
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
||||
@@ -306,12 +309,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const availability = formMethods.watch("availability");
|
||||
const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0;
|
||||
let numberOfActiveApps = 0;
|
||||
|
||||
if (appsMetadata) {
|
||||
numberOfActiveApps = Object.entries(appsMetadata).filter(
|
||||
([appId, appData]) => eventTypeApps?.find((app) => app.slug === appId)?.isInstalled && appData.enabled
|
||||
([appId, appData]) =>
|
||||
eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled
|
||||
).length;
|
||||
}
|
||||
|
||||
@@ -423,7 +426,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
<>
|
||||
<EventTypeSingleLayout
|
||||
enabledAppsNumber={numberOfActiveApps}
|
||||
installedAppsNumber={numberOfInstalledApps}
|
||||
installedAppsNumber={eventTypeApps?.items.length || 0}
|
||||
enabledWorkflowsNumber={eventType.workflows.length}
|
||||
eventType={eventType}
|
||||
team={team}
|
||||
|
||||
@@ -103,11 +103,11 @@ const querySchema = z.object({
|
||||
|
||||
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
const { eventTypeGroups } = props;
|
||||
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const tabs = eventTypeGroups.map((item) => ({
|
||||
name: item.profile.name ?? "",
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types",
|
||||
avatar: item.profile.image ?? `${WEBAPP_URL}/${item.profile.slug}/avatar.png`,
|
||||
avatar: item.profile.image ?? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${item.profile.slug}/avatar.png`,
|
||||
}));
|
||||
const { data } = useTypedQuery(querySchema);
|
||||
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||
@@ -404,7 +404,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
items={type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)}
|
||||
@@ -419,7 +421,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
.flatMap((ch) => ch.users)
|
||||
.map((user: Pick<User, "name" | "username">) => ({
|
||||
alt: user.name || "",
|
||||
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
title: user.name || "",
|
||||
}))}
|
||||
/>
|
||||
@@ -714,7 +716,10 @@ const EventTypeListHeading = ({
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
imageSrc={
|
||||
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${teamId ? "team/" : ""}${profile.slug}/avatar.png` ||
|
||||
undefined
|
||||
}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingByUidOrRescheduleUid } 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 { classNames } from "@calcom/lib";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
return (
|
||||
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleBooking={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernames,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username: usernames.join("+"), eventSlug: slug });
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
away: false,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: false,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
themeBasis: username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
user: z.string().transform((s) => getUsernameList(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { user } = paramsSchema.parse(context.params);
|
||||
const isDynamicGroup = user.length > 1;
|
||||
|
||||
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
// Somehow these types don't accept the {notFound: true} return type.
|
||||
// Probably still need to fix this. I don't know why this isn't allowed yet.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -1,7 +0,0 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../../[user]";
|
||||
|
||||
export { default } from "../../[user]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingByUidOrRescheduleUid } 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";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
|
||||
return (
|
||||
<main className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleBooking={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const username = hashedLink?.eventType.users[0]?.username;
|
||||
|
||||
if (!hashedLink || !username) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
// Sending the team event from the server, because this template file
|
||||
// is reused for both team and user events.
|
||||
isTeamEvent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) });
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
return await getUserPageProps(context);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
return (
|
||||
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleBooking={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 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 { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: teamSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -1,7 +0,0 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../../../team/[slug]";
|
||||
|
||||
export { default } from "../../../team/[slug]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -1 +1,62 @@
|
||||
export { getServerSideProps, default } from "../../../[user]/[type]";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { PageProps as UserTypePageProps } from "../../../[user]/[type]";
|
||||
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[user]/[type]";
|
||||
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
|
||||
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: ctx.query.user as string,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: {
|
||||
slug: ctx.query.orgSlug as string,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
const params = { slug: ctx.query.user, type: ctx.query.type };
|
||||
return GSSTeamTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
const params = { user: ctx.query.user, type: ctx.query.type };
|
||||
return GSSUserTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type Props = UserTypePageProps | TeamTypePageProps;
|
||||
|
||||
export default function Page(props: Props) {
|
||||
if ((props as TeamTypePageProps)?.teamId) return TeamTypePage(props as TeamTypePageProps);
|
||||
return UserTypePage(props as UserTypePageProps);
|
||||
}
|
||||
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await _getServerSideProps(context);
|
||||
if (ssrResponse.notFound) {
|
||||
return ssrResponse;
|
||||
}
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
isEmbed: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { UserPageProps } from "../../../[user]";
|
||||
import UserPage, { getServerSideProps as GSSUserPage } from "../../../[user]";
|
||||
import type { TeamPageProps } from "../../../team/[slug]";
|
||||
import type { PageProps as TeamPageProps } from "../../../team/[slug]";
|
||||
import TeamPage, { getServerSideProps as GSSTeamPage } from "../../../team/[slug]";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
|
||||
@@ -12,14 +12,11 @@ export default function Type() {
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { uid: bookingId } = z
|
||||
const { uid: bookingId, seatReferenceUid } = z
|
||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||
.parse(context.query);
|
||||
let seatReferenceUid;
|
||||
|
||||
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
|
||||
if (uid) {
|
||||
seatReferenceUid = bookingId;
|
||||
}
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
@@ -39,6 +36,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
seatsPerTimeSlot: true,
|
||||
},
|
||||
},
|
||||
dynamicEventSlugRef: true,
|
||||
@@ -74,15 +72,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
"/" +
|
||||
eventType?.slug;
|
||||
const destinationUrl = new URLSearchParams();
|
||||
if (seatReferenceUid) {
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid);
|
||||
} else {
|
||||
destinationUrl.set("rescheduleUid", bookingId);
|
||||
}
|
||||
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/${eventPage}?${destinationUrl.toString()}`,
|
||||
destination: `/${eventPage}?${destinationUrl.toString()}${
|
||||
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
|
||||
}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -160,7 +160,7 @@ const ConferencingLayout = () => {
|
||||
disabled={app.isGlobal}
|
||||
StartIcon={Trash}
|
||||
onClick={() => {
|
||||
setDeleteCredentialId(app.credentialIds[0]);
|
||||
setDeleteCredentialId(app.userCredentialIds[0]);
|
||||
setDeleteAppModal(true);
|
||||
}}>
|
||||
{t("remove_app")}
|
||||
|
||||
@@ -48,9 +48,9 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="mb-8 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonAvatar className="me-4 h-16 w-16 px-4" />
|
||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||
</div>
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { CreateANewOrganizationForm } from "@calcom/features/ee/organizations/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WizardLayout, Meta } from "@calcom/ui";
|
||||
@@ -7,10 +8,10 @@ import PageWrapper from "@components/PageWrapper";
|
||||
const CreateNewOrganizationPage = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<LicenseRequired>
|
||||
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
|
||||
<CreateANewOrganizationForm />
|
||||
</>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
const LayoutWrapper = (page: React.ReactElement) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useEffect } from "react";
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -28,9 +27,9 @@ import Team from "@components/team/screens/Team";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: TeamPageProps) {
|
||||
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
|
||||
useTheme(team.theme);
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
@@ -103,7 +102,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: CAL_URL + "/" + user.username + "/avatar.png" || "",
|
||||
image: "/" + user.username + "/avatar.png" || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -140,7 +139,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
truncateAfter={4}
|
||||
items={ch.members.map(({ user: member }) => ({
|
||||
alt: member.name || "",
|
||||
image: `${WEBAPP_URL}/${member.username}/avatar.png`,
|
||||
image: `/${member.username}/avatar.png`,
|
||||
title: member.name || "",
|
||||
}))}
|
||||
/>
|
||||
@@ -284,7 +283,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
...user,
|
||||
avatar: CAL_URL + "/" + user.username + "/avatar.png",
|
||||
avatar: "/" + user.username + "/avatar.png",
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
||||
}));
|
||||
@@ -297,10 +296,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const markdownStrippedBio = stripMarkdown(team?.bio || "");
|
||||
|
||||
const { inviteToken: _inviteToken, ...serializableTeam } = team;
|
||||
|
||||
return {
|
||||
props: {
|
||||
team: { ...team, safeBio, members },
|
||||
themeBasis: team.slug,
|
||||
team: { ...serializableTeam, safeBio, members },
|
||||
themeBasis: serializableTeam.slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
markdownStrippedBio,
|
||||
isValidOrgDomain,
|
||||
|
||||
@@ -1,214 +1,104 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { privacyFilteredLocations } from "@calcom/core/location";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getBooking from "@calcom/features/bookings/lib/get-booking";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssgInit } from "@server/lib/ssg";
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export type AvailabilityTeamPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function TeamType(props: AvailabilityTeamPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
return (
|
||||
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
TeamType.isBookingPage = true;
|
||||
TeamType.PageWrapper = PageWrapper;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slugParam = asStringOrNull(context.query.slug);
|
||||
const typeParam = asStringOrNull(context.query.type);
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
|
||||
const ssg = await ssgInit(context);
|
||||
|
||||
if (!slugParam || !typeParam) {
|
||||
throw new Error(`File is not named [idOrSlug]/[user]`);
|
||||
}
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: slugParam,
|
||||
slug: teamSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
logo: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: typeParam,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
hidden: true,
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
title: true,
|
||||
availability: true,
|
||||
description: true,
|
||||
length: true,
|
||||
disableGuests: true,
|
||||
schedulingType: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
locations: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
timeZone: true,
|
||||
slotInterval: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingFields: true,
|
||||
customInputs: true,
|
||||
schedule: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
where: {
|
||||
role: "OWNER",
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
weekStart: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team || team.eventTypes.length != 1) {
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
}
|
||||
|
||||
const [eventType] = team.eventTypes;
|
||||
|
||||
const timeZone = eventType.schedule?.timeZone || eventType.timeZone || undefined;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
eventType.schedule?.availability || eventType.availability
|
||||
);
|
||||
|
||||
eventType.schedule = null;
|
||||
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: eventType.hosts.map(({ user: { name, username, hideBranding, timeZone } }) => ({
|
||||
name,
|
||||
username,
|
||||
hideBranding,
|
||||
timeZone,
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
});
|
||||
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
profile: {
|
||||
name: team.name || team.slug,
|
||||
slug: team.slug,
|
||||
image: team.logo,
|
||||
theme: team.theme,
|
||||
weekStart: weekStart ?? "Sunday",
|
||||
brandColor: team.brandColor,
|
||||
darkBrandColor: team.darkBrandColor,
|
||||
},
|
||||
themeBasis: team.slug,
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
previousPage: context.req.headers.referer ?? null,
|
||||
booking,
|
||||
trpcState: ssg.dehydrate(),
|
||||
isBrandingHidden: team.hideBranding,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,104 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export { default } from "../[type]";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
|
||||
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
|
||||
return (
|
||||
<main className={classNames("flex h-full items-center justify-center", !isEmbed && "min-h-[100dvh]")}>
|
||||
<BookerSeo
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 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 { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: teamSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import getBooking from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
return <BookingPage {...props} />;
|
||||
}
|
||||
TeamBookingPage.isBookingPage = true;
|
||||
TeamBookingPage.PageWrapper = PageWrapper;
|
||||
|
||||
const querySchema = z.object({
|
||||
rescheduleUid: z.string().optional(),
|
||||
bookingUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventTypeRaw = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
customInputs: true,
|
||||
periodType: true,
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
disableGuests: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
schedulingType: true,
|
||||
bookingFields: true,
|
||||
successRedirectUrl: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
metadata: EventTypeMetaDataSchema.parse(e.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
users: eventType.users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
image: u.avatar,
|
||||
slug: u.username,
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
};
|
||||
})[0];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
|
||||
if (rescheduleUid || bookingUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
|
||||
}
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? parseInt(recurringEventCountQuery)
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
profile: {
|
||||
...eventTypeObject.team,
|
||||
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
|
||||
slug: "team/" + eventTypeObject.slug,
|
||||
image: eventTypeObject.team?.logo || null,
|
||||
eventName: null,
|
||||
},
|
||||
themeBasis: eventTypeObject.team?.slug,
|
||||
eventType: eventTypeObject,
|
||||
recurringEventCount,
|
||||
booking,
|
||||
currentSlotBooking: null,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
isEmbed: typeof context.query.embed === "string",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[slug]";
|
||||
import { getServerSideProps as _getServerSideProps } from "../../team/[slug]";
|
||||
|
||||
export { default } from "../[slug]";
|
||||
export { default } from "../../team/[slug]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import Shell 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";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
function Teams() {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("teams")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button
|
||||
variant="fab"
|
||||
StartIcon={Plus}
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||
<Button
|
||||
variant="fab"
|
||||
StartIcon={Plus}
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
)
|
||||
}>
|
||||
<TeamsListing />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations("en", ["common"])),
|
||||
},
|
||||
};
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
Teams.requiresLicense = false;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const glob = require("glob");
|
||||
const { getSubdomainRegExp } = require("./getSubdomainRegExp");
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
let pages = (exports.pages = glob
|
||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
||||
.map((filename) =>
|
||||
filename
|
||||
.substr(6)
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
|
||||
|
||||
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
||||
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
||||
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
||||
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
|
||||
|
||||
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
|
||||
));
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`;
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
||||
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
||||
"/|"
|
||||
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`;
|
||||
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
|
||||
"/|"
|
||||
)}|_next/|public/)[^/]+)/:type/embed`;
|
||||
@@ -8,7 +8,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_IS_E2E !== "1", "It shouldn't if we can't skip email");
|
||||
const user = await users.create();
|
||||
const newPassword = `${user.username}-123`;
|
||||
const newPassword = `${user.username}-123CAL`; // To match the password policy
|
||||
// Got to reset password flow
|
||||
await page.goto("/auth/forgot-password");
|
||||
|
||||
@@ -24,8 +24,8 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
|
||||
// Wait for page to fully load
|
||||
await page.waitForSelector("text=Reset Password");
|
||||
// Fill input[name="password"]
|
||||
await page.fill('input[name="password"]', newPassword);
|
||||
// Fill input[name="new_password"]
|
||||
await page.fill('input[name="new_password"]', newPassword);
|
||||
|
||||
// Click text=Submit
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothBookers } from "./lib/new-booker";
|
||||
import {
|
||||
bookFirstEvent,
|
||||
bookOptinEvent,
|
||||
bookTimeSlot,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
selectSecondAvailableTimeSlotNextMonth,
|
||||
testEmail,
|
||||
testName,
|
||||
} from "./lib/testUtils";
|
||||
@@ -15,7 +13,7 @@ import {
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(async ({ users }) => users.deleteAll());
|
||||
|
||||
testBothBookers.describe("free user", (bookerVariant) => {
|
||||
test.describe("free user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const free = await users.create();
|
||||
await page.goto(`/${free.username}`);
|
||||
@@ -27,23 +25,11 @@ testBothBookers.describe("free user", (bookerVariant) => {
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Kept in if statement here, since it's only temporary
|
||||
// until the old booker isn't used anymore, and I wanted
|
||||
// to change the test as little as possible.
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (bookerVariant !== "new-booker") {
|
||||
// Navigate to book page
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/book");
|
||||
});
|
||||
}
|
||||
await bookTimeSlot(page);
|
||||
|
||||
// save booking url
|
||||
const bookingUrl: string = page.url();
|
||||
|
||||
// book same time spot twice
|
||||
await bookTimeSlot(page);
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
@@ -53,12 +39,11 @@ testBothBookers.describe("free user", (bookerVariant) => {
|
||||
// book same time spot again
|
||||
await bookTimeSlot(page);
|
||||
|
||||
// check for error message
|
||||
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
|
||||
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
testBothBookers.describe("pro user", () => {
|
||||
test.describe("pro user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await page.goto(`/${pro.username}`);
|
||||
@@ -87,8 +72,8 @@ testBothBookers.describe("pro user", () => {
|
||||
const bookingId = url.searchParams.get("rescheduleUid");
|
||||
return !!bookingId;
|
||||
});
|
||||
await selectSecondAvailableTimeSlotNextMonth(page);
|
||||
// --- fill form
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
@@ -163,4 +148,68 @@ testBothBookers.describe("pro user", () => {
|
||||
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
|
||||
});
|
||||
});
|
||||
|
||||
test("Time slots should be reserved when selected", async ({ context, page }) => {
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
const initialUrl = page.url();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
const pageTwo = await context.newPage();
|
||||
await pageTwo.goto(initialUrl);
|
||||
await pageTwo.waitForURL(initialUrl);
|
||||
|
||||
await pageTwo.waitForSelector('[data-testid="event-type-link"]');
|
||||
const eventTypeLink = pageTwo.locator('[data-testid="event-type-link"]').first();
|
||||
await eventTypeLink.click();
|
||||
|
||||
await pageTwo.waitForLoadState("networkidle");
|
||||
await pageTwo.locator('[data-testid="incrementMonth"]').waitFor();
|
||||
await pageTwo.click('[data-testid="incrementMonth"]');
|
||||
await pageTwo.waitForLoadState("networkidle");
|
||||
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
|
||||
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
|
||||
// 9:30 should be the first available time slot
|
||||
await pageTwo.locator('[data-testid="time"]').nth(0).waitFor();
|
||||
const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0);
|
||||
// Find text inside the element
|
||||
const firstSlotAvailableText = await firstSlotAvailable.innerText();
|
||||
expect(firstSlotAvailableText).toContain("9:30");
|
||||
});
|
||||
|
||||
test("Time slots are not reserved when going back via Cancel button on Event Form", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const initialUrl = page.url();
|
||||
await page.waitForSelector('[data-testid="event-type-link"]');
|
||||
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
|
||||
await eventTypeLink.click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const pageTwo = await context.newPage();
|
||||
await pageTwo.goto(initialUrl);
|
||||
await pageTwo.waitForURL(initialUrl);
|
||||
|
||||
await pageTwo.waitForSelector('[data-testid="event-type-link"]');
|
||||
const eventTypeLinkTwo = pageTwo.locator('[data-testid="event-type-link"]').first();
|
||||
await eventTypeLinkTwo.click();
|
||||
|
||||
await page.locator('[data-testid="back"]').waitFor();
|
||||
await page.click('[data-testid="back"]');
|
||||
|
||||
await pageTwo.waitForLoadState("networkidle");
|
||||
await pageTwo.locator('[data-testid="incrementMonth"]').waitFor();
|
||||
await pageTwo.click('[data-testid="incrementMonth"]');
|
||||
await pageTwo.waitForLoadState("networkidle");
|
||||
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
|
||||
await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
|
||||
await pageTwo.locator('[data-testid="time"]').nth(0).waitFor();
|
||||
const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0);
|
||||
|
||||
// Find text inside the element
|
||||
const firstSlotAvailableText = await firstSlotAvailable.innerText();
|
||||
expect(firstSlotAvailableText).toContain("9:00");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothBookers } from "./lib/new-booker";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
@@ -56,7 +55,7 @@ async function createUserWithSeatedEventAndAttendees(
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
||||
test.describe("Booking with Seats", () => {
|
||||
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
|
||||
const user = await users.create({ name: "Seated event" });
|
||||
await user.apiLogin();
|
||||
@@ -83,34 +82,56 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
||||
],
|
||||
});
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Kept in if statement here, since it's only temporary
|
||||
// until the old booker isn't used anymore, and I wanted
|
||||
// to change the test as little as possible.
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (bookerVariant === "old-booker") {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/book");
|
||||
});
|
||||
}
|
||||
let bookingUrl = "";
|
||||
|
||||
const bookingUrl = page.url();
|
||||
await test.step("Attendee #1 can book a seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #2 can book the same seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.waitForURL(/bookingUid/);
|
||||
bookingUrl = page.url();
|
||||
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot", async () => {
|
||||
await test.step("Attendee #3 cannot click on the same seated event time slot", async () => {
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("User owner should have only 1 booking with 3 attendees", async () => {
|
||||
// Make sure user owner has only 1 booking with 3 attendees
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id },
|
||||
select: {
|
||||
id: true,
|
||||
attendees: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(bookings).toHaveLength(1);
|
||||
expect(bookings[0].attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
||||
@@ -145,7 +166,7 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
@@ -169,9 +190,8 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
@@ -187,7 +207,7 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
||||
});
|
||||
});
|
||||
|
||||
testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
test.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
@@ -227,9 +247,10 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
// should wait for URL but that path starts with booking/
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect new booking to be created for John Third
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
@@ -292,7 +313,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForURL(/.*booking/);
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
@@ -301,7 +322,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
// Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled
|
||||
await page.waitForNavigation({ url: /.*booking/ });
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
@@ -351,9 +372,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
@@ -457,7 +476,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
await page.goto(
|
||||
`/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}`
|
||||
@@ -468,7 +487,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
});
|
||||
|
||||
test("Should book with seats and hide attendees info from showAttendees true", async ({
|
||||
|
||||
@@ -4,13 +4,12 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothBookers } from "./lib/new-booker";
|
||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Event Types tests", () => {
|
||||
testBothBookers.describe("user", (bookerVariant) => {
|
||||
test.describe("user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
@@ -143,17 +142,6 @@ test.describe("Event Types tests", () => {
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Navigate to book page
|
||||
// Kept in if statement here, since it's only temporary
|
||||
// until the old booker isn't used anymore, and I wanted
|
||||
// to change the test as little as possible.
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (bookerVariant === "old-booker") {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/book");
|
||||
});
|
||||
}
|
||||
|
||||
for (const location of locationData) {
|
||||
await page.locator(`span:has-text("${location}")`).click();
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
|
||||
export type BookerVariants = "new-booker" | "old-booker";
|
||||
|
||||
const bookerVariants = ["new-booker", "old-booker"];
|
||||
|
||||
/**
|
||||
* Small wrapper around test.describe().
|
||||
* When using testbothBookers.describe() instead of test.describe(), this will run the specified
|
||||
* tests twice. One with the old booker, and one with the new booker. It will also add the booker variant
|
||||
* name to the test name for easier debugging.
|
||||
* Finally it also adds a parameter bookerVariant to your testBothBooker.describe() callback, which
|
||||
* can be used to do any conditional rendering in the test for a specific booker variant (should be as little
|
||||
* as possible).
|
||||
*
|
||||
* See apps/web/playwright/booking-pages.e2e.ts for an example.
|
||||
*/
|
||||
export const testBothBookers = {
|
||||
describe: (testName: string, testFn: (bookerVariant: BookerVariants) => void) => {
|
||||
bookerVariants.forEach((bookerVariant) => {
|
||||
test.describe(`${testName} -- ${bookerVariant}`, () => {
|
||||
if (bookerVariant === "new-booker") {
|
||||
test.beforeEach(({ context }) => {
|
||||
context.addCookies([{ name: "new-booker-enabled", value: "true", url: "http://localhost:3000" }]);
|
||||
});
|
||||
}
|
||||
testFn(bookerVariant as BookerVariants);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -79,38 +79,21 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time
|
||||
|
||||
export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
|
||||
// Let current month dates fully render.
|
||||
// There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered
|
||||
// This doesn't seem to be replicable with the speed of a person, only during automation.
|
||||
// It would also allow correct snapshot to be taken for current month.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
// @TODO: Find a better way to make test wait for full month change render to end
|
||||
// so it can click up on the right day, also when resolve remove other todos
|
||||
|
||||
// Waiting for full month increment
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
await page.locator('[data-testid="time"][data-disabled="false"]').nth(0).click();
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
}
|
||||
|
||||
export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
|
||||
// Let current month dates fully render.
|
||||
// There is a bug where if we don't let current month fully render and quickly click go to next month, current month get's rendered
|
||||
// This doesn't seem to be replicable with the speed of a person, only during automation.
|
||||
// It would also allow correct snapshot to be taken for current month.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
// @TODO: Find a better way to make test wait for full month change render to end
|
||||
// so it can click up on the right day, also when resolve remove other todos
|
||||
// Waiting for full month increment
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
await page.locator('[data-testid="time"][data-disabled="false"]').nth(1).click();
|
||||
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
}
|
||||
|
||||
async function bookEventOnThisPage(page: Page) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothBookers } from "./lib/new-booker";
|
||||
import type { BookerVariants } from "./lib/new-booker";
|
||||
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
async function getLabelText(field: Locator) {
|
||||
@@ -21,7 +19,7 @@ test.describe("Manage Booking Questions", () => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
testBothBookers.describe("For User EventType", (bookerVariant) => {
|
||||
test.describe("For User EventType", () => {
|
||||
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
||||
page,
|
||||
users,
|
||||
@@ -40,11 +38,11 @@ test.describe("Manage Booking Questions", () => {
|
||||
await firstEventTypeElement.click();
|
||||
});
|
||||
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
||||
});
|
||||
});
|
||||
|
||||
testBothBookers.describe("For Team EventType", (bookerVariant) => {
|
||||
test.describe("For Team EventType", () => {
|
||||
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
||||
page,
|
||||
users,
|
||||
@@ -76,7 +74,7 @@ test.describe("Manage Booking Questions", () => {
|
||||
await firstEventTypeElement.click();
|
||||
});
|
||||
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,8 +88,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
requestList: (import("http").IncomingMessage & { body?: any })[];
|
||||
url: string;
|
||||
},
|
||||
bookerVariant: BookerVariants
|
||||
}
|
||||
) {
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||
@@ -106,7 +103,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
},
|
||||
});
|
||||
|
||||
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
|
||||
const userFieldLocator = allFieldsLocator.nth(5);
|
||||
|
||||
@@ -122,7 +119,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeHidden();
|
||||
});
|
||||
@@ -136,7 +133,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
});
|
||||
|
||||
await test.step('Try to book without providing "How are you?" response', async () => {
|
||||
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expectErrorToBeThereFor({ page, name: "how_are_you" });
|
||||
});
|
||||
@@ -155,7 +152,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
return await doOnFreshPreview(
|
||||
page,
|
||||
context,
|
||||
bookerVariant,
|
||||
async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeVisible();
|
||||
@@ -324,11 +320,10 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
|
||||
async function doOnFreshPreview(
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
bookerVariant: BookerVariants,
|
||||
callback: (page: Page) => Promise<void>,
|
||||
persistTab = false
|
||||
) {
|
||||
const previewTabPage = await openBookingFormInPreviewTab(context, page, bookerVariant);
|
||||
const previewTabPage = await openBookingFormInPreviewTab(context, page);
|
||||
await callback(previewTabPage);
|
||||
if (!persistTab) {
|
||||
await previewTabPage.close();
|
||||
@@ -377,39 +372,19 @@ async function createAndLoginUserWithEventTypes({
|
||||
return user;
|
||||
}
|
||||
|
||||
async function rescheduleFromTheLinkOnPage({
|
||||
page,
|
||||
bookerVariant,
|
||||
}: {
|
||||
page: Page;
|
||||
bookerVariant?: BookerVariants;
|
||||
}) {
|
||||
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
|
||||
await page.locator('[data-testid="reschedule-link"]').click();
|
||||
await page.waitForLoadState();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
if (bookerVariant === "old-booker") {
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/book");
|
||||
});
|
||||
}
|
||||
await page.click('[data-testid="confirm-reschedule-button"]');
|
||||
}
|
||||
|
||||
async function openBookingFormInPreviewTab(
|
||||
context: PlaywrightTestArgs["context"],
|
||||
page: Page,
|
||||
bookerVariant: BookerVariants
|
||||
) {
|
||||
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
|
||||
const previewTabPromise = context.waitForEvent("page");
|
||||
await page.locator('[data-testid="preview-button"]').click();
|
||||
const previewTabPage = await previewTabPromise;
|
||||
await previewTabPage.waitForLoadState();
|
||||
await selectFirstAvailableTimeSlotNextMonth(previewTabPage);
|
||||
if (bookerVariant === "old-booker") {
|
||||
await previewTabPage.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/book");
|
||||
});
|
||||
}
|
||||
return previewTabPage;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { testBothBookers } from "./lib/new-booker";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
@@ -17,7 +16,7 @@ test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
testBothBookers.describe("Reschedule Tests", async () => {
|
||||
test.describe("Reschedule Tests", async () => {
|
||||
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
@@ -414,6 +414,7 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await page.click('[data-testid="install-app-button-personal"]');
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto(`/settings/developer/webhooks/new`);
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"team_info": "معلومات الفريق",
|
||||
"request_another_invitation_email": "إذا كنت تفضل عدم استخدام {{toEmail}} كبريدك الإلكتروني {{appName}} أو لديك بالفعل حساب {{appName}} ، يرجى طلب دعوة أخرى إلى ذلك البريد الإلكتروني.",
|
||||
"you_have_been_invited": "تم دعوتك للانضمام إلى الفريق {{teamName}}",
|
||||
"user_invited_you": "قام {{user}} بدعوتك للانضمام إلى فريق {{team}} على {{appName}}",
|
||||
"hidden_team_member_title": "أنت في الوضع الخفي في هذا الفريق",
|
||||
"hidden_team_member_message": "لم يتم الدفع مقابل مقعدك. يمكنك الترقية إلى Pro أو إعلام مالك الفريق أنه يستطيع الدفع مقابل مقعدك.",
|
||||
"hidden_team_owner_message": "تحتاج إلى حساب Pro لاستخدام خدمة الفرق، وأنت حاليًا في الوضع الخفي حتى تقوم بالترقية.",
|
||||
@@ -181,7 +180,6 @@
|
||||
"blog": "المدونة",
|
||||
"blog_description": "اقرأ أحدث أخبارنا ومقالاتنا",
|
||||
"join_our_community": "انضم إلى مجتمعنا",
|
||||
"join_our_slack": "انضم إلى Slack الخاص بنا",
|
||||
"404_claim_entity_user": "احصل على اسم المستخدم وقم بجدولة الأحداث",
|
||||
"popular_pages": "الصفحات الشائعة",
|
||||
"register_now": "التسجيل الآن",
|
||||
@@ -503,6 +501,8 @@
|
||||
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
|
||||
"members": "الأعضاء",
|
||||
"member": "العضو",
|
||||
"number_member_one": "{{count}} عضو",
|
||||
"number_member_other": "{{count}} من الأعضاء",
|
||||
"owner": "المالك",
|
||||
"admin": "المشرف",
|
||||
"administrator_user": "المستخدم المسؤول",
|
||||
@@ -639,7 +639,6 @@
|
||||
"create_new_team": "إنشاء فريق جديد",
|
||||
"open_invitations": "الدعوات المفتوحة",
|
||||
"new_team": "فريق جديد",
|
||||
"create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.",
|
||||
"create_team_to_get_started": "قم بإنشاء فريق للبدء",
|
||||
"teams": "الفرق",
|
||||
"team": "فريق",
|
||||
@@ -1073,8 +1072,8 @@
|
||||
"specific_issue": "هل لديك مشكلة معينة؟",
|
||||
"browse_our_docs": "تصفح مستنداتنا",
|
||||
"choose_template": "اختيار قالب",
|
||||
"reminder": "تذكير",
|
||||
"custom": "مخصص",
|
||||
"reminder": "تذكير",
|
||||
"reminder_email": "تذكر {{eventType}} مع {{name}} في {{date}}",
|
||||
"not_triggering_existing_bookings": "لن يتم تشغيل الحجوزات الموجودة مسبقاً حيث سيتم طلب رقم الهاتف عند حجز الحدث.",
|
||||
"minute_one": "{{count}} دقيقة",
|
||||
@@ -1679,7 +1678,6 @@
|
||||
"managed_event_dialog_title_one": "رابط /{{slug}} موجود بالفعل لـ {{count}} عضو. هل تريد استبداله؟",
|
||||
"managed_event_dialog_title_other": "رابط /{{slug}} موجود بالفعل لـ {{count}} من الأعضاء. هل تريد استبداله؟",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> يستخدم الرابط <strong>/{{slug}}</strong> بالفعل.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong>يستخدمون الرابط <string>/{{slug}}</strong> بالفعل.",
|
||||
"managed_event_dialog_clarification": "إذا اخترت استبداله، فسنخطرهم بذلك. ارجع وقم بإزالتهم إذا كنت لا تريد استبداله.",
|
||||
"review_event_type": "مراجعة نوع الحدث",
|
||||
"looking_for_more_analytics": "هل تبحث عن المزيد من التحليلات؟",
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"team_info": "Informace o týmu",
|
||||
"request_another_invitation_email": "Pokud si nepřejete použít e-mail {{toEmail}} pro {{appName}} nebo už účet na {{appName}} máte, požádejte prosím o další pozvánku na jiný e-mail.",
|
||||
"you_have_been_invited": "Byli jste pozváni do týmu {{teamName}}",
|
||||
"user_invited_you": "{{user}} vás pozval, abyste se připojili k týmu {{team}} na {{appName}}",
|
||||
"hidden_team_member_title": "V tomto týmu nejste viditelní",
|
||||
"hidden_team_member_message": "Vaše místo není zaplacené. Buď přejděte na tarif Pro, nebo informujte vlastníka týmu, že za vaše místo může zaplatit.",
|
||||
"hidden_team_owner_message": "Pro využívání týmů potřebujete tarif Pro, budete skrytí, než přejdete na vyšší verzi.",
|
||||
@@ -181,7 +180,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Přečtěte si naše nejnovější novinky a články",
|
||||
"join_our_community": "Připojte se k naší komunitě",
|
||||
"join_our_slack": "Připojte se k našemu Slacku",
|
||||
"404_claim_entity_user": "Nárokujte si své uživatelské jméno a plánujte události",
|
||||
"popular_pages": "Oblíbené stránky",
|
||||
"register_now": "Zaregistrovat se nyní",
|
||||
@@ -503,6 +501,8 @@
|
||||
"team_description": "Pár vět o vašem týmu. Objeví se na URL stránce vašeho týmu.",
|
||||
"members": "Členové",
|
||||
"member": "Člen",
|
||||
"number_member_one": "Počet členů: {{count}}",
|
||||
"number_member_other": "Počet členů: {{count}}",
|
||||
"owner": "Vlastník",
|
||||
"admin": "Administrátor",
|
||||
"administrator_user": "Uživatel administrátor",
|
||||
@@ -639,7 +639,6 @@
|
||||
"create_new_team": "Vytvořit nový tým",
|
||||
"open_invitations": "Otevřené pozvánky",
|
||||
"new_team": "Nový tým",
|
||||
"create_first_team_and_invite_others": "Vytvořte svůj první tým a pozvěte další uživatele, aby s vámi spolupracovali.",
|
||||
"create_team_to_get_started": "Začněte vytvořením týmu",
|
||||
"teams": "Týmy",
|
||||
"team": "Tým",
|
||||
@@ -1073,8 +1072,8 @@
|
||||
"specific_issue": "Uveďte konkrétní problém",
|
||||
"browse_our_docs": "procházet naše dokumenty",
|
||||
"choose_template": "Vyberte si šablonu",
|
||||
"reminder": "Upomínka",
|
||||
"custom": "Vlastní",
|
||||
"reminder": "Upomínka",
|
||||
"reminder_email": "Upomínka: událost {{eventType}} s uživatelem {{name}} dne {{date}}",
|
||||
"not_triggering_existing_bookings": "Nespustí se u již existujících rezervací, protože uživatel bude při rezervaci události požádán o zadání telefonního čísla.",
|
||||
"minute_one": "{{count}} min",
|
||||
@@ -1679,7 +1678,6 @@
|
||||
"managed_event_dialog_title_one": "Adresa URL /{{slug}} již existuje pro {{count}} člena. Chcete ji nahradit?",
|
||||
"managed_event_dialog_title_other": "Adresa URL /{{slug}} již existuje pro ({{count}}) členy. Chcete ji nahradit?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> již používá adresu URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> již používají adresu URL <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Pokud se rozhodnete ji nahradit, pošleme jim upozornění. Pokud ji nechcete přepsat, vraťte se a odstraňte ji.",
|
||||
"review_event_type": "Revidovat typ události",
|
||||
"looking_for_more_analytics": "Hledáte další analytické informace?",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"team_info": "Team Info",
|
||||
"request_another_invitation_email": "Hvis du foretrækker ikke at bruge {{toEmail}} som din {{appName}} e-mail eller allerede har en {{appName}} konto, bedes du anmode om en anden invitation til den pågældende e-mail.",
|
||||
"you_have_been_invited": "Du er blevet inviteret til at deltage i teamet {{teamName}}",
|
||||
"user_invited_you": "{{user}} inviterede dig til at deltage i teamet {{team}} på {{appName}}",
|
||||
"hidden_team_member_title": "Du er skjult i dette team",
|
||||
"hidden_team_member_message": "Din plads er ikke betalt for, enten Opgradér til PRO eller lad teamejeren vide, at de kan betale for din plads.",
|
||||
"hidden_team_owner_message": "Du skal bruge en pro-konto for at bruge teams, du er skjult indtil du opgraderer.",
|
||||
@@ -174,7 +173,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Læs vores seneste nyheder og artikler",
|
||||
"join_our_community": "Tilmeld dig vores fællesskab",
|
||||
"join_our_slack": "Tilmeld dig vores Slack",
|
||||
"404_claim_entity_user": "Få dit brugernavn og planlæg begivenheder",
|
||||
"popular_pages": "Populære sider",
|
||||
"register_now": "Tilmeld dig nu",
|
||||
@@ -610,7 +608,6 @@
|
||||
"create_new_team": "Opret et nyt team",
|
||||
"open_invitations": "Åbne Invitationer",
|
||||
"new_team": "Nyt Team",
|
||||
"create_first_team_and_invite_others": "Opret dit første team og invitér andre brugere til at arbejde sammen med dig.",
|
||||
"create_team_to_get_started": "Opret et team for at komme i gang",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
@@ -1032,8 +1029,8 @@
|
||||
"specific_issue": "Har du et konkret spørgsmål?",
|
||||
"browse_our_docs": "gennemse vores dokumenter",
|
||||
"choose_template": "Vælg en skabelon",
|
||||
"reminder": "Påmindelse",
|
||||
"custom": "Brugerdefineret",
|
||||
"reminder": "Påmindelse",
|
||||
"reminder_email": "Påmindelse: {{eventType}} med {{name}} den {{date}}",
|
||||
"not_triggering_existing_bookings": "Vil ikke udløses for allerede eksisterende bookinger, da brugeren vil blive bedt om telefonnummer, når de booker begivenheden.",
|
||||
"minute_one": "{{count}} minut",
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"team_info": "Informationen zum Team",
|
||||
"request_another_invitation_email": "Falls Sie {{toEmail}} lieber nicht als Ihre {{appName}}-E-Mail verwenden möchten oder bereits über ein {{appName}}-Konto verfügen, fordern Sie bitte eine weitere Einladung zu dieser E-Mail an.",
|
||||
"you_have_been_invited": "Sie wurden eingeladen, dem Team {{teamName}} beizutreten",
|
||||
"user_invited_you": "{{user}} hat Sie eingeladen, dem Team {{team}} auf {{appName}} beizutreten",
|
||||
"hidden_team_member_title": "Sie sind in diesem Team ausgeblendet",
|
||||
"hidden_team_member_message": "Ihre Teammitgliedschaft ist nicht bezahlt, entweder upgraden Sie Ihren Account auf Pro oder lassen den Team-Organisator wissen, dass er für Ihren Platz im Team bezahlen kann.",
|
||||
"hidden_team_owner_message": "Sie benötigen einen Pro-Account um die Teamfunktionen zu verweden. Sie werden dem Team nicht angezeigt, bis der Account upgegradet wurde.",
|
||||
@@ -181,7 +180,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Lesen Sie unsere Neuigkeiten und Artikel",
|
||||
"join_our_community": "Treten Sie unserer Community bei",
|
||||
"join_our_slack": "Treten Sie unserem Slack bei",
|
||||
"404_claim_entity_user": "Benutzername registrieren und Termine vergeben",
|
||||
"popular_pages": "Beliebte Seiten",
|
||||
"register_now": "Jetzt registrieren",
|
||||
@@ -503,6 +501,8 @@
|
||||
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.",
|
||||
"members": "Mitglieder",
|
||||
"member": "Mitglied",
|
||||
"number_member_one": "{{count}} Mitglied",
|
||||
"number_member_other": "{{count}} Mitglieder",
|
||||
"owner": "Inhaber",
|
||||
"admin": "Administrator",
|
||||
"administrator_user": "Administrator Benutzer",
|
||||
@@ -639,7 +639,6 @@
|
||||
"create_new_team": "Ein neues Team erstellen",
|
||||
"open_invitations": "Offene Einladungen",
|
||||
"new_team": "Neues Team",
|
||||
"create_first_team_and_invite_others": "Erstellen Sie Ihr erstes Team und laden Sie andere Benutzer ein, mit Ihnen zusammenzuarbeiten.",
|
||||
"create_team_to_get_started": "Erstellen Sie ein Team um loszulegen",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
@@ -1073,8 +1072,8 @@
|
||||
"specific_issue": "Haben Sie ein spezielles Problem",
|
||||
"browse_our_docs": "Durchsuchen Sie unsere Dokumentation",
|
||||
"choose_template": "Vorlage auswählen",
|
||||
"reminder": "Erinnerung",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"reminder": "Erinnerung",
|
||||
"reminder_email": "Erinnerung: {{eventType}} mit {{name}} bei {{date}}",
|
||||
"not_triggering_existing_bookings": "Wird nicht für bereits bestehende Buchungen ausgelöst, da der Benutzer bei der Buchung nach einer Telefonnummer gefragt wird.",
|
||||
"minute_one": "{{count}} Minute",
|
||||
@@ -1676,10 +1675,9 @@
|
||||
"connect_now": "Jetzt verbinden",
|
||||
"managed_event_dialog_confirm_button_one": "{{count}} Mitglied ersetzen & benachrichtigen",
|
||||
"managed_event_dialog_confirm_button_other": "{{count}} Mitglieder ersetzen & benachrichtigen",
|
||||
"managed_event_dialog_title_one": "Die URL /{{slug}} ist bereits für {{count}} Mitglied vorhanden. Möchten Sie sie ersetzen?",
|
||||
"managed_event_dialog_title_other": "Die URL /{{slug}} ist bereits für {{count}} Mitglieder vorhanden. Möchten Sie sie ersetzen?",
|
||||
"managed_event_dialog_title_one": "Die URL /{{slug}} wird bereits von {{count}} Mitglied genutzt. Möchten Sie sie ersetzen?",
|
||||
"managed_event_dialog_title_other": "Die URL /{{slug}} wird bereits von {{count}} Mitgliedern genutzt. Möchten Sie sie ersetzen?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> verwendet bereits die URL <strong>/{{slug}}</strong>-.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> verwenden bereits die URL <strong>/{{slug}}</strong>-.",
|
||||
"managed_event_dialog_clarification": "Wenn Sie die URL ersetzen, werden wir sie benachrichtigen. Gehen Sie zurück und entfernen Sie sie, wenn Sie sie nicht überschreiben möchten.",
|
||||
"review_event_type": "Ereignistyp überprüfen",
|
||||
"looking_for_more_analytics": "Suchen Sie nach weiteren Analysedaten?",
|
||||
@@ -1733,7 +1731,7 @@
|
||||
"payment_app_commission": "Zahlung erfordern ({{paymentFeePercentage}} % + {{fee, currency}} Provision pro Transaktion)",
|
||||
"email_invite_team": "{{email}} wurde eingeladen",
|
||||
"email_invite_team_bulk": "{{userCount}} Benutzer wurden eingeladen",
|
||||
"error_collecting_card": "Fehler beim Erfassen der Karte",
|
||||
"error_collecting_card": "Kartenfehler",
|
||||
"image_size_limit_exceed": "Das hochgeladene Bild sollte die maximale Größe von 5 MB nicht überschreiten",
|
||||
"inline_embed": "Inline-Einbettung",
|
||||
"load_inline_content": "Laden Sie Ihren Termintyp direkt inline mit Ihren anderen Website-Inhalten.",
|
||||
@@ -1749,7 +1747,7 @@
|
||||
"disable_attendees_confirmation_emails_description": "Mindestens ein Workflow, durch den bei Buchung eine E-Mail an die Teilnehmer gesendet wird, ist für diesen Termintyp aktiv.",
|
||||
"disable_host_confirmation_emails": "Standardmäßige Bestätigungs-E-Mails für Veranstalter deaktivieren",
|
||||
"disable_host_confirmation_emails_description": "Mindestens ein Workflow, durch den bei Buchung eine E-Mail an den Veranstalter gesendet wird, ist für diesen Termintyp aktiv.",
|
||||
"add_an_override": "Eine Ausnahmeregelung hinzufügen",
|
||||
"add_an_override": "Eine Überschreibung hinzufügen",
|
||||
"import_from_google_workspace": "Benutzer aus Google Workspace importieren",
|
||||
"connect_google_workspace": "Google Workspace verbinden",
|
||||
"google_workspace_admin_tooltip": "Sie müssen ein Workspace-Admin sein, um diese Funktion nutzen zu können",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"team_info": "Πληροφορίες Ομάδας",
|
||||
"request_another_invitation_email": "Αν προτιμάτε να μην χρησιμοποιείτε το {{toEmail}} ως {{appName}} email σας ή να έχετε ήδη λογαριασμό {{appName}} , παρακαλώ ζητήστε μια άλλη πρόσκληση σε αυτό το email.",
|
||||
"you_have_been_invited": "Έχετε προσκληθεί να γίνετε μέλος της ομάδας {{teamName}}",
|
||||
"user_invited_you": "Ο χρήστης {{user}} σας προσκάλεσε να συμμετάσχετε στην ομάδα {{team}} στο {{appName}}",
|
||||
"hidden_team_member_title": "Είστε κρυμμένοι σε αυτήν την ομάδα",
|
||||
"link_expires": "Υ.Γ. Λήγει σε {{expiresIn}} ώρες.",
|
||||
"upgrade_banner_action": "Αναβάθμισε εδώ",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Team Info",
|
||||
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
|
||||
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the team {{team}} on {{appName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
|
||||
"hidden_team_member_title": "You are hidden in this team",
|
||||
"hidden_team_member_message": "Your seat is not paid for, either Upgrade to PRO or let the team owner know they can pay for your seat.",
|
||||
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
|
||||
@@ -197,7 +197,7 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Read our latest news and articles",
|
||||
"join_our_community": "Join our community",
|
||||
"join_our_slack": "Join our Slack",
|
||||
"join_our_discord": "Join our Discord",
|
||||
"404_claim_entity_user": "Claim your username and schedule events",
|
||||
"popular_pages": "Popular pages",
|
||||
"register_now": "Register now",
|
||||
@@ -241,7 +241,7 @@
|
||||
"done": "Done",
|
||||
"all_done": "All done!",
|
||||
"all": "All",
|
||||
"yours": "Yours",
|
||||
"yours": "Your account",
|
||||
"available_apps": "Available Apps",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
@@ -352,6 +352,7 @@
|
||||
"no_status_bookings_yet_description": "You have no {{status}} bookings. {{description}}",
|
||||
"event_between_users": "{{eventName}} between {{host}} and {{attendeeName}}",
|
||||
"bookings": "Bookings",
|
||||
"booking_not_found": "Booking not found",
|
||||
"bookings_description": "See upcoming and past events booked through your event type links.",
|
||||
"upcoming_bookings": "As soon as someone books a time with you it will show up here.",
|
||||
"recurring_bookings": "As soon as someone books a recurring meeting with you it will show up here.",
|
||||
@@ -504,6 +505,7 @@
|
||||
"additional_guests": "Add guests",
|
||||
"your_name": "Your name",
|
||||
"your_full_name": "Your full name",
|
||||
"no_name":"No name",
|
||||
"email_address": "Email address",
|
||||
"enter_valid_email": "Please enter a valid email",
|
||||
"location": "Location",
|
||||
@@ -551,6 +553,9 @@
|
||||
"members": "Members",
|
||||
"organization_members": "Organization members",
|
||||
"member": "Member",
|
||||
"number_member_one": "{{count}} member",
|
||||
"number_member_other": "{{count}} members",
|
||||
"number_selected": "{{count}} selected",
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"administrator_user": "Administrator user",
|
||||
@@ -657,6 +662,7 @@
|
||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||
"hours": "Hours",
|
||||
"people": "People",
|
||||
"your_email": "Your Email",
|
||||
"change_avatar": "Change Avatar",
|
||||
"language": "Language",
|
||||
@@ -689,7 +695,7 @@
|
||||
"create_new_team": "Create a new team",
|
||||
"open_invitations": "Open Invitations",
|
||||
"new_team": "New Team",
|
||||
"create_first_team_and_invite_others": "Create your first team and invite other users to work together with you.",
|
||||
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
|
||||
"create_team_to_get_started": "Create a team to get started",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
@@ -860,6 +866,7 @@
|
||||
"installed_apps": "Installed Apps",
|
||||
"free_to_use_apps": "Free",
|
||||
"no_category_apps": "No {{category}} apps",
|
||||
"all_apps": "All apps",
|
||||
"no_category_apps_description_calendar": "Add a calendar app to check for conflicts to prevent double bookings",
|
||||
"no_category_apps_description_conferencing": "Try adding a conference app for video calls with your clients",
|
||||
"no_category_apps_description_payment": "Add a payment app to ease transaction between you and your clients",
|
||||
@@ -996,6 +1003,9 @@
|
||||
"user_impersonation_heading": "User Impersonation",
|
||||
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
|
||||
"team_impersonation_description": "Allows your team Owners/Admins to temporarily sign in as you.",
|
||||
"make_team_private": "Make team private",
|
||||
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
|
||||
"you_cannot_see_team_members": "You cannot see all the team members of a private team.",
|
||||
"allow_booker_to_select_duration": "Allow booker to select duration",
|
||||
"impersonate_user_tip": "All uses of this feature is audited.",
|
||||
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
|
||||
@@ -1076,6 +1086,8 @@
|
||||
"email_attendee_action": "send email to attendees",
|
||||
"sms_attendee_action": "Send SMS to attendee",
|
||||
"sms_number_action": "send SMS to a specific number",
|
||||
"whatsapp_number_action": "send Whatsapp to a specific number",
|
||||
"whatsapp_attendee_action": "send Whatsapp to attendee",
|
||||
"workflows": "Workflows",
|
||||
"new_workflow_btn": "New Workflow",
|
||||
"add_new_workflow": "Add a new workflow",
|
||||
@@ -1137,8 +1149,10 @@
|
||||
"specific_issue": "Have a specific issue?",
|
||||
"browse_our_docs": "browse our docs",
|
||||
"choose_template": "Choose a template",
|
||||
"reminder": "Reminder",
|
||||
"custom": "Custom",
|
||||
"reminder": "Reminder",
|
||||
"rescheduled": "Rescheduled",
|
||||
"completed": "Completed",
|
||||
"reminder_email": "Reminder: {{eventType}} with {{name}} at {{date}}",
|
||||
"not_triggering_existing_bookings": "Won't trigger for already existing bookings as user will be asked for phone number when booking the event.",
|
||||
"minute_one": "{{count}} minute",
|
||||
@@ -1451,6 +1465,7 @@
|
||||
"new_event_type_availability": "{{eventTypeTitle}} Availability",
|
||||
"error_editing_availability": "Error editing availability",
|
||||
"dont_have_permission": "You don't have permission to access this resource.",
|
||||
"saml_config": "SAML",
|
||||
"saml_configuration_placeholder": "Please paste the SAML metadata from your Identity Provider here",
|
||||
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
|
||||
"saml_sp_title": "Service Provider Details",
|
||||
@@ -1480,7 +1495,7 @@
|
||||
"team_url_required": "Must enter a team URL",
|
||||
"url_taken": "This URL is already taken",
|
||||
"team_publish": "Publish team",
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"number_text_notifications": "Phone number (Text notifications)",
|
||||
"attendee_email_variable": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
"kbar_search_placeholder": "Type a command or search...",
|
||||
@@ -1543,6 +1558,7 @@
|
||||
"apps_settings": "Apps settings",
|
||||
"fill_this_field": "Please fill in this field",
|
||||
"options": "Options",
|
||||
"no_options_available":"No options available",
|
||||
"enter_option": "Enter Option {{index}}",
|
||||
"add_an_option": "Add an option",
|
||||
"radio": "Radio",
|
||||
@@ -1773,7 +1789,7 @@
|
||||
"managed_event_dialog_title_one": "The url /{{slug}} already exists for {{count}} member. Do you want to replace it?",
|
||||
"managed_event_dialog_title_other": "The url /{{slug}} already exists for {{count}} members. Do you want to replace it?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> is already using the <strong>/{{slug}}</strong> url.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> are already using the <string>/{{slug}}</strong> url.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> are already using the <strong>/{{slug}}</strong> url.",
|
||||
"managed_event_dialog_clarification": "If you choose to replace it, we will notify them. Go back and remove them if you don't want to overwrite it.",
|
||||
"review_event_type": "Review Event Type",
|
||||
"looking_for_more_analytics": "Looking for more analytics?",
|
||||
@@ -1863,6 +1879,7 @@
|
||||
"connect_google_workspace": "Connect Google Workspace",
|
||||
"google_workspace_admin_tooltip": "You must be a Workspace Admin to use this feature",
|
||||
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||
"install_app_on": "Install app on",
|
||||
"create_for": "Create for",
|
||||
"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.",
|
||||
@@ -1900,8 +1917,8 @@
|
||||
"team_names_empty": "Team names can't be empty",
|
||||
"team_names_repeated": "Team names can't be repeated",
|
||||
"user_belongs_organization": "User belongs to an organization",
|
||||
"no_teams_yet": "This organization has no teams yet",
|
||||
"no_teams_yet_description": "if you are an administrator, be sure to create teams to be shown here.",
|
||||
"org_no_teams_yet": "This organization has no teams yet",
|
||||
"org_no_teams_yet_description": "If you are an administrator, be sure to create teams to be shown here.",
|
||||
"set_up": "Set up",
|
||||
"set_up_your_profile": "Set up your profile",
|
||||
"set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.",
|
||||
@@ -1910,6 +1927,7 @@
|
||||
"crm": "CRM",
|
||||
"messaging": "Messaging",
|
||||
"sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)",
|
||||
"org_admins_can_create_new_teams": "Only the admin of your organization can create new teams",
|
||||
"google_new_spam_policy": "Google’s new spam policy could prevent you from receiving any email and calendar notifications about this booking.",
|
||||
"resolve": "Resolve",
|
||||
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"team_info": "Información del equipo",
|
||||
"request_another_invitation_email": "Si prefiere no utilizar {{toEmail}} como su correo electrónico de {{appName}} o ya tiene una cuenta de {{appName}}, por favor solicite otra invitación a ese email.",
|
||||
"you_have_been_invited": "Has sido invitado a unirte al equipo {{teamName}}",
|
||||
"user_invited_you": "{{user}} te invitó a unirte al equipo {{team}} en {{appName}}",
|
||||
"hidden_team_member_title": "Estás oculto en este equipo",
|
||||
"hidden_team_member_message": "No está paga tu plaza, actualiza a PRO o infórmale al propietario del equipo para que pague tu plaza.",
|
||||
"hidden_team_owner_message": "Necesitas una cuenta Pro para gestionar equipos, permanecerás oculto hasta que se realice la mejora.",
|
||||
@@ -181,7 +180,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Lee nuestras últimas noticias y artículos",
|
||||
"join_our_community": "Únete a nuestra comunidad",
|
||||
"join_our_slack": "Únete a nuestro Slack",
|
||||
"404_claim_entity_user": "Reclama tu nombre de usuario y programa eventos",
|
||||
"popular_pages": "Páginas Populares",
|
||||
"register_now": "Regístrate Ahora",
|
||||
@@ -503,6 +501,8 @@
|
||||
"team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.",
|
||||
"members": "Miembros",
|
||||
"member": "Miembro",
|
||||
"number_member_one": "{{count}} miembro",
|
||||
"number_member_other": "{{count}} miembros",
|
||||
"owner": "Propietario",
|
||||
"admin": "Administrador",
|
||||
"administrator_user": "Usuario administrador",
|
||||
@@ -639,7 +639,6 @@
|
||||
"create_new_team": "Crear Nuevo Equipo",
|
||||
"open_invitations": "Invitaciones Abiertas",
|
||||
"new_team": "Nuevo Equipo",
|
||||
"create_first_team_and_invite_others": "Crea tu primer equipo e invita a otros usuarios a trabajar contigo.",
|
||||
"create_team_to_get_started": "Crea un equipo para empezar",
|
||||
"teams": "Equipos",
|
||||
"team": "Equipo",
|
||||
@@ -1073,8 +1072,8 @@
|
||||
"specific_issue": "¿Tienes un problema específico?",
|
||||
"browse_our_docs": "navega por nuestros documentos",
|
||||
"choose_template": "Elija una plantilla",
|
||||
"reminder": "Recordatorio",
|
||||
"custom": "Personalizado",
|
||||
"reminder": "Recordatorio",
|
||||
"reminder_email": "Recordatorio: {{eventType}} con {{name}} el {{date}}",
|
||||
"not_triggering_existing_bookings": "No se activará para las reservas ya existentes, ya que se le pedirá al usuario un número de teléfono al reservar el evento.",
|
||||
"minute_one": "{{count}} minuto",
|
||||
@@ -1679,7 +1678,6 @@
|
||||
"managed_event_dialog_title_one": "La url /{{slug}} ya existe para {{count}} miembro. ¿Desea reemplazarla?",
|
||||
"managed_event_dialog_title_other": "La url /{{slug}} ya existe para {{count}} miembros. ¿Desea reemplazarla?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> ya usa la url <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> ya usan la url <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Si elige reemplazarlo, se lo notificaremos. Regrese y elimínelos si no desea sobrescribirlo.",
|
||||
"review_event_type": "Revisar tipo de evento",
|
||||
"looking_for_more_analytics": "¿Busca más datos de análisis?",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Informations sur l'équipe",
|
||||
"request_another_invitation_email": "Si vous préférez ne pas utiliser {{toEmail}} comme votre adresse e-mail {{appName}} ou si vous avez déjà un compte {{appName}}, veuillez demander une autre invitation à cette adresse.",
|
||||
"you_have_been_invited": "Vous avez été invité(e) à rejoindre l'équipe {{teamName}}",
|
||||
"user_invited_you": "{{user}} vous a invité à rejoindre l'équipe {{team}} sur {{appName}}",
|
||||
"user_invited_you": "{{user}} vous a invité à rejoindre l'{{entity}} {{team}} sur {{appName}}",
|
||||
"hidden_team_member_title": "Vous êtes masqué(e) dans cette équipe",
|
||||
"hidden_team_member_message": "Votre place n'est pas payée, passez au plan Pro ou faites savoir au propriétaire de l'équipe qu'il peut payer votre place.",
|
||||
"hidden_team_owner_message": "Vous avez besoin d'un compte Pro pour utiliser les équipes, vous êtes masqué(e) jusqu'à ce que vous mettiez à niveau.",
|
||||
@@ -197,7 +197,7 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Lire nos dernières actualités et articles",
|
||||
"join_our_community": "Rejoignez notre communauté",
|
||||
"join_our_slack": "Rejoindre notre Slack",
|
||||
"join_our_discord": "Rejoignez notre Discord",
|
||||
"404_claim_entity_user": "Réservez votre nom d'utilisateur et planifiez des événements",
|
||||
"popular_pages": "Pages populaires",
|
||||
"register_now": "S'inscrire maintenant",
|
||||
@@ -241,7 +241,7 @@
|
||||
"done": "Terminé",
|
||||
"all_done": "Terminé !",
|
||||
"all": "Tous",
|
||||
"yours": "Les vôtres",
|
||||
"yours": "Votre compte",
|
||||
"available_apps": "Applications disponibles",
|
||||
"check_email_reset_password": "Vérifiez vos e-mails. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe.",
|
||||
"finish": "Terminer",
|
||||
@@ -352,6 +352,7 @@
|
||||
"no_status_bookings_yet_description": "Vous n'avez pas de réservations {{status}}. {{description}}",
|
||||
"event_between_users": "{{eventName}} entre {{host}} et {{attendeeName}}",
|
||||
"bookings": "Réservations",
|
||||
"booking_not_found": "Réservation introuvable",
|
||||
"bookings_description": "Consultez les événements à venir et passés réservés via vos liens de type d'événement.",
|
||||
"upcoming_bookings": "Dès que quelqu'un réservera un rendez-vous avec vous, il apparaîtra ici.",
|
||||
"recurring_bookings": "Dès que quelqu'un réserve un rendez-vous récurrent avec vous, il apparaît ici.",
|
||||
@@ -504,6 +505,7 @@
|
||||
"additional_guests": "Ajouter des invités",
|
||||
"your_name": "Votre nom",
|
||||
"your_full_name": "Votre nom complet",
|
||||
"no_name": "Pas de nom",
|
||||
"email_address": "Adresse e-mail",
|
||||
"enter_valid_email": "Veuillez saisir une adresse e-mail valide",
|
||||
"location": "Lieu",
|
||||
@@ -551,6 +553,9 @@
|
||||
"members": "Membres",
|
||||
"organization_members": "Membres de l'organisation",
|
||||
"member": "Membre",
|
||||
"number_member_one": "{{count}} membre",
|
||||
"number_member_other": "{{count}} membres",
|
||||
"number_selected": "{{count}} sélectionnés",
|
||||
"owner": "Propriétaire",
|
||||
"admin": "Administrateur",
|
||||
"administrator_user": "Utilisateur administrateur",
|
||||
@@ -657,6 +662,7 @@
|
||||
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour avec succès",
|
||||
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
||||
"hours": "heures",
|
||||
"people": "Personnes",
|
||||
"your_email": "Votre adresse e-mail",
|
||||
"change_avatar": "Changer d'avatar",
|
||||
"language": "Langue",
|
||||
@@ -689,7 +695,7 @@
|
||||
"create_new_team": "Créer une nouvelle équipe",
|
||||
"open_invitations": "Invitations ouvertes",
|
||||
"new_team": "Nouvelle équipe",
|
||||
"create_first_team_and_invite_others": "Créez votre première équipe et invitez d'autres utilisateurs à travailler avec vous.",
|
||||
"create_first_team_and_invite_others": "Créez votre première équipe et invitez d'autres utilisateurs à travailler ensemble.",
|
||||
"create_team_to_get_started": "Créez une équipe pour commencer",
|
||||
"teams": "Équipes",
|
||||
"team": "Équipe",
|
||||
@@ -783,7 +789,6 @@
|
||||
"add_new_event_type": "Ajouter un nouveau type d'événement",
|
||||
"new_event_type_to_book_description": "Créez un nouveau type d’événement pour que les personnes puissent effectuer des réservations.",
|
||||
"length": "Durée",
|
||||
"people": "Personnes",
|
||||
"minimum_booking_notice": "Préavis minimum",
|
||||
"offset_toggle": "Décalage de l'heure de début",
|
||||
"offset_toggle_description": "Appliquez un décalage de durée spécifique aux créneaux horaires affichés aux utilisateurs.",
|
||||
@@ -860,6 +865,7 @@
|
||||
"installed_apps": "Apps installées",
|
||||
"free_to_use_apps": "Gratuit",
|
||||
"no_category_apps": "Aucune application {{category}}",
|
||||
"all_apps": "Toutes les apps",
|
||||
"no_category_apps_description_calendar": "Ajoutez une application de calendrier pour vérifier les conflits et éviter les doubles réservations.",
|
||||
"no_category_apps_description_conferencing": "Essayez d'ajouter une application de visioconférence pour les appels vidéo avec vos clients.",
|
||||
"no_category_apps_description_payment": "Ajoutez une application de paiement pour faciliter les transactions entre vous et vos clients.",
|
||||
@@ -996,6 +1002,9 @@
|
||||
"user_impersonation_heading": "Emprunt d'identité",
|
||||
"user_impersonation_description": "Autorisez notre équipe d'assistance à se connecter temporairement à votre place pour nous aider à résoudre les problèmes que vous pourriez nous signaler.",
|
||||
"team_impersonation_description": "Autorisez les propriétaires/administrateurs de votre équipe à se connecter temporairement à votre place.",
|
||||
"make_team_private": "Rendre l'équipe privée",
|
||||
"make_team_private_description": "Les membres de votre équipe ne pourront pas voir les autres membres de l'équipe si cette option est activée.",
|
||||
"you_cannot_see_team_members": "Vous ne pouvez pas voir tous les membres d'une équipe privée.",
|
||||
"allow_booker_to_select_duration": "Autoriser l'utilisateur à sélectionner la durée",
|
||||
"impersonate_user_tip": "Toutes les utilisations de cette fonctionnalité sont vérifiées.",
|
||||
"impersonating_user_warning": "Emprunt du nom d'utilisateur « {{user}} ».",
|
||||
@@ -1137,8 +1146,8 @@
|
||||
"specific_issue": "Vous avez un problème particulier ?",
|
||||
"browse_our_docs": "parcourir notre documentation",
|
||||
"choose_template": "Choisir un modèle",
|
||||
"reminder": "Rappel",
|
||||
"custom": "Personnalisé",
|
||||
"reminder": "Rappel",
|
||||
"reminder_email": "Rappel : {{eventType}} avec {{name}} le {{date}}",
|
||||
"not_triggering_existing_bookings": "Ne se déclenchera pas pour les réservations déjà existantes car l'utilisateur sera invité à fournir un numéro de téléphone lors de la réservation de l'événement.",
|
||||
"minute_one": "{{count}} minute",
|
||||
@@ -1451,6 +1460,7 @@
|
||||
"new_event_type_availability": "Disponibilités de {{eventTypeTitle}}",
|
||||
"error_editing_availability": "Erreur lors de la modification des disponibilités",
|
||||
"dont_have_permission": "Vous n'avez pas la permission d'accéder à cette ressource.",
|
||||
"saml_config": "SAML",
|
||||
"saml_configuration_placeholder": "Veuillez coller les métadonnées SAML de votre fournisseur d'identité ici.",
|
||||
"saml_email_required": "Veuillez saisir une adresse e-mail pour que nous puissions trouver votre fournisseur d'identité SAML",
|
||||
"saml_sp_title": "Informations sur le fournisseur de services",
|
||||
@@ -1480,7 +1490,6 @@
|
||||
"team_url_required": "Vous devez saisir un lien d'équipe",
|
||||
"url_taken": "Ce lien est déjà pris",
|
||||
"team_publish": "Publier l'équipe",
|
||||
"number_sms_notifications": "Numéro de téléphone (notifications par SMS)",
|
||||
"attendee_email_variable": "Adresse e-mail du participant",
|
||||
"attendee_email_info": "Adresse e-mail du participant",
|
||||
"kbar_search_placeholder": "Saisissez une commande ou une recherche...",
|
||||
@@ -1773,7 +1782,6 @@
|
||||
"managed_event_dialog_title_one": "Le lien /{{slug}} existe déjà pour {{count}} membre. Voulez-vous le remplacer ?",
|
||||
"managed_event_dialog_title_other": "Le lien /{{slug}} existe déjà pour {{count}} membres. Voulez-vous le remplacer ?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> utilise déjà le lien <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> utilisent déjà le lien <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Si vous choisissez de le remplacer, nous les en informerons. Revenez en arrière et supprimez-les si vous ne souhaitez pas l'écraser.",
|
||||
"review_event_type": "Examiner le type d'événement",
|
||||
"looking_for_more_analytics": "Vous cherchez plus d'analyses ?",
|
||||
@@ -1863,6 +1871,7 @@
|
||||
"connect_google_workspace": "Connecter Google Workspace",
|
||||
"google_workspace_admin_tooltip": "Vous devez être un administrateur d'espace de travail pour utiliser cette fonctionnalité",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -1900,8 +1909,8 @@
|
||||
"team_names_empty": "Les noms d'équipe ne peuvent pas être vides",
|
||||
"team_names_repeated": "Les noms d'équipe ne peuvent pas être répétés",
|
||||
"user_belongs_organization": "L'utilisateur appartient à une organisation",
|
||||
"no_teams_yet": "Cette organisation n'a pas encore d'équipe",
|
||||
"no_teams_yet_description": "si vous êtes administrateur, assurez-vous de créer des équipes à afficher ici.",
|
||||
"org_no_teams_yet": "Cette organisation n'a pas encore d'équipe",
|
||||
"org_no_teams_yet_description": "Si vous êtes un administrateur, assurez-vous de créer des équipes à afficher ici.",
|
||||
"set_up": "Configurer",
|
||||
"set_up_your_profile": "Configurer votre profil",
|
||||
"set_up_your_profile_description": "Faites savoir aux gens qui vous êtes au sein de {{orgName}} et quand ils interagissent avec votre lien public.",
|
||||
@@ -1910,6 +1919,7 @@
|
||||
"crm": "CRM",
|
||||
"messaging": "Messagerie",
|
||||
"sender_id_info": "Nom ou numéro affiché comme expéditeur d'un SMS (certains pays n'autorisent pas les ID d'expéditeur alphanumériques)",
|
||||
"org_admins_can_create_new_teams": "Seul l'administrateur de votre organisation peut créer de nouvelles équipes",
|
||||
"google_new_spam_policy": "La nouvelle politique anti-spam de Google peut vous empêcher de recevoir des notifications par e-mail ou sur votre calendrier concernant cette réservation.",
|
||||
"resolve": "Résoudre",
|
||||
"no_organization_slug": "Une erreur s'est produite lors de la création des équipes pour cette organisation. Le slug du lien est manquant.",
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
"team_info": "מידע על הצוות",
|
||||
"request_another_invitation_email": "אם אתה מעדיף לא להשתמש בכתובת {{toEmail}} ככתובת הדוא\"ל שלך עבור {{appName}} או שכבר יש לך חשבון {{appName}}, יש לבקש לקבל הזמנה נוספת לכתובת הדוא\"ל הרצויה.",
|
||||
"you_have_been_invited": "הוזמנת להצטרף לצוות {{teamName}}",
|
||||
"user_invited_you": "{{user}} הזמין אותך להצטרף לצוות {{team}} ב-{{appName}}",
|
||||
"hidden_team_member_title": "אתה מוסתר בצוות זה",
|
||||
"hidden_team_member_message": "לא בוצע תשלום עבור המקום שלך. ניתן לשדרג ל-PRO או ליידע את הבעלים של הצוות שהוא או היא יכולים לשלם עבור המקום שלך.",
|
||||
"hidden_team_owner_message": "נדרש חשבון Pro כדי להשתמש בתכונות הצוותים. תהיה מוסתר עד שתבצע שידרוג.",
|
||||
@@ -188,7 +187,6 @@
|
||||
"blog": "בלוג",
|
||||
"blog_description": "לעיון בחדשות ובמאמרים האחרונים שלנו",
|
||||
"join_our_community": "הצטרף לקהילה שלנו",
|
||||
"join_our_slack": "הצטרף לצ׳אט שלנו ב-Slack",
|
||||
"404_claim_entity_user": "קבל את שם המשתמש שלך והתחל לקבוע מועדים לאירועים",
|
||||
"popular_pages": "דפים פופולריים",
|
||||
"register_now": "להירשם עכשיו",
|
||||
@@ -465,7 +463,7 @@
|
||||
"friday": "יום שישי",
|
||||
"saturday": "יום שבת",
|
||||
"sunday": "יום ראשון",
|
||||
"all_booked_today": "כל מה שהוזמן.",
|
||||
"all_booked_today": "הכל הוזמן.",
|
||||
"slots_load_fail": "לא ניתן לטעון את חלונות הזמן הזמינים.",
|
||||
"additional_guests": "הוספת אורחים",
|
||||
"your_name": "שמך",
|
||||
@@ -515,6 +513,8 @@
|
||||
"team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.",
|
||||
"members": "חברים",
|
||||
"member": "חבר/ת",
|
||||
"number_member_one": "חבר {{count}}",
|
||||
"number_member_other": "{{count}} חברים",
|
||||
"owner": "בעלים",
|
||||
"admin": "מנהל/ת מערכת",
|
||||
"administrator_user": "משתמש המוגדר כמנהל מערכת",
|
||||
@@ -651,7 +651,6 @@
|
||||
"create_new_team": "יצירת צוות חדש",
|
||||
"open_invitations": "הזמנות פתוחות",
|
||||
"new_team": "צוות חדש",
|
||||
"create_first_team_and_invite_others": "צור את הצוות הראשון שלך והזמן משתמשים אחרים לעבוד יחד איתך.",
|
||||
"create_team_to_get_started": "צור צוות כדי להתחיל",
|
||||
"teams": "צוותים",
|
||||
"team": "צוות",
|
||||
@@ -1085,8 +1084,8 @@
|
||||
"specific_issue": "נתקלת בבעיה ספציפית?",
|
||||
"browse_our_docs": "לדפדף במסמכים שלנו",
|
||||
"choose_template": "בחירת תבנית",
|
||||
"reminder": "תזכורת",
|
||||
"custom": "התאמה אישית",
|
||||
"reminder": "תזכורת",
|
||||
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
|
||||
"minute_one": "{{count}} דקה",
|
||||
@@ -1691,7 +1690,6 @@
|
||||
"managed_event_dialog_title_one": "כתובת ה-URL /{{slug}} כבר קיימת עבור חבר/ה {{count}}. האם ברצונך להחליף אותה?",
|
||||
"managed_event_dialog_title_other": "כתובת ה-URL /{{slug}} כבר קיימת עבור {{count}} חברים. האם ברצונך להחליף אותה?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> כבר משתמש/ת בכתובת ה-URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> כבר משתמשים בכתובת ה-URL <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "אם תבחר/י להחליף את כתובת ה-URL, אנו נודיע על כך לחברים. אם אינך רוצה להחליף אותה, חזור/י והסר/י את החברים.",
|
||||
"review_event_type": "בדיקת סוג האירוע",
|
||||
"looking_for_more_analytics": "מחפש עוד מידע אנליטי?",
|
||||
@@ -1745,7 +1743,7 @@
|
||||
"payment_app_commission": "דרישת תשלום ({{paymentFeePercentage}}% + {{fee, currency}} עמלה על העסקה)",
|
||||
"email_invite_team": "נשלחה הזמנה ל-{{email}}",
|
||||
"email_invite_team_bulk": "{{userCount}} משתמשים הוזמנו",
|
||||
"error_collecting_card": "שגיאה בעיבוד הכרטיס",
|
||||
"error_collecting_card": "שגיאה בחיוב הכרטיס",
|
||||
"image_size_limit_exceed": "גודל התמונה המרבי שניתן להעלות הוא 5MB",
|
||||
"inline_embed": "הטמעה באתר",
|
||||
"load_inline_content": "סוג האירוע נטען ישירות בתוך שאר תוכן האתר שלך.",
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"team_info": "Informacije o timu",
|
||||
"request_another_invitation_email": "Ako ne želite koristiti {{toEmail}} kao vašu {{appName}} adresu e-pošte ili već imate {{appName}} račun, zatražite još jednu pozivnicu na tu adresu.",
|
||||
"you_have_been_invited": "Pozvani ste da se pridružite timu {{teamName}}",
|
||||
"user_invited_you": "{{user}} vas je pozvao da se pridružite timu {{team}} na {{appName}}",
|
||||
"hidden_team_member_title": "Sakriveni ste u ovom timu",
|
||||
"hidden_team_member_message": "Vaše mjesto nije plaćeno, ili se pretplatite na PRO ili obavijestite vođu tima da plati za vaše mjesto.",
|
||||
"hidden_team_owner_message": "Potreban vam je PRO račun za korištenje timova, sakriveni ste dok se ne pretplatite.",
|
||||
@@ -170,7 +169,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Pročitajte naše najnovije vijesti i članke",
|
||||
"join_our_community": "Pridružite se našoj zajednici",
|
||||
"join_our_slack": "Pridružite se na naš Slack",
|
||||
"404_claim_entity_user": "Zatražite svoje korisničko ime i zakažite događaje",
|
||||
"popular_pages": "Popularne stranice",
|
||||
"register_now": "Registriraj se sad",
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
"team_info": "Informasi Tim",
|
||||
"request_another_invitation_email": "Jika kamu lebih suka tidak menggukan {{toEmail}} sebagai email {{appName}} atau telah memiliki akun {{appName}} silakan membuat permintaan lain kepada email tersebut.",
|
||||
"you_have_been_invited": "Kamu telah diundang untuk bergabung ke tim {{teamName}}",
|
||||
"user_invited_you": "{{user}} mengundangmu untuk bergabung ke tim {{team}} di {{appName}}",
|
||||
"hidden_team_member_title": "Kamu tersembunyi di tim ini",
|
||||
"hidden_team_member_message": "Kamu tidak dibayari, upgrade ke PRO atau beritahu pemilik tim bahwa mereka bisa membayari kamu.",
|
||||
"hidden_team_owner_message": "Kamu perlu akun pro untuk menggunakan tim, kamu disembunyikan sementara hingga kamu upgrade.",
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
"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 alla squadra {{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.",
|
||||
@@ -189,7 +188,6 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Leggi le nostre ultime notizie e articoli",
|
||||
"join_our_community": "Unisciti alla nostra community",
|
||||
"join_our_slack": "Unisciti al nostro Slack",
|
||||
"404_claim_entity_user": "Richiedi il tuo nome utente e pianifica eventi",
|
||||
"popular_pages": "Pagine popolari",
|
||||
"register_now": "Registrati ora",
|
||||
@@ -477,7 +475,7 @@
|
||||
"friday": "Venerdì",
|
||||
"saturday": "Sabato",
|
||||
"sunday": "Domenica",
|
||||
"all_booked_today": "Tutto prenotato.",
|
||||
"all_booked_today": "Nessuna disponibilità.",
|
||||
"slots_load_fail": "Impossibile caricare gli intervalli di tempo disponibili.",
|
||||
"additional_guests": "Aggiungi ospiti",
|
||||
"your_name": "Il tuo nome",
|
||||
@@ -527,6 +525,8 @@
|
||||
"team_description": "Alcune frasi sul tuo team. Appariranno nella pagina URL del team.",
|
||||
"members": "Membri",
|
||||
"member": "Membri",
|
||||
"number_member_one": "{{count}} membro",
|
||||
"number_member_other": "{{count}} membri",
|
||||
"owner": "Proprietario",
|
||||
"admin": "Amministratore",
|
||||
"administrator_user": "Utente amministratore",
|
||||
@@ -663,7 +663,6 @@
|
||||
"create_new_team": "Crea un nuovo team",
|
||||
"open_invitations": "Inviti Aperti",
|
||||
"new_team": "Nuovo Team",
|
||||
"create_first_team_and_invite_others": "Crea il tuo primo team e invita altri utenti a lavorare insieme a te.",
|
||||
"create_team_to_get_started": "Crea una squadra per iniziare",
|
||||
"teams": "Team",
|
||||
"team": "Team",
|
||||
@@ -1099,8 +1098,8 @@
|
||||
"specific_issue": "Hai un problema specifico?",
|
||||
"browse_our_docs": "sfoglia i nostri documenti",
|
||||
"choose_template": "Scegli un modello",
|
||||
"reminder": "Promemoria",
|
||||
"custom": "Personalizzato",
|
||||
"reminder": "Promemoria",
|
||||
"reminder_email": "Promemoria: {{eventType}} con {{name}} il {{date}}",
|
||||
"not_triggering_existing_bookings": "Non verrà attivato per le prenotazioni già esistenti, in quanto agli utenti verrà chiesto di specificare il numero di telefono al momento della prenotazione dell'evento.",
|
||||
"minute_one": "{{count}} minuto",
|
||||
@@ -1670,7 +1669,7 @@
|
||||
"no_routes_defined": "Nessun percorso definito",
|
||||
"this_will_be_the_placeholder": "Questo sarà il segnaposto",
|
||||
"timeslot_missing_title": "Nessuna fascia oraria selezionata",
|
||||
"timeslot_missing_description": "Selezionare una fascia oraria per prenotare l'evento.",
|
||||
"timeslot_missing_description": "Seleziona una fascia oraria per prenotare l'evento.",
|
||||
"timeslot_missing_cta": "Seleziona fascia oraria",
|
||||
"switch_monthly": "Passa alla vista mensile",
|
||||
"switch_weekly": "Passa alla vista settimanale",
|
||||
@@ -1712,9 +1711,8 @@
|
||||
"managed_event_dialog_confirm_button_one": "Sostituisci e invia notifica a {{count}} membro",
|
||||
"managed_event_dialog_confirm_button_other": "Sostituisci e invia notifica a {{count}} membri",
|
||||
"managed_event_dialog_title_one": "L'URL /{{slug}} esiste già per {{count}} membro. Vuoi sostituirlo?",
|
||||
"managed_event_dialog_title_other": "L'URL /{{slug}} esiste già per {{count}} membri. Sostituirlo?",
|
||||
"managed_event_dialog_title_other": "L'URL /{{slug}} esiste già per {{count}} membri. Vuoi sostituirlo?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> utilizza già l'URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> utilizzano già l'URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Se decidi di sostituirlo, i membri ne saranno avvisati. Torna indietro e rimuovili se non desideri sovrascriverlo.",
|
||||
"review_event_type": "Rivedi tipo di evento",
|
||||
"looking_for_more_analytics": "Hai bisogno di più dati analitici?",
|
||||
@@ -1773,9 +1771,9 @@
|
||||
"thirty_days": "30 giorni",
|
||||
"team_invite_received": "Hai ricevuto un invito a unirti al team {{teamName}}",
|
||||
"currency_string": "{{amount, currency}}",
|
||||
"charge_card_dialog_body": "Stai per addebitare {{amount, currency}} al partecipante. Continuare?",
|
||||
"charge_card_dialog_body": "Stai per addebitare {{amount, currency}} al partecipante. Confermi di voler proseguire?",
|
||||
"charge_attendee": "Addebita {{amount, currency}} al partecipante",
|
||||
"payment_app_commission": "Pagamento richiesto ({{paymentFeePercentage}}% + {{fee, currency}} di commissione per transazione)",
|
||||
"payment_app_commission": "Richiedi un pagamento ({{paymentFeePercentage}}% + {{fee, currency}} di commissione per transazione)",
|
||||
"email_invite_team": "{{email}} è stato invitato",
|
||||
"email_invite_team_bulk": "{{userCount}} utenti sono stati invitati",
|
||||
"error_collecting_card": "Errore durante l'elaborazione della carta",
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
"team_info": "チームの情報",
|
||||
"request_another_invitation_email": "{{toEmail}} を {{appName}} のメールアドレスとして使用しない場合、または {{appName}} アカウントをすでに持っている場合は、そのメールへの招待を別にリクエストしてください。",
|
||||
"you_have_been_invited": "チーム {{teamName}} への参加を招待されました。",
|
||||
"user_invited_you": "{{user}} があなたを {{appName}} のチーム {{team}} に招待しました",
|
||||
"hidden_team_member_title": "あなたはこのチームで非表示にされています",
|
||||
"hidden_team_member_message": "座席料の支払いが完了していません。Pro へとアップグレードするか、チームのオーナーにあなたの座席料の支払いをご依頼ください。",
|
||||
"hidden_team_owner_message": "チームをご利用いただくためには、Pro アカウントが必要です。アップグレードするまでは非表示となります。",
|
||||
@@ -181,7 +180,6 @@
|
||||
"blog": "ブログ",
|
||||
"blog_description": "最新のニュースや記事を読む",
|
||||
"join_our_community": "コミュニティーに参加する",
|
||||
"join_our_slack": "Slack に参加する",
|
||||
"404_claim_entity_user": "ユーザー名を獲得してイベントをスケジュールする",
|
||||
"popular_pages": "人気のページ",
|
||||
"register_now": "今すぐ登録",
|
||||
@@ -503,6 +501,8 @@
|
||||
"team_description": "あなたのチームについての簡単な説明文です。あなたのチームの URL ページに表示されます。",
|
||||
"members": "メンバー",
|
||||
"member": "メンバー",
|
||||
"number_member_one": "{{count}} 人のメンバー",
|
||||
"number_member_other": "{{count}} 人のメンバー",
|
||||
"owner": "所有者",
|
||||
"admin": "管理者",
|
||||
"administrator_user": "管理者ユーザー",
|
||||
@@ -639,7 +639,6 @@
|
||||
"create_new_team": "新しいチームを作成",
|
||||
"open_invitations": "招待状を開く",
|
||||
"new_team": "新しいチーム",
|
||||
"create_first_team_and_invite_others": "あなたの最初のチームを作成し、あなたと一緒に仕事をするために他のユーザーを招待してください。",
|
||||
"create_team_to_get_started": "始めるためにチームを作成",
|
||||
"teams": "チーム",
|
||||
"team": "チーム",
|
||||
@@ -1073,8 +1072,8 @@
|
||||
"specific_issue": "特定の問題がおありですか?",
|
||||
"browse_our_docs": "ドキュメントを閲覧",
|
||||
"choose_template": "テンプレートを選択する",
|
||||
"reminder": "リマインダー",
|
||||
"custom": "カスタム",
|
||||
"reminder": "リマインダー",
|
||||
"reminder_email": "リマインダー:{{date}} の {{name}} との {{eventType}}",
|
||||
"not_triggering_existing_bookings": "イベントの予約時に電話番号の入力を求められるため、すでにある予約はトリガーされません。",
|
||||
"minute_one": "{{count}} 分",
|
||||
@@ -1679,7 +1678,6 @@
|
||||
"managed_event_dialog_title_one": "{{count}} 人のメンバーがすでに URL /{{slug}} を使用しています。置き換えますか?",
|
||||
"managed_event_dialog_title_other": "{{count}} 人のメンバーがすでに URL /{{slug}} を使用しています。置き換えますか?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> がすでに URL <strong>/{{slug}}</strong> を使用しています。",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> らがすでに URL <string>/{{slug}}</strong> を使用しています。",
|
||||
"managed_event_dialog_clarification": "置き換えを選択すると、通知が送られます。上書きしたくない場合には、戻って削除してください。",
|
||||
"review_event_type": "イベントの種類を確認",
|
||||
"looking_for_more_analytics": "詳しい分析情報をお探しですか?",
|
||||
@@ -1735,7 +1733,7 @@
|
||||
"email_invite_team_bulk": "{{userCount}} 人のユーザーが招待されました",
|
||||
"error_collecting_card": "カードの収集中にエラーが発生しました",
|
||||
"image_size_limit_exceed": "アップロードする画像のサイズは 5 MB 以下にする必要があります",
|
||||
"inline_embed": "インラインで埋め込む",
|
||||
"inline_embed": "インライン埋め込み",
|
||||
"load_inline_content": "イベントの種類を他のウェブサイト内コンテンツにインラインで直接読み込みます。",
|
||||
"floating_pop_up_button": "フローティングポップアップボタン",
|
||||
"floating_button_trigger_modal": "モーダルウィンドウをご利用のイベントの種類でトリガーするフローティングボタンをサイトに設置します。",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user