Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c51ba483 | ||
|
|
b6eaafa39f | ||
|
|
98707e66e1 | ||
|
|
44308cbfa0 | ||
|
|
352a9e2fec | ||
|
|
c1a9f4b656 | ||
|
|
32c6ca0ad5 | ||
|
|
e79b4168f2 | ||
|
|
54833b65ff | ||
|
|
26afc077a7 | ||
|
|
0093b6faa5 | ||
|
|
ef49fc4ce1 | ||
|
|
605f7d35ec | ||
|
|
9b37d65503 | ||
|
|
a1f9012501 | ||
|
|
008016902e | ||
|
|
9f33d85903 | ||
|
|
496b03e92f | ||
|
|
358da42a9b | ||
|
|
8812580f72 | ||
|
|
3009f17cb0 | ||
|
|
3328cb2226 | ||
|
|
7b57b4bcda | ||
|
|
6d413f5721 | ||
|
|
c9fc88ddc7 | ||
|
|
6d284ae15c | ||
|
|
c6791a8d3c | ||
|
|
aa1370b9c7 | ||
|
|
db27c7e4bc | ||
|
|
325c6ddc86 | ||
|
|
9c7428571d | ||
|
|
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 |
+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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: "Welcome new contributors"
|
||||
|
||||
on:
|
||||
issues:
|
||||
on:
|
||||
issues:
|
||||
types: opened
|
||||
pull_request:
|
||||
types: opened
|
||||
@@ -13,16 +13,16 @@ permissions:
|
||||
jobs:
|
||||
welcome-message:
|
||||
name: Welcoming New Users
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: semantics
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
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 [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. ❤️🎉
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: semantics
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
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 [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. ❤️🎉
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,298 +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) => {
|
||||
const count =
|
||||
eventType?.recurringEvent?.count &&
|
||||
parseInt(event?.target.value) > eventType?.recurringEvent?.count
|
||||
? eventType.recurringEvent.count
|
||||
: parseInt(event?.target.value);
|
||||
|
||||
setRecurringEventCount(count);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema"
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -85,6 +87,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
return {
|
||||
...field,
|
||||
hidden: !enabled,
|
||||
editable: (!enabled ? "system-but-hidden" : "system-but-optional") as z.infer<typeof EditableSchema>
|
||||
};
|
||||
}
|
||||
return field;
|
||||
|
||||
@@ -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";
|
||||
@@ -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" : "";
|
||||
|
||||
@@ -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,11 +1,10 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
||||
|
||||
const ChooseLicense = (
|
||||
props: {
|
||||
value: string;
|
||||
@@ -50,23 +49,19 @@ const ChooseLicense = (
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
<RadioGroup.Item value="EE">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
|
||||
value === "EE" && "ring-2 ring-black"
|
||||
)}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("ee_enterprise_license")}</h2>
|
||||
<p className="font-medium text-green-800">
|
||||
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
||||
</p>
|
||||
<p className="text-subtle">{t("enterprise_license_includes")}</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RadioGroup.Item value="EE" disabled>
|
||||
<Link href="https://cal.com/sales" target="_blank">
|
||||
<div className={classNames("bg-default h-full cursor-pointer space-y-2 rounded-md border p-4")}>
|
||||
<h2 className="font-cal text-emphasis text-xl">{t("custom_plan")}</h2>
|
||||
<p className="font-medium text-green-800">{t("contact_sales")}</p>
|
||||
<p className="text-subtle">Build on top of Cal.com</p>
|
||||
<ul className="text-subtle ml-4 list-disc text-left text-xs">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Link>
|
||||
</RadioGroup.Item>
|
||||
</RadioGroup.Root>
|
||||
</form>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -46,6 +47,7 @@ const obtainNewUsernameChangeCondition = ({
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -94,6 +96,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
await update();
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -23,6 +24,7 @@ interface ICustomUsernameProps {
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -69,6 +71,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setOpenDialogSaveUsername(false);
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
await update();
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
@@ -156,23 +159,23 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
<DialogContent type="confirmation" Icon={Edit2} title={t("confirm_username_change_dialog_title")}>
|
||||
<div className="flex flex-row">
|
||||
<div className="mb-4 w-full pt-1">
|
||||
<div className="bg-subtle flex w-full flex-wrap rounded-sm py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<div className="bg-subtle flex w-full flex-wrap gap-6 rounded-sm px-2 py-3 text-sm">
|
||||
<div>
|
||||
<p className="text-subtle">{t("current_username")}</p>
|
||||
<p className="text-emphasis mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<div>
|
||||
<p className="text-subtle" data-testid="new-username">
|
||||
{t("new_username")}
|
||||
</p>
|
||||
<p className="text-emphasis">{inputUsernameValue}</p>
|
||||
<p className="text-emphasis mt-1">{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
<div className="ml-4 mt-4 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsernameMutation.isLoading}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,11 +5,6 @@ const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
const {
|
||||
userTypeRoutePath,
|
||||
teamTypeRoutePath,
|
||||
privateLinkRoutePath,
|
||||
embedUserTypeRoutePath,
|
||||
embedTeamTypeRoutePath,
|
||||
orgHostPath,
|
||||
orgUserRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
@@ -285,85 +280,12 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
|
||||
// Keep cookie based booker enabled just in case we disable new-booker globally
|
||||
...[
|
||||
{
|
||||
source: userTypeRoutePath,
|
||||
destination: "/new-booker/:user/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: teamTypeRoutePath,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: privateLinkRoutePath,
|
||||
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: embedUserTypeRoutePath,
|
||||
destination: "/new-booker/:user/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: embedTeamTypeRoutePath,
|
||||
destination: "/new-booker/team/:slug/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
],
|
||||
/* 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: embedUserTypeRoutePath,
|
||||
destination: "/new-booker/:user/:type/embed",
|
||||
},
|
||||
{
|
||||
source: embedTeamTypeRoutePath,
|
||||
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: userTypeRoutePath,
|
||||
destination: "/new-booker/:user/:type",
|
||||
},
|
||||
{
|
||||
source: teamTypeRoutePath,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
},
|
||||
{
|
||||
source: privateLinkRoutePath,
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
return {
|
||||
beforeFiles,
|
||||
afterFiles,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.0.14",
|
||||
"version": "3.0.16",
|
||||
"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",
|
||||
@@ -89,7 +89,7 @@
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^13.4.6",
|
||||
"next-auth": "^4.20.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^13.2.2",
|
||||
|
||||
+13
-9
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, JOIN_DISCORD, 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, Shield } from "@calcom/ui/components/icon";
|
||||
@@ -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
|
||||
@@ -249,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">
|
||||
@@ -267,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">
|
||||
@@ -317,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">
|
||||
@@ -337,7 +341,7 @@ 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,6 @@ import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||
|
||||
const email = z
|
||||
.string()
|
||||
.email()
|
||||
@@ -47,6 +45,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
name: true,
|
||||
identityProvider: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +56,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||
}
|
||||
|
||||
const t = await getTranslation(maybeUser.locale ?? "en", "common");
|
||||
|
||||
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
|
||||
where: {
|
||||
email: maybeUser.email,
|
||||
@@ -88,17 +89,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
resetLink,
|
||||
});
|
||||
|
||||
/** So we can test the password reset flow on CI */
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return res.status(201).json({
|
||||
message: "If this email exists in our system, you should receive a Reset email.",
|
||||
resetLink,
|
||||
});
|
||||
} else {
|
||||
return res
|
||||
.status(201)
|
||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||
}
|
||||
return res
|
||||
.status(201)
|
||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||
} catch (reason) {
|
||||
// console.error(reason);
|
||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||
|
||||
@@ -1,55 +1,66 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
password: z.string().refine(validPassword, () => ({
|
||||
message: "Password does not meet the requirements",
|
||||
})),
|
||||
requestId: z.string(), // format doesn't matter.
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(400).json({ message: "" });
|
||||
}
|
||||
// Bad Method when not POST
|
||||
if (req.method !== "POST") return res.status(405).end();
|
||||
|
||||
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(req.body);
|
||||
// rate-limited there is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options.
|
||||
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
expires: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await hashPassword(rawPassword);
|
||||
// this can fail if a password request has been made for an email that has since changed or-
|
||||
// never existed within Cal. In this case we do not want to disclose the email's existence.
|
||||
// instead, we just return 404
|
||||
try {
|
||||
const rawPassword = req.body?.password;
|
||||
const rawRequestId = req.body?.requestId;
|
||||
|
||||
if (!rawPassword || !rawRequestId) {
|
||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||
}
|
||||
|
||||
const maybeRequest = await prisma.resetPasswordRequest.findUnique({
|
||||
where: {
|
||||
id: rawRequestId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!maybeRequest) {
|
||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||
}
|
||||
|
||||
const maybeUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: maybeRequest.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!maybeUser) {
|
||||
return res.status(400).json({ message: "Couldn't find an account for this email" });
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(rawPassword);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: maybeUser.id,
|
||||
email: maybeRequest.email,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
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" });
|
||||
} catch (e) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
await expireResetPasswordRequest(rawRequestId);
|
||||
|
||||
return res.status(201).json({ message: "Password reset." });
|
||||
}
|
||||
|
||||
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,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/features/bookings/types";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
|
||||
const data: RecurringBookingCreateBody[] = req.body;
|
||||
const session = await getServerSession({ req, res });
|
||||
const createdBookings: BookingResponse[] = [];
|
||||
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
||||
let appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
|
||||
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
||||
// emailed booking taking into account accumulated results to send app status accurately
|
||||
for (let key = data.length - 1; key >= 0; key--) {
|
||||
const booking = data[key];
|
||||
if (key === 0) {
|
||||
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||
.reduce((prev, curr) => {
|
||||
if (prev[curr.type]) {
|
||||
prev[curr.type].failures += curr.failures;
|
||||
prev[curr.type].success += curr.success;
|
||||
} else {
|
||||
prev[curr.type] = curr;
|
||||
}
|
||||
return prev;
|
||||
}, {} as { [key: string]: AppsStatus });
|
||||
appsStatus = Object.values(calcAppsStatus);
|
||||
}
|
||||
|
||||
const recurringEventReq: NextApiRequest & { userId?: number } = req;
|
||||
|
||||
recurringEventReq.body = {
|
||||
...booking,
|
||||
appsStatus,
|
||||
allRecurringDates,
|
||||
currentRecurringIndex: key,
|
||||
noEmail: key !== 0,
|
||||
};
|
||||
|
||||
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
||||
isNotAnApiCall: true,
|
||||
});
|
||||
|
||||
createdBookings.push(eachRecurringBooking);
|
||||
}
|
||||
return createdBookings;
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/api/scheduleWhatsappReminders";
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
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 React 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";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
resetPasswordRequest: ResetPasswordRequest;
|
||||
requestId: string;
|
||||
isRequestExpired: boolean;
|
||||
csrfToken: string;
|
||||
};
|
||||
|
||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
export default function Page({ requestId, isRequestExpired, 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 (
|
||||
<>
|
||||
@@ -95,11 +75,6 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const isRequestExpired = useMemo(() => {
|
||||
const now = dayjs();
|
||||
return dayjs(resetPasswordRequest.expires).isBefore(now);
|
||||
}, [resetPasswordRequest]);
|
||||
|
||||
return (
|
||||
<AuthContainer
|
||||
showLogo
|
||||
@@ -109,8 +84,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 +95,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,
|
||||
});
|
||||
}}>
|
||||
<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 +128,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{!isRequestExpired && success && (
|
||||
@@ -179,31 +145,29 @@ Page.PageWrapper = PageWrapper;
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({
|
||||
where: {
|
||||
id,
|
||||
expires: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expires: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
resetPasswordRequest: {
|
||||
...resetPasswordRequest,
|
||||
expires: resetPasswordRequest.expires.toString(),
|
||||
},
|
||||
id,
|
||||
csrfToken: await getCsrfToken({ req: context.req }),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
},
|
||||
};
|
||||
} catch (reason) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
resetPasswordRequest &&
|
||||
(await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } }));
|
||||
} catch (e) {
|
||||
resetPasswordRequest = null;
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
isRequestExpired: !resetPasswordRequest,
|
||||
requestId: id,
|
||||
csrfToken: await getCsrfToken({ req: context.req }),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
@@ -31,7 +31,7 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
try {
|
||||
const res = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email, language: i18n.language }),
|
||||
body: JSON.stringify({ email }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Login({
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
// Don't leak information about whether an email is registered or not
|
||||
[ErrorCode.IncorrectUsernamePassword]: t("incorrect_username_password"),
|
||||
[ErrorCode.IncorrectEmailPassword]: t("incorrect_email_password"),
|
||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -53,9 +53,10 @@ type AvailabilityFormValues = {
|
||||
};
|
||||
|
||||
const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
const { remove, append, update, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
|
||||
const { remove, append, replace, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
|
||||
name: "dateOverrides",
|
||||
});
|
||||
const excludedDates = fields.map((field) => dayjs(field.ranges[0].start).utc().format("YYYY-MM-DD"));
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -70,15 +71,15 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
<p className="text-subtle mb-4 text-sm">{t("date_overrides_subtitle")}</p>
|
||||
<div className="space-y-2">
|
||||
<DateOverrideList
|
||||
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
||||
excludedDates={excludedDates}
|
||||
remove={remove}
|
||||
update={update}
|
||||
replace={replace}
|
||||
items={fields}
|
||||
workingHours={workingHours}
|
||||
/>
|
||||
<DateOverrideInputDialog
|
||||
workingHours={workingHours}
|
||||
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
||||
excludedDates={excludedDates}
|
||||
onChange={(ranges) => append({ ranges })}
|
||||
Trigger={
|
||||
<Button color="secondary" StartIcon={Plus} data-testid="add-override">
|
||||
|
||||
@@ -95,6 +95,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
||||
Icon={Clock}
|
||||
headline={t("new_schedule_heading")}
|
||||
description={t("new_schedule_description")}
|
||||
className="w-full"
|
||||
buttonRaw={<NewScheduleButton />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -503,7 +503,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
</Form>
|
||||
</EventTypeSingleLayout>
|
||||
|
||||
{slugExistsChildrenDialogOpen.length && (
|
||||
{slugExistsChildrenDialogOpen.length ? (
|
||||
<ManagedEventTypeDialog
|
||||
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
|
||||
isLoading={formMethods.formState.isSubmitting}
|
||||
@@ -518,7 +518,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
setSlugExistsChildrenDialogOpen([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
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}
|
||||
/>
|
||||
<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,104 +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";
|
||||
|
||||
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}
|
||||
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,
|
||||
teamId: team.id,
|
||||
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);
|
||||
@@ -4,10 +4,10 @@ import prisma from "@calcom/prisma";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { PageProps as UserTypePageProps } from "../../../new-booker/[user]/[type]";
|
||||
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../new-booker/[user]/[type]";
|
||||
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../new-booker/team/[slug]/[type]";
|
||||
import type { PageProps as TeamTypePageProps } from "../../../new-booker/team/[slug]/[type]";
|
||||
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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
@@ -83,7 +82,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
};
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<>
|
||||
<div
|
||||
className="bg-muted flex min-h-screen flex-col justify-center "
|
||||
style={
|
||||
@@ -166,7 +165,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -65,7 +64,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
}
|
||||
|
||||
// slug is a route parameter, we don't want to forward it to the next route
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
|
||||
const { slug: _slug, ...queryParamsToForward } = router.query;
|
||||
|
||||
const EventTypes = () => (
|
||||
<ul className="border-subtle rounded-md border">
|
||||
@@ -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 || "",
|
||||
}))}
|
||||
/>
|
||||
@@ -187,7 +186,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
{!isBioEmpty && (
|
||||
<>
|
||||
<div
|
||||
className="text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: team.safeBio }}
|
||||
/>
|
||||
</>
|
||||
@@ -197,26 +196,21 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<SubTeams />
|
||||
) : (
|
||||
<>
|
||||
{(showMembers.isOn || !team.eventTypes.length) &&
|
||||
(team.isPrivate ? (
|
||||
<div className="w-full text-center">
|
||||
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
|
||||
</div>
|
||||
) : (
|
||||
<Team team={team} />
|
||||
))}
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl ">
|
||||
<EventTypes />
|
||||
|
||||
{!(team.hideBookATeamMember || team.isPrivate) && (
|
||||
{!team.hideBookATeamMember && (
|
||||
<div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-subtle w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-subtle text-subtle px-2 text-sm">{t("or")}</span>
|
||||
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,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),
|
||||
}));
|
||||
@@ -302,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;
|
||||
|
||||
@@ -22,14 +22,6 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
// [^/]+ 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
|
||||
|
||||
const afterFilesRewriteExcludePages = pages;
|
||||
exports.userTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join(
|
||||
"/|"
|
||||
)})[^/]*)/:type((?!book$)[^/]+)`;
|
||||
exports.teamTypeRoutePath = "/team/:slug/:type((?!book$)[^/]+)";
|
||||
exports.privateLinkRoutePath = "/d/:link/:slug((?!book$)[^/]+)";
|
||||
exports.embedUserTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join("/|")})[^/]*)/:type/embed`;
|
||||
exports.embedTeamTypeRoutePath = "/team/:slug/:type/embed";
|
||||
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
|
||||
));
|
||||
|
||||
@@ -1,49 +1,93 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
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`;
|
||||
|
||||
// Got to reset password flow
|
||||
await page.goto("/auth/forgot-password");
|
||||
|
||||
await page.waitForSelector("text=Forgot Password");
|
||||
// Fill [placeholder="john.doe@example.com"]
|
||||
await page.fill('input[name="email"]', `${user.username}@example.com`);
|
||||
await page.press('input[name="email"]', "Enter");
|
||||
|
||||
// Press Enter
|
||||
await Promise.all([
|
||||
page.waitForURL((u) => u.pathname.startsWith("/auth/forgot-password")),
|
||||
page.press('input[name="email"]', "Enter"),
|
||||
]);
|
||||
// wait for confirm page.
|
||||
await page.waitForSelector("text=Reset link sent");
|
||||
|
||||
// As a workaround, we query the db for the last created password request
|
||||
// there should be one, otherwise we throw
|
||||
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
// Test when a user changes his email after starting the password reset flow
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}-2@example.com`,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
await page.waitForSelector("text=That request is expired.");
|
||||
|
||||
// Change the email back to continue testing.
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: `${user.username}-2@example.com`,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}@example.com`,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
const newPassword = `${user.username}-123CAL-${uuid().toString()}`; // To match the password policy
|
||||
|
||||
// Wait for page to fully load
|
||||
await page.waitForSelector("text=Reset Password");
|
||||
// Fill input[name="password"]
|
||||
await page.fill('input[name="password"]', newPassword);
|
||||
|
||||
// Click text=Submit
|
||||
await page.fill('input[name="new_password"]', newPassword);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await page.waitForSelector("text=Password updated");
|
||||
|
||||
await expect(page.locator(`text=Password updated`)).toBeVisible();
|
||||
// Click button:has-text("Login")
|
||||
await Promise.all([
|
||||
page.waitForURL((u) => u.pathname.startsWith("/auth/login")),
|
||||
page.click('a:has-text("Login")'),
|
||||
]);
|
||||
// now we check our DB to confirm the password was indeed updated.
|
||||
// we're not logging in to the UI to speed up test performance.
|
||||
const updatedUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fill input[name="email"]
|
||||
await page.fill('input[name="email"]', `${user.username}@example.com`);
|
||||
await page.fill('input[name="password"]', newPassword);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
await page.waitForSelector("[data-testid=dashboard-shell]");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
|
||||
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
|
||||
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
await page.waitForSelector("text=That request is expired.");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
setupEventWithPrice(eventType, store.page),
|
||||
bookAndPaidEvent: async (eventType: Pick<Prisma.EventType, "slug">) =>
|
||||
bookAndPaidEvent(user, eventType, store.page),
|
||||
makePaymentUsingStripe: async () => makePaymentUsingStripe(store.page),
|
||||
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
|
||||
debug: async (message: string | Record<string, JSONValue>) => {
|
||||
await prisma.user.update({
|
||||
@@ -398,7 +399,12 @@ export async function bookAndPaidEvent(
|
||||
|
||||
await Promise.all([page.waitForURL("/payment/*"), page.press('[name="email"]', "Enter")]);
|
||||
|
||||
const stripeFrame = page.frameLocator("iframe").first();
|
||||
await makePaymentUsingStripe(page);
|
||||
}
|
||||
|
||||
export async function makePaymentUsingStripe(page: Page) {
|
||||
const stripeElement = await page.locator(".StripeElement").first();
|
||||
const stripeFrame = stripeElement.frameLocator("iframe").first();
|
||||
await stripeFrame.locator('[name="number"]').fill("4242 4242 4242 4242");
|
||||
const now = new Date();
|
||||
await stripeFrame.locator('[name="expiry"]').fill(`${now.getMonth()} / ${now.getFullYear() + 1}`);
|
||||
|
||||
@@ -69,9 +69,31 @@ test.describe("Stripe integration", () => {
|
||||
await expect(page.getByText("Unconfirmed")).toBeVisible();
|
||||
await expect(page.getByText("Pending payment").last()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Paid booking should be able to be rescheduled", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
await user.apiLogin();
|
||||
await page.goto("/apps/installed");
|
||||
|
||||
await user.getPaymentCredential();
|
||||
await user.setupEventWithPrice(eventType);
|
||||
await user.bookAndPaidEvent(eventType);
|
||||
|
||||
// Rescheduling the event
|
||||
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL("/payment/*"),
|
||||
page.click('[data-testid="confirm-reschedule-button"]'),
|
||||
]);
|
||||
|
||||
await user.makePaymentUsingStripe();
|
||||
});
|
||||
todo("Payment should confirm pending payment booking");
|
||||
todo("Payment should trigger a BOOKING_PAID webhook");
|
||||
todo("Paid booking should be able to be rescheduled");
|
||||
todo("Paid booking should be able to be cancelled");
|
||||
todo("Cancelled paid booking should be refunded");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -58,7 +58,7 @@ test.describe("Login and logout tests", () => {
|
||||
|
||||
test.describe("Login flow validations", async () => {
|
||||
test("Should warn when user does not exist", async ({ page }) => {
|
||||
const alertMessage = (await localize("en"))("incorrect_username_password");
|
||||
const alertMessage = (await localize("en"))("incorrect_email_password");
|
||||
|
||||
// Login with a non-existent user
|
||||
const never = "never";
|
||||
@@ -69,7 +69,7 @@ test.describe("Login and logout tests", () => {
|
||||
});
|
||||
|
||||
test("Should warn when password is incorrect", async ({ page, users }) => {
|
||||
const alertMessage = (await localize("en"))("incorrect_username_password");
|
||||
const alertMessage = (await localize("en"))("incorrect_email_password");
|
||||
// by default password===username with the users fixture
|
||||
const pro = await users.create({ username: "pro" });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "يجب أن يكون عدد الأحرف {{limit}} أو أقل",
|
||||
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
|
||||
"incorrect_password": "كلمة المرور غير صحيحة.",
|
||||
"incorrect_username_password": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||
"use_setting": "استخدام الإعداد",
|
||||
"am_pm": "صباحًا/مساءً",
|
||||
"time_options": "خيارات الوقت",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "إنشاء فريق جديد",
|
||||
"open_invitations": "الدعوات المفتوحة",
|
||||
"new_team": "فريق جديد",
|
||||
"create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.",
|
||||
"create_team_to_get_started": "قم بإنشاء فريق للبدء",
|
||||
"teams": "الفرق",
|
||||
"team": "فريق",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "هل لديك مشكلة معينة؟",
|
||||
"browse_our_docs": "تصفح مستنداتنا",
|
||||
"choose_template": "اختيار قالب",
|
||||
"reminder": "تذكير",
|
||||
"custom": "مخصص",
|
||||
"reminder": "تذكير",
|
||||
"reminder_email": "تذكر {{eventType}} مع {{name}} في {{date}}",
|
||||
"not_triggering_existing_bookings": "لن يتم تشغيل الحجوزات الموجودة مسبقاً حيث سيتم طلب رقم الهاتف عند حجز الحدث.",
|
||||
"minute_one": "{{count}} دقيقة",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "يأتي Cal.com مع ترخيص AGPLv3 مجاني يمكن الوصول إليه ويمكن ترقيته إلى ترخيص Enterprise في أي وقت. يمكنك الترقية في أي وقت لاحق.",
|
||||
"license": "الترخيص",
|
||||
"agplv3_license": "ترخيص AGPLv3",
|
||||
"ee_enterprise_license": "رخصة المؤسسة \"/ee\"",
|
||||
"enterprise_booking_fee": "بدءًا من {{enterprise_booking_fee}}/ الشهر",
|
||||
"enterprise_license_includes": "كل شيء لأغراض الاستخدام التجاري",
|
||||
"no_need_to_keep_your_code_open_source": "لا حاجة للحفاظ على تعليماتك البرمجية مفتوحة المصدر",
|
||||
"repackage_rebrand_resell": "يمكنك إعادة التعبئة وإعادة الترويج وإعادة البيع بسهولة",
|
||||
"a_vast_suite_of_enterprise_features": "مجموعة كبيرة من ميزات المؤسسة",
|
||||
@@ -1679,7 +1674,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": "هل تبحث عن المزيد من التحليلات؟",
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
"continue_without_calendar": "Pokračovat bez kalendáře",
|
||||
"connect_your_calendar": "Připojit svůj kalendář",
|
||||
"connect_your_video_app": "Propojte své video aplikace",
|
||||
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je v typech událostí.",
|
||||
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je ve svých typech událostí.",
|
||||
"connect_your_calendar_instructions": "Připojit kalendář pro automatickou kontrolu časů, kdy nejste dostupní, a nově naplánovaných událostí.",
|
||||
"set_up_later": "Zprovoznit později",
|
||||
"current_time": "Aktuální čas",
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Maximální povolená délka je {{limit}} znaků",
|
||||
"invalid_password_hint": "Heslo musí být minimálně {{passwordLength}} znaků dlouhé, obsahovat alespoň jedno číslo a obsahovat kombinaci velkých a malých písmen",
|
||||
"incorrect_password": "Heslo není správné.",
|
||||
"incorrect_username_password": "Uživatelské jméno nebo heslo je nesprávné.",
|
||||
"use_setting": "Použít nastavení",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Nastavení času",
|
||||
@@ -639,7 +638,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 +1071,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",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com se dodává s dostupnou a bezplatnou licencí AGPLv3, která má určitá omezení. Kdykoli je ale možné provést upgrade na licenci Enterprise. Upgrade je možné provést kdykoli v pozdější době.",
|
||||
"license": "Licence",
|
||||
"agplv3_license": "Licence AGPLv3",
|
||||
"ee_enterprise_license": "„/ee“ Licence Enterprise",
|
||||
"enterprise_booking_fee": "Od {{{enterprise_booking_fee}}/měsíc",
|
||||
"enterprise_license_includes": "Vše pro komerční použití",
|
||||
"no_need_to_keep_your_code_open_source": "Není nutné, aby váš kód byl otevřený",
|
||||
"repackage_rebrand_resell": "Snadné přebalení, rebranding a přeprodej",
|
||||
"a_vast_suite_of_enterprise_features": "Rozsáhlá sada firemních funkcí",
|
||||
@@ -1679,7 +1674,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?",
|
||||
|
||||
@@ -414,7 +414,6 @@
|
||||
"password_hint_num": "Indeholde mindst 1 tal",
|
||||
"invalid_password_hint": "Adgangskoden skal være mindst {{passwordLength}} tegn langt, der indeholder mindst ét tal og har en blanding af store og små bogstaver",
|
||||
"incorrect_password": "Adgangskoden er forkert.",
|
||||
"incorrect_username_password": "Brugernavn eller adgangskode er forkert.",
|
||||
"use_setting": "Brug indstilling",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Tidsmuligheder",
|
||||
@@ -608,7 +607,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",
|
||||
@@ -1030,8 +1028,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",
|
||||
@@ -1455,9 +1453,6 @@
|
||||
"choose_license_description": "Cal.com kommer med en tilgængelig og gratis AGPLv3-licens med nogle begrænsninger, som til enhver tid kan opgraderes til en Enterprise-licens. Du kan opgradere når som helst senere.",
|
||||
"license": "Licens",
|
||||
"agplv3_license": "AGPLv3 Licens",
|
||||
"ee_enterprise_license": "“/ee” Virksomhedslicens",
|
||||
"enterprise_booking_fee": "Fra {{enterprise_booking_fee}}/måned",
|
||||
"enterprise_license_includes": "Alt til en kommerciel brugssag",
|
||||
"repackage_rebrand_resell": "Ompak, rebrand og videresælg nemt",
|
||||
"a_vast_suite_of_enterprise_features": "En bred vifte af virksomhedsfunktioner",
|
||||
"free_license_fee": "0,00kr./måned",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Muss {{limit}} oder weniger Zeichen lang sein",
|
||||
"invalid_password_hint": "Das Passwort muss mindestens {{passwordLength}} Zeichen lang sein und mindestens eine Nummer beinhalten, sowie eine Mischung aus Groß- und Kleinbuchstaben sein",
|
||||
"incorrect_password": "Passwort ist falsch",
|
||||
"incorrect_username_password": "Benutzername oder Passwort ist falsch.",
|
||||
"use_setting": "Benutze Einstellung",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Zeitoptionen",
|
||||
@@ -639,7 +638,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 +1071,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",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com enthält eine kostenlose AGPLv3-Lizenz, mit einigen Einschränkungen, die jederzeit auf eine Enterprise-Lizenz aufgerüstet werden können. Sie können später jederzeit upgraden.",
|
||||
"license": "Lizenz",
|
||||
"agplv3_license": "AGPLv3 Lizenz",
|
||||
"ee_enterprise_license": "„/ee“ Enterprise-Lizenz",
|
||||
"enterprise_booking_fee": "Ab {{enterprise_booking_fee}}/Monat",
|
||||
"enterprise_license_includes": "Alles für einen kommerziellen Anwendungsfall",
|
||||
"no_need_to_keep_your_code_open_source": "Ihr Code muss nicht open-source bleiben",
|
||||
"repackage_rebrand_resell": "Einfaches Neuverpacken, Rebranding und Weiterverkaufen",
|
||||
"a_vast_suite_of_enterprise_features": "Eine große Auswahl an Enterprise-Funktionen",
|
||||
@@ -1676,10 +1671,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 +1727,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 +1743,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",
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
|
||||
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
|
||||
"incorrect_password": "Password is incorrect.",
|
||||
"incorrect_username_password": "Username or password is incorrect.",
|
||||
"incorrect_email_password": "Email or password is incorrect.",
|
||||
"use_setting": "Use setting",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Time options",
|
||||
@@ -695,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",
|
||||
@@ -1086,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",
|
||||
@@ -1147,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",
|
||||
@@ -1490,8 +1494,9 @@
|
||||
"must_enter_team_name": "Must enter a team name",
|
||||
"team_url_required": "Must enter a team URL",
|
||||
"url_taken": "This URL is already taken",
|
||||
"problem_registering_domain": "There was a problem with registering the subdomain, please try again or contact an administrator",
|
||||
"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...",
|
||||
@@ -1619,12 +1624,9 @@
|
||||
"teams_plan_required": "Teams plan required",
|
||||
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
|
||||
"choose_a_license": "Choose a license",
|
||||
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which some limitations which can be upgraded to an Enterprise license at any time. You can upgrade at anytime later.",
|
||||
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which has limitations. We are onboarding Enterprise customers for the commercial license which you can inquire about by contacting sales below.",
|
||||
"license": "License",
|
||||
"agplv3_license": "AGPLv3 License",
|
||||
"ee_enterprise_license": "“/ee” Enterprise License",
|
||||
"enterprise_booking_fee": "Starting at {{enterprise_booking_fee}}/month",
|
||||
"enterprise_license_includes": "Everything for a commercial use case",
|
||||
"no_need_to_keep_your_code_open_source": "No need to keep your code open source",
|
||||
"repackage_rebrand_resell": "Repackage, rebrand and resell easily",
|
||||
"a_vast_suite_of_enterprise_features": "A vast suite of enterprise features",
|
||||
@@ -1785,7 +1787,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?",
|
||||
@@ -1913,8 +1915,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.",
|
||||
@@ -1923,6 +1925,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.",
|
||||
@@ -1937,5 +1940,6 @@
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "User: {{userName}}",
|
||||
"insights_subtitle": "View booking insights across your events",
|
||||
"custom_plan": "Custom Plan",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Debe tener {{limit}} o menos caracteres",
|
||||
"invalid_password_hint": "La contraseña debe tener un mínimo de {{passwordLength}} caracteres que contengan al menos un número y una mezcla de letras mayúsculas y minúsculas",
|
||||
"incorrect_password": "La Contraseña es Incorrecta.",
|
||||
"incorrect_username_password": "El nombre de usuario o la contraseña son incorrectos.",
|
||||
"use_setting": "Usar Ajuste",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Opciones Horas",
|
||||
@@ -639,7 +638,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 +1071,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",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com viene con una licencia AGPLv3 accesible y gratuita, la cual tiene algunas limitaciones. Esta licencia se puede actualizar a una licencia Empresarial en cualquier momento. Puede actualizarla más tarde.",
|
||||
"license": "Licencia",
|
||||
"agplv3_license": "Licencia AGPLv3",
|
||||
"ee_enterprise_license": "Licencia Empresarial \"/ee\"",
|
||||
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mes",
|
||||
"enterprise_license_includes": "Todo para un caso de uso comercial",
|
||||
"no_need_to_keep_your_code_open_source": "No es necesario mantener su código abierto",
|
||||
"repackage_rebrand_resell": "Vuelva a empaquetar, cambie la marca y revenda fácilmente",
|
||||
"a_vast_suite_of_enterprise_features": "Un amplio conjunto de funciones empresariales",
|
||||
@@ -1679,7 +1674,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?",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"done": "Terminé",
|
||||
"all_done": "Terminé !",
|
||||
"all": "Tous",
|
||||
"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",
|
||||
@@ -476,7 +477,6 @@
|
||||
"max_limit_allowed_hint": "Doit contenir {{limit}} caractères ou moins",
|
||||
"invalid_password_hint": "Le mot de passe doit contenir au moins {{passwordLength}} caractères, un chiffre et un mélange de lettres majuscules et minuscules.",
|
||||
"incorrect_password": "Le mot de passe est incorrect.",
|
||||
"incorrect_username_password": "Le nom d'utilisateur ou le mot de passe est incorrect.",
|
||||
"use_setting": "Utiliser le paramètre",
|
||||
"am_pm": "AM/PM",
|
||||
"time_options": "Options de temps",
|
||||
@@ -694,7 +694,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",
|
||||
@@ -864,6 +864,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.",
|
||||
@@ -1083,6 +1084,8 @@
|
||||
"email_attendee_action": "envoyer un e-mail aux participants",
|
||||
"sms_attendee_action": "Envoyer un SMS au participant",
|
||||
"sms_number_action": "envoyer un SMS à un numéro spécifique",
|
||||
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
|
||||
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
|
||||
"workflows": "Workflows",
|
||||
"new_workflow_btn": "Nouveau workflow",
|
||||
"add_new_workflow": "Ajouter un nouveau workflow",
|
||||
@@ -1144,8 +1147,10 @@
|
||||
"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",
|
||||
"rescheduled": "Replanifié",
|
||||
"completed": "Terminé",
|
||||
"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",
|
||||
@@ -1458,6 +1463,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",
|
||||
@@ -1486,8 +1492,9 @@
|
||||
"must_enter_team_name": "Vous devez saisir un nom d'équipe",
|
||||
"team_url_required": "Vous devez saisir un lien d'équipe",
|
||||
"url_taken": "Ce lien est déjà pris",
|
||||
"problem_registering_domain": "Un problème est survenu lors de l'enregistrement du sous-domaine, veuillez réessayer ou contacter un administrateur",
|
||||
"team_publish": "Publier l'équipe",
|
||||
"number_sms_notifications": "Numéro de téléphone (notifications par SMS)",
|
||||
"number_text_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...",
|
||||
@@ -1614,12 +1621,9 @@
|
||||
"teams_plan_required": "Le plan Équipes est requis",
|
||||
"routing_forms_are_a_great_way": "Les formulaires de routage sont un excellent moyen d'acheminer vos prospects entrants vers la bonne personne. Passez au plan Équipes pour accéder à cette fonctionnalité.",
|
||||
"choose_a_license": "Choisir une licence",
|
||||
"choose_license_description": "Cal.com est fourni avec licence AGPLv3 accessible et gratuite (avec certaines limitations) qui peut être mis à niveau vers une licence Entreprise à tout moment. Vous pouvez mettre à niveau à tout moment plus tard.",
|
||||
"choose_license_description": "Cal.com est fourni avec une licence AGPLv3 accessible et gratuite mais comportant des limitations. Nous sommes en train d'intégrer les clients du plan Entreprise pour la licence commerciale. Vous pouvez vous renseigner à ce sujet en contactant le service commercial ci-dessous.",
|
||||
"license": "Licence",
|
||||
"agplv3_license": "Licence AGPLv3",
|
||||
"ee_enterprise_license": "Licence Entreprise « /ee »",
|
||||
"enterprise_booking_fee": "À partir de {{enterprise_booking_fee}}/mois",
|
||||
"enterprise_license_includes": "Tout pour un usage commercial",
|
||||
"no_need_to_keep_your_code_open_source": "Pas besoin de garder votre code open source",
|
||||
"repackage_rebrand_resell": "Restructurez, changez de marque et revendez facilement",
|
||||
"a_vast_suite_of_enterprise_features": "Une vaste suite de fonctionnalités d'entreprise",
|
||||
@@ -1780,7 +1784,7 @@
|
||||
"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_information_other": "<strong>{{names}}</strong> utilisent déjà l'URL <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 ?",
|
||||
@@ -1908,8 +1912,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.",
|
||||
@@ -1918,6 +1922,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.",
|
||||
@@ -1932,5 +1937,6 @@
|
||||
"insights_team_filter": "Équipe : {{teamName}}",
|
||||
"insights_user_filter": "Utilisateur : {{userName}}",
|
||||
"insights_subtitle": "Visualisez les statistiques de réservation à travers vos événements",
|
||||
"custom_plan": "Plan personnalisé",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -440,7 +440,6 @@
|
||||
"max_limit_allowed_hint": "חייב להיות באורך של עד {{limit}} תווים",
|
||||
"invalid_password_hint": "הסיסמה חייבת להיות באורך של {{passwordLength}} תווים לפחות ולכלול לפחות ספרה אחת ושילוב של אותיות רישיות וקטנות",
|
||||
"incorrect_password": "הסיסמה שגויה.",
|
||||
"incorrect_username_password": "שם משתמש או סיסמה שגויים.",
|
||||
"use_setting": "השתמש בהגדרה",
|
||||
"am_pm": "לפנה\"צ/אחה\"צ",
|
||||
"time_options": "אפשרויות זמן",
|
||||
@@ -463,7 +462,7 @@
|
||||
"friday": "יום שישי",
|
||||
"saturday": "יום שבת",
|
||||
"sunday": "יום ראשון",
|
||||
"all_booked_today": "כל מה שהוזמן.",
|
||||
"all_booked_today": "הכל הוזמן.",
|
||||
"slots_load_fail": "לא ניתן לטעון את חלונות הזמן הזמינים.",
|
||||
"additional_guests": "הוספת אורחים",
|
||||
"your_name": "שמך",
|
||||
@@ -651,7 +650,6 @@
|
||||
"create_new_team": "יצירת צוות חדש",
|
||||
"open_invitations": "הזמנות פתוחות",
|
||||
"new_team": "צוות חדש",
|
||||
"create_first_team_and_invite_others": "צור את הצוות הראשון שלך והזמן משתמשים אחרים לעבוד יחד איתך.",
|
||||
"create_team_to_get_started": "צור צוות כדי להתחיל",
|
||||
"teams": "צוותים",
|
||||
"team": "צוות",
|
||||
@@ -1085,8 +1083,8 @@
|
||||
"specific_issue": "נתקלת בבעיה ספציפית?",
|
||||
"browse_our_docs": "לדפדף במסמכים שלנו",
|
||||
"choose_template": "בחירת תבנית",
|
||||
"reminder": "תזכורת",
|
||||
"custom": "התאמה אישית",
|
||||
"reminder": "תזכורת",
|
||||
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
|
||||
"minute_one": "{{count}} דקה",
|
||||
@@ -1540,9 +1538,6 @@
|
||||
"choose_license_description": "Cal.com מגיע עם רישיון AGPLv3 נגיש וחינמי עם כמה מגבלות שניתן לשדרג לתוכנית Enterprise בכל עת לקבלת גישה אליהן. אתה יכול לשדרג בכל מועד עתידי.",
|
||||
"license": "רישיון",
|
||||
"agplv3_license": "רישיון AGPLv3",
|
||||
"ee_enterprise_license": "\"ee/\" רישיון Enterprise",
|
||||
"enterprise_booking_fee": "החל מ- {{enterprise_booking_fee}}/לחודש",
|
||||
"enterprise_license_includes": "הכל לשימוש מסחרי",
|
||||
"no_need_to_keep_your_code_open_source": "אין צורך להשאיר את הקוד שלך כקוד פתוח",
|
||||
"repackage_rebrand_resell": "אריזה מחדש, מיתוג מחדש ומכירה בקלות",
|
||||
"a_vast_suite_of_enterprise_features": "מגוון יכולות Enterprise עצום",
|
||||
@@ -1691,7 +1686,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 +1739,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": "סוג האירוע נטען ישירות בתוך שאר תוכן האתר שלך.",
|
||||
|
||||
@@ -452,7 +452,6 @@
|
||||
"max_limit_allowed_hint": "Deve avere una lunghezza massima di {{limit}} caratteri",
|
||||
"invalid_password_hint": "La password deve avere una lunghezza minima di {{passwordLength}} caratteri e deve contenere almeno 1 numero e una combinazione di caratteri maiuscoli e minuscoli",
|
||||
"incorrect_password": "La password non è corretta.",
|
||||
"incorrect_username_password": "Nome utente o password errati.",
|
||||
"use_setting": "Usa impostazione",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Opzioni tempo",
|
||||
@@ -475,7 +474,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",
|
||||
@@ -663,7 +662,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 +1097,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",
|
||||
@@ -1560,9 +1558,6 @@
|
||||
"choose_license_description": "Cal.com viene fornito con una licenza AGPLv3 accessibile e gratuita che ha alcune limitazioni e può essere convertita in una licenza Enterprise in qualsiasi momento. È possibile effettuare l'upgrade in un secondo tempo.",
|
||||
"license": "Licenza",
|
||||
"agplv3_license": "Licenza AGPLv3",
|
||||
"ee_enterprise_license": "Licenza Enterprise “/ee”",
|
||||
"enterprise_booking_fee": "A partire da {{enterprise_booking_fee}}/mese",
|
||||
"enterprise_license_includes": "Tutto il necessario per l'utilizzo commerciale",
|
||||
"no_need_to_keep_your_code_open_source": "Non è necessario che il codice rimanga open source",
|
||||
"repackage_rebrand_resell": "Rivendi facilmente con un nuovo marchio",
|
||||
"a_vast_suite_of_enterprise_features": "Un'estesa suite di funzionalità enterprise",
|
||||
@@ -1714,7 +1709,6 @@
|
||||
"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. 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?",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "{{limit}} 文字以下で入力してください",
|
||||
"invalid_password_hint": "パスワードには少なくとも 1 文字以上数字を含め、アルファベットの大文字と小文字を両方使用した上で {{passwordLength}} 文字以上の長さに設定してください",
|
||||
"incorrect_password": "パスワードが正しくありません。",
|
||||
"incorrect_username_password": "ユーザー名またはパスワードが正しくありません。",
|
||||
"use_setting": "設定を使用",
|
||||
"am_pm": "午前/午後",
|
||||
"time_options": "時刻オプション",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "新しいチームを作成",
|
||||
"open_invitations": "招待状を開く",
|
||||
"new_team": "新しいチーム",
|
||||
"create_first_team_and_invite_others": "あなたの最初のチームを作成し、あなたと一緒に仕事をするために他のユーザーを招待してください。",
|
||||
"create_team_to_get_started": "始めるためにチームを作成",
|
||||
"teams": "チーム",
|
||||
"team": "チーム",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "特定の問題がおありですか?",
|
||||
"browse_our_docs": "ドキュメントを閲覧",
|
||||
"choose_template": "テンプレートを選択する",
|
||||
"reminder": "リマインダー",
|
||||
"custom": "カスタム",
|
||||
"reminder": "リマインダー",
|
||||
"reminder_email": "リマインダー:{{date}} の {{name}} との {{eventType}}",
|
||||
"not_triggering_existing_bookings": "イベントの予約時に電話番号の入力を求められるため、すでにある予約はトリガーされません。",
|
||||
"minute_one": "{{count}} 分",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com には無料で利用可能な AGPLv3 ライセンスが付属していますが、これにはいくつかの制限があり、いつでも企業向けライセンスへとアップグレードすることができます。アップグレードは今後のどのタイミングでも可能です。",
|
||||
"license": "ライセンス",
|
||||
"agplv3_license": "AGPLv3 ライセンス",
|
||||
"ee_enterprise_license": "\"/ee\" Enterprise ライセンス",
|
||||
"enterprise_booking_fee": "{{enterprise_booking_fee}}/月で開始する",
|
||||
"enterprise_license_includes": "商用ユースケースで必要となるすべてのもの",
|
||||
"no_need_to_keep_your_code_open_source": "コードをオープンソースのままにしておく必要はありません",
|
||||
"repackage_rebrand_resell": "再パッケージ、リブランディング、再販売が簡単になります",
|
||||
"a_vast_suite_of_enterprise_features": "膨大な量の企業向け機能パッケージ",
|
||||
@@ -1679,7 +1674,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 +1729,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": "モーダルウィンドウをご利用のイベントの種類でトリガーするフローティングボタンをサイトに設置します。",
|
||||
|
||||
@@ -429,7 +429,6 @@
|
||||
"max_limit_allowed_hint": "{{limit}}자 이하여야 합니다",
|
||||
"invalid_password_hint": "비밀번호는 7자 이상이어야 하고 숫자가 하나 이상 포함되어야 하며 대문자와 소문자가 혼합되어야 합니다",
|
||||
"incorrect_password": "비밀번호가 잘못되었습니다",
|
||||
"incorrect_username_password": "사용자 이름 또는 비밀번호가 잘못되었습니다.",
|
||||
"use_setting": "사용 설정",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "시간 옵션",
|
||||
@@ -640,7 +639,6 @@
|
||||
"create_new_team": "새 팀 만들기",
|
||||
"open_invitations": "초대 열기",
|
||||
"new_team": "새 팀",
|
||||
"create_first_team_and_invite_others": "첫 번째 팀을 만들고 함께 작업할 다른 사용자를 초대합니다.",
|
||||
"create_team_to_get_started": "시작하려면 팀을 만드세요.",
|
||||
"teams": "팀",
|
||||
"team": "팀",
|
||||
@@ -1074,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}}분",
|
||||
@@ -1529,9 +1527,6 @@
|
||||
"choose_license_description": "Cal.com은 액세스 가능한 무료 AGPLv3 라이선스와 함께 제공되며 일부 제한된 기능은 Enterprise 라이선스로 업그레이드하여 언제든지 이용할 수 있습니다. 업그레이드는 나중에 언제든지 가능합니다.",
|
||||
"license": "라이선스",
|
||||
"agplv3_license": "AGPLv3 라이선스",
|
||||
"ee_enterprise_license": "“/ee” Enterprise 라이선스",
|
||||
"enterprise_booking_fee": "시작 가격 {{enterprise_booking_fee}}/월",
|
||||
"enterprise_license_includes": "모든 상업적 기능 구비",
|
||||
"no_need_to_keep_your_code_open_source": "코드를 오픈 소스로 유지할 필요가 없습니다",
|
||||
"repackage_rebrand_resell": "쉽게 패키지를 재구성하여 새 브랜드로 재판매 가능",
|
||||
"a_vast_suite_of_enterprise_features": "방대한 엔터프라이즈 기능 모음",
|
||||
@@ -1680,7 +1675,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> 님이 이미 <strong>/{{slug}}</strong> url을 사용하는 중입니다.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> 님이 이미 <string>/{{slug}}</strong> url을 사용하는 중입니다.",
|
||||
"managed_event_dialog_clarification": "URL 바꾸기를 선택하면 회원에게 알려드립니다. 덮어쓰지 않으려면 돌아가서 제거하십시오.",
|
||||
"review_event_type": "이벤트 타입 검토",
|
||||
"looking_for_more_analytics": "더 많은 분석 정보를 원합니까?",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Moet {{limit}}} of minder tekens lang zijn",
|
||||
"invalid_password_hint": "Het wachtwoord moet minimaal {{passwordLength}} tekens lang zijn, minimaal 1 cijfer bevatten en bestaan uit een mix van hoofd- en kleine letters",
|
||||
"incorrect_password": "Uw wachtwoord is incorrect.",
|
||||
"incorrect_username_password": "Gebruikersnaam of wachtwoord is onjuist.",
|
||||
"use_setting": "Instelling gebruiken",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Tijd, datum en tijdzone instellen",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Nieuw team aanmaken",
|
||||
"open_invitations": "Openstaande uitnodigingen",
|
||||
"new_team": "Nieuw Team",
|
||||
"create_first_team_and_invite_others": "Maak uw eerste team en nodig andere gebruikers uit om in groepsverband af te spreken.",
|
||||
"create_team_to_get_started": "Maak een team aan om aan de slag te gaan",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Heeft u een specifiek probleem?",
|
||||
"browse_our_docs": "door onze documenten bladen",
|
||||
"choose_template": "Kies een sjabloon",
|
||||
"reminder": "Herinnering",
|
||||
"custom": "Aangepast",
|
||||
"reminder": "Herinnering",
|
||||
"reminder_email": "Herinnering: {{eventType}} met {{name}} op {{date}}",
|
||||
"not_triggering_existing_bookings": "Wordt niet geactiveerd voor bestaande boekingen, omdat de gebruiker om zijn/haar telefoonnummer gevraagd wordt bij het boeken van de gebeurtenis.",
|
||||
"minute_one": "{{count}} minuut",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com wordt geleverd met een toegankelijke en gratis AGPLv3-licentie met enkele beperkingen die op elk moment kan worden geüpgraded naar een bedrijfslicentie. U kunt later altijd upgraden.",
|
||||
"license": "Licentie",
|
||||
"agplv3_license": "AGPLv3-licentie",
|
||||
"ee_enterprise_license": "“/ee” Enterprise-licentie",
|
||||
"enterprise_booking_fee": "Vanaf {{enterprise_booking_fee}}/maand",
|
||||
"enterprise_license_includes": "Alles voor een commerciële toepassing",
|
||||
"no_need_to_keep_your_code_open_source": "Uw code hoeft niet open source te zijn",
|
||||
"repackage_rebrand_resell": "Gemakkelijk opnieuw verpakken, rebranden en doorverkopen",
|
||||
"a_vast_suite_of_enterprise_features": "Een uitgebreid pakket bedrijfsfuncties",
|
||||
@@ -1679,7 +1674,6 @@
|
||||
"managed_event_dialog_title_one": "De URL /{{slug}} bestaat al voor {{count}} lid. Wilt u deze vervangen?",
|
||||
"managed_event_dialog_title_other": "De URL /{{slug}} bestaat al voor {{count}} leden. Wilt u deze vervangen?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> gebruikt de URL <strong>/{{slug}}</strong> al.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> gebruiken de URL <string>/{{slug}}</strong> al.",
|
||||
"managed_event_dialog_clarification": "Als u ervoor kiest om de URL te vervangen, informeren wij de leden daarover. Ga terug en verwijder ze als u de URL niet wilt overschrijven.",
|
||||
"review_event_type": "Gebeurtenistype bekijken",
|
||||
"looking_for_more_analytics": "Op zoek naar meer analyses?",
|
||||
|
||||
@@ -598,7 +598,6 @@
|
||||
"create_new_team": "Opprett et nytt team",
|
||||
"open_invitations": "Åpne invitasjoner",
|
||||
"new_team": "Nytt team",
|
||||
"create_first_team_and_invite_others": "Lag ditt første team og inviter andre brukere til å jobbe sammen med deg.",
|
||||
"create_team_to_get_started": "Opprett et team for å komme i gang",
|
||||
"teams": "Team",
|
||||
"team": "Team",
|
||||
@@ -1010,8 +1009,8 @@
|
||||
"specific_issue": "Har du et spesifikt problem?",
|
||||
"browse_our_docs": "bla gjennom vår dokumentasjon",
|
||||
"choose_template": "Velg en mal",
|
||||
"reminder": "Påminnelse",
|
||||
"custom": "Egendefinert",
|
||||
"reminder": "Påminnelse",
|
||||
"reminder_email": "Påminnelse: {{eventType}} med {{name}} kl. {{date}}",
|
||||
"not_triggering_existing_bookings": "Vil ikke utløses for allerede eksisterende bookinger da brukeren vil bli bedt om telefonnummer ved booking av hendelsen.",
|
||||
"minute_one": "{{count}} minutt",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Może składać się z maksymalnie {{limit}} znaków",
|
||||
"invalid_password_hint": "Hasło musi składać się z co najmniej {{passwordLength}} znaków, zawierać co najmniej jedną cyfrę oraz kombinację wielkich i małych liter",
|
||||
"incorrect_password": "Hasło jest nieprawidłowe.",
|
||||
"incorrect_username_password": "Nieprawidłowa nazwa użytkownika lub hasło.",
|
||||
"use_setting": "Użyj ustawień",
|
||||
"am_pm": "rano/po południu",
|
||||
"time_options": "Opcje czasu",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Utwórz nowy zespół",
|
||||
"open_invitations": "Otwórz Zaproszenia",
|
||||
"new_team": "Nowy zespół",
|
||||
"create_first_team_and_invite_others": "Utwórz swój pierwszy zespół i zaproś innych użytkowników do współpracy z Tobą.",
|
||||
"create_team_to_get_started": "Utwórz drużynę, aby rozpocząć",
|
||||
"teams": "Drużyny",
|
||||
"team": "Zespół",
|
||||
@@ -735,7 +733,7 @@
|
||||
"offset_toggle": "Przesuń czasy rozpoczęcia",
|
||||
"offset_toggle_description": "Przesuń o określoną liczbę minut przedziały czasowe wyświetlane rezerwującym",
|
||||
"offset_start": "Przesuń o",
|
||||
"offset_start_description": "np. przedziały czasowe do rezerwacji będą dostępne o {{ adjustedTime }} zamiast o {{ originalTime }}",
|
||||
"offset_start_description": "np. przedziały czasowe do rezerwacji będą dostępne o {{ adjustedTime }}, zamiast o {{ originalTime }}",
|
||||
"slot_interval": "Przedziały czasowe",
|
||||
"slot_interval_default": "Użyj długości zdarzenia (domyślnie)",
|
||||
"delete_event_type": "Usunąć typ wydarzenia?",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Masz określony problem?",
|
||||
"browse_our_docs": "przeglądaj nasze dokumenty",
|
||||
"choose_template": "Wybierz szablon",
|
||||
"reminder": "Przypomnienie",
|
||||
"custom": "Niestandard.",
|
||||
"reminder": "Przypomnienie",
|
||||
"reminder_email": "Przypomnienie: {{eventType}} z {{name}} w dniu {{date}}",
|
||||
"not_triggering_existing_bookings": "Nie uruchomi się w przypadku już istniejących rezerwacji, ponieważ użytkownik zostanie poproszony o numer telefonu podczas rezerwacji wydarzenia.",
|
||||
"minute_one": "{{count}} min",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Wraz z Cal.com zyskujesz przystępną i bezpłatną licencję AGPLv3 z pewnymi ograniczeniami, z której można w każdej chwili przejść na licencję dla przedsiębiorstw. Na wyższą wersję możesz przejść w każdej chwili.",
|
||||
"license": "Licencja",
|
||||
"agplv3_license": "Licencja AGPLv3",
|
||||
"ee_enterprise_license": "Licencja dla przedsiębiorstw „/ee”",
|
||||
"enterprise_booking_fee": "Od {{enterprise_booking_fee}}/miesiąc",
|
||||
"enterprise_license_includes": "Wszystko, co niezbędne do zastosowań komercyjnych",
|
||||
"no_need_to_keep_your_code_open_source": "Twój kod nie musi pozostawać otwarty.",
|
||||
"repackage_rebrand_resell": "Łatwa modyfikacja wyglądu, marki i możliwość odsprzedaży",
|
||||
"a_vast_suite_of_enterprise_features": "Duży wybór funkcji dla przedsiębiorstw",
|
||||
@@ -1679,7 +1674,6 @@
|
||||
"managed_event_dialog_title_one": "Adres URL /{{slug}} już istnieje dla {{count}} członka. Czy chcesz go zastąpić?",
|
||||
"managed_event_dialog_title_other": "Adres URL /{{slug}} już istnieje dla {{count}} członków. Czy chcesz go zastąpić?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> używa już adresu URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> używają już adresu URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Jeśli zdecydujesz się go zastąpić, powiadomimy te osoby. Wróć i usuń je, jeśli nie chcesz zastępować adresu.",
|
||||
"review_event_type": "Sprawdź typ wydarzenia",
|
||||
"looking_for_more_analytics": "Szukasz dokładniejszych danych analitycznych?",
|
||||
@@ -1733,7 +1727,7 @@
|
||||
"payment_app_commission": "Wymagaj płatności ({{paymentFeePercentage}}% + {{fee, currency}} prowizji od transakcji)",
|
||||
"email_invite_team": "Zaproszono {{email}}",
|
||||
"email_invite_team_bulk": "Zaproszono {{userCount}} użytkowników",
|
||||
"error_collecting_card": "Błąd podczas pobierania karty",
|
||||
"error_collecting_card": "Błąd podczas pobierania należności z karty",
|
||||
"image_size_limit_exceed": "Rozmiar przesyłanego obrazu nie powinien przekraczać limitu 5 MB",
|
||||
"inline_embed": "Osadź w stronie",
|
||||
"load_inline_content": "Wczytuje typ wydarzenia bezpośrednio w treści Twojej strony internetowej.",
|
||||
@@ -1742,7 +1736,7 @@
|
||||
"pop_up_element_click": "Okienko wyskakujące po kliknięciu elementu",
|
||||
"open_dialog_with_element_click": "Otwiera okno dialogowe Cal, gdy użytkownik kliknie odpowiedni element.",
|
||||
"need_help_embedding": "Potrzebujesz pomocy? Zapoznaj się z naszymi instrukcjami osadzania narzędzi Cal w stronach utworzonych w usługach Wix, Squarespace lub WordPress, sprawdź często zadawane pytania lub poznaj zaawansowane opcje osadzania.",
|
||||
"book_my_cal": "Zarezerwuj w moim kalendarzu",
|
||||
"book_my_cal": "Zarezerwuj za pośrednictwem Cal",
|
||||
"invite_as": "Zaproś jako",
|
||||
"form_updated_successfully": "Formularz został zaktualizowany.",
|
||||
"disable_attendees_confirmation_emails": "Wyłącz domyślne wiadomości e-mail z potwierdzeniem dla uczestników",
|
||||
|
||||
@@ -11,11 +11,21 @@
|
||||
"calcom_explained_new_user": "Termine de configurar sua conta do {{appName}}! Falta pouco para você solucionar todos os seus problemas de agendamento.",
|
||||
"have_any_questions": "Precisa de ajuda? Estamos aqui para ajudar.",
|
||||
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
|
||||
"verify_email_subject": "{{appName}}: Verificar sua conta",
|
||||
"check_your_email": "Verifique seu e-mail",
|
||||
"verify_email_email_header": "Verifique seu endereço de e-mail",
|
||||
"verify_email_email_button": "Verificar e-mail",
|
||||
"verify_email_email_body": "Por favor, verifique seu endereço de e-mail clicando no botão abaixo.",
|
||||
"verify_email_email_link_text": "Aqui está o link caso você não goste de usar botões:",
|
||||
"email_sent": "E-mail enviado com sucesso",
|
||||
"event_declined_subject": "Recusado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "Sua solicitação de reunião foi recusada",
|
||||
"event_request_declined_recurring": "Sua solicitação de evento recorrente foi recusada",
|
||||
"event_request_cancelled": "Seu evento agendado foi cancelado",
|
||||
"organizer": "Organizador",
|
||||
"need_to_reschedule_or_cancel": "Precisa reagendar ou cancelar?",
|
||||
"no_options_available": "Sem opções disponíveis",
|
||||
"cancellation_reason": "Motivo do cancelamento",
|
||||
"cancellation_reason_placeholder": "Por que você está cancelando?",
|
||||
"rejection_reason": "Motivo da recusa",
|
||||
@@ -76,6 +86,7 @@
|
||||
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
|
||||
"event_has_been_rescheduled": "Atualização - O seu evento foi reagendado",
|
||||
"request_reschedule_title_organizer": "Você solicitou que {{attendee}} reagendasse",
|
||||
"request_reschedule_subtitle_organizer": "Você cancelou a reserva e {{attendee}} escolherá um novo horário.",
|
||||
"rescheduled_event_type_subject": "Solicitação de reagendamento enviado: {{eventType}} com {{name}} às {{date}}",
|
||||
"requested_to_reschedule_subject_attendee": "Ação de reagendamento necessária: reserve outro horário para {{eventType}} com {{name}}",
|
||||
"hi_user_name": "Olá {{name}}",
|
||||
@@ -180,6 +191,7 @@
|
||||
"blog": "Blog",
|
||||
"blog_description": "Leia as nossas últimas notícias e artigos",
|
||||
"join_our_community": "Junte-se à nossa comunidade",
|
||||
"join_our_discord": "Junte-se ao nosso Discord",
|
||||
"404_claim_entity_user": "Obtenha o seu nome de usuário e agende eventos",
|
||||
"popular_pages": "Páginas populares",
|
||||
"register_now": "Inscreva-se agora",
|
||||
@@ -222,6 +234,7 @@
|
||||
"done": "Concluído",
|
||||
"all_done": "Tudo pronto!",
|
||||
"all": "Todos os apps",
|
||||
"yours": "Sua conta",
|
||||
"available_apps": "Aplicativos disponíveis",
|
||||
"check_email_reset_password": "Verifique o seu email. Enviamos um link para redefinir a sua senha.",
|
||||
"finish": "Finalizar",
|
||||
@@ -282,6 +295,9 @@
|
||||
"success": "Sucesso",
|
||||
"failed": "Falhou",
|
||||
"password_has_been_reset_login": "A sua senha foi redefinida. Agora pode fazer login com a sua senha recém-criada.",
|
||||
"bookerlayout_month_view": "Mês",
|
||||
"bookerlayout_week_view": "Semanalmente",
|
||||
"bookerlayout_column_view": "Coluna",
|
||||
"unexpected_error_try_again": "Ocorreu um erro inesperado. Tente novamente.",
|
||||
"sunday_time_error": "Horário inválido no domingo",
|
||||
"monday_time_error": "Horário inválido na segunda-feira",
|
||||
@@ -294,6 +310,7 @@
|
||||
"error_end_time_next_day": "O horário de término não pode ser maior do que 24 horas",
|
||||
"back_to_bookings": "Voltar às Reservas",
|
||||
"free_to_pick_another_event_type": "Sinta-se à vontade para escolher outro evento a qualquer hora.",
|
||||
"cancelled": "Cancelado",
|
||||
"cancellation_successful": "Cancelado com Sucesso",
|
||||
"really_cancel_booking": "Deseja realmente cancelar a sua reserva?",
|
||||
"cannot_cancel_booking": "Você não pode cancelar esta reserva",
|
||||
@@ -315,6 +332,7 @@
|
||||
"upcoming_bookings": "Assim que alguém reservar uma hora, ela aparecerá aqui.",
|
||||
"recurring_bookings": "Assim que alguém reservar uma reunião recorrente com você, essa pessoa aparecerá aqui.",
|
||||
"past_bookings": "As suas reservas anteriores aparecerão aqui.",
|
||||
"cancelled_bookings": "Suas reservas canceladas aparecerão aqui.",
|
||||
"unconfirmed_bookings": "Suas reservas não confirmadas aparecerão aqui.",
|
||||
"unconfirmed_bookings_tooltip": "Reservas não confirmadas",
|
||||
"on": "em",
|
||||
@@ -428,7 +446,6 @@
|
||||
"max_limit_allowed_hint": "O tamanho deve ser de {{limit}} ou menos caracteres",
|
||||
"invalid_password_hint": "A senha precisa ter pelo menos sete caracteres com pelo menos um número e ser uma combinação de letras maiúsculas e minúsculas",
|
||||
"incorrect_password": "Senha incorreta.",
|
||||
"incorrect_username_password": "A senha ou o nome de usuário estão incorretos.",
|
||||
"use_setting": "Usar configurações",
|
||||
"am_pm": "manhã/tarde",
|
||||
"time_options": "Opções de horário",
|
||||
@@ -456,6 +473,7 @@
|
||||
"additional_guests": "+ Participantes Adicionais",
|
||||
"your_name": "Seu nome",
|
||||
"your_full_name": "Seu nome completo",
|
||||
"no_name": "Sem nome",
|
||||
"email_address": "Endereço de e-mail",
|
||||
"enter_valid_email": "Insira um e-mail válido",
|
||||
"location": "Local",
|
||||
@@ -499,10 +517,13 @@
|
||||
"your_team_updated_successfully": "Seu time foi atualizado com sucesso.",
|
||||
"about": "Sobre",
|
||||
"team_description": "Fale um pouco sobre seu time. Isso aparecerá na página pública do seu time.",
|
||||
"org_description": "Uma breve descrição sobre sua organização. Isso aparecerá na página principal da sua organização.",
|
||||
"members": "Membros",
|
||||
"organization_members": "Membros da organização",
|
||||
"member": "Membro",
|
||||
"number_member_one": "{{count}} membro",
|
||||
"number_member_other": "{{count}} membros",
|
||||
"number_selected": "{{count}} selecionados",
|
||||
"owner": "Proprietário",
|
||||
"admin": "Admin",
|
||||
"administrator_user": "Usuário administrador",
|
||||
@@ -639,10 +660,11 @@
|
||||
"create_new_team": "Criar um novo time",
|
||||
"open_invitations": "Convites enviados",
|
||||
"new_team": "Novo time",
|
||||
"create_first_team_and_invite_others": "Crie o seu primeiro time e convide outros usuários para trabalhar em conjunto.",
|
||||
"create_first_team_and_invite_others": "Crie seu primeiro time e convide outros usuários para trabalharem com você.",
|
||||
"create_team_to_get_started": "Criar um time para começar",
|
||||
"teams": "Times",
|
||||
"team": "Equipe",
|
||||
"organization": "Organização",
|
||||
"team_billing": "Cobrança da equipe",
|
||||
"team_billing_description": "Gerenciar cobrança para sua equipe",
|
||||
"upgrade_to_flexible_pro_title": "Alteramos a cobrança para equipes",
|
||||
@@ -717,6 +739,7 @@
|
||||
"cal_provide_huddle01_meeting_url": "O {{appName}} fornecerá um URL de reunião do Huddle01 Web3 video.",
|
||||
"cal_provide_teams_meeting_url": "O {{appName}} enviará um URL da reunião para o MS Teams. OBSERVAÇÃO: É PRECISO TER UMA CONTA ESCOLAR OU CORPORATIVA",
|
||||
"require_payment": "Requerer Pagamento",
|
||||
"you_need_to_add_a_name": "Você precisa adicionar um nome",
|
||||
"commission_per_transaction": "comissão por transação",
|
||||
"event_type_updated_successfully_description": "O seu tipo de evento foi atualizado com sucesso.",
|
||||
"hide_event_type": "Ocultar Tipo de Evento",
|
||||
@@ -808,6 +831,7 @@
|
||||
"free_to_use_apps": "Grátis",
|
||||
"no_category_apps": "Sem aplicativos de {{category}}",
|
||||
"no_category_apps_description_calendar": "Adicione um aplicativo de calendário para verificar se há conflitos para evitar agendamentos duplos",
|
||||
"no_category_apps_description_conferencing": "Tente adicionar um aplicativo para chamadas de vídeo com seus clientes",
|
||||
"no_category_apps_description_payment": "Adicione um aplicativo de pagamento para facilitar a transação entre você e seus clientes",
|
||||
"no_category_apps_description_analytics": "Adicione um aplicativo de análise às suas páginas de reservas",
|
||||
"no_category_apps_description_automation": "Adicione um aplicativo de automação para usar",
|
||||
@@ -1073,8 +1097,8 @@
|
||||
"specific_issue": "Tem um problema específico?",
|
||||
"browse_our_docs": "pesquisar nos nossos documentos",
|
||||
"choose_template": "Escolher um modelo",
|
||||
"reminder": "Lembrete",
|
||||
"custom": "Personalizado",
|
||||
"reminder": "Lembrete",
|
||||
"reminder_email": "Lembrete: {{eventType}} com {{name}} em {{date}}",
|
||||
"not_triggering_existing_bookings": "Não será acionado para reservas já existentes, pois o usuário deverá fornecer o número de telefone ao fazer um pedido no evento.",
|
||||
"minute_one": "{{count}} minuto",
|
||||
@@ -1528,9 +1552,6 @@
|
||||
"choose_license_description": "O Cal.com vem com uma licença AGPLv3 acessível e gratuita, mas tem algumas limitações, que podem ser resolvidas atualizando para a licença Enterprise a qualquer momento. Você poderá fazer isso mais tarde, quando quiser.",
|
||||
"license": "Licença",
|
||||
"agplv3_license": "Licença AGPLv3",
|
||||
"ee_enterprise_license": "“/ee” Licença Enterprise",
|
||||
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mês",
|
||||
"enterprise_license_includes": "Tudo para um caso de uso comercial",
|
||||
"no_need_to_keep_your_code_open_source": "Não precisa manter seu código em código aberto",
|
||||
"repackage_rebrand_resell": "Reempacote, troque de marca e revenda com facilidade",
|
||||
"a_vast_suite_of_enterprise_features": "Uma ampla variedade de recursos empresariais",
|
||||
@@ -1679,7 +1700,6 @@
|
||||
"managed_event_dialog_title_one": "O url/{{slug}} já existe para {{count}} membro. Deseja substituir?",
|
||||
"managed_event_dialog_title_other": "O url/{{slug}} já existe para {{count}} membros. Deseja substituir?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> já está usando o url <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> já estão usando o url <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Se você escolher substituir, serão enviadas notificações. Volte e remova caso não queira sobrescrever.",
|
||||
"review_event_type": "Revisar tipo de evento",
|
||||
"looking_for_more_analytics": "Procurando mais análises?",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"calcom_explained_new_user": "Conclua a configuração da sua conta {{appName}}! Está apenas a alguns passos de resolver todos os seus problemas com agendamentos.",
|
||||
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
|
||||
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
|
||||
"verify_email_subject": "{{appName}}: Verificar sua conta",
|
||||
"verify_email_page_body": "Nós enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir que você receberá as comunicações de {{appName}}.",
|
||||
"event_declined_subject": "Declinado: {{title}} em {{date}}",
|
||||
"event_request_declined": "O seu pedido de evento foi declinado",
|
||||
"event_request_declined_recurring": "O seu pedido de evento recorrente foi recusado",
|
||||
@@ -302,6 +304,12 @@
|
||||
"cancelling_event_recurring": "O evento é uma instância de um evento recorrente.",
|
||||
"cancelling_all_recurring": "Estas são todas as restantes instâncias do evento recorrente.",
|
||||
"error_with_status_code_occured": "Ocorreu um erro com o código de estado {{status}}",
|
||||
"error_event_type_url_duplicate": "Um tipo de evento com essa URL já existe.",
|
||||
"error_event_type_unauthorized_create": "Você não tem autorização para criar esse evento",
|
||||
"error_event_type_unauthorized_update": "Você não tem autorização para editar esse evento",
|
||||
"error_workflow_unauthorized_create": "Você não tem autorização para criar esse evento",
|
||||
"error_schedule_unauthorized_create": "Você não tem autorização para criar essa agenda",
|
||||
"booking_already_cancelled": "Esta reserva já foi cancelada",
|
||||
"booking_already_accepted_rejected": "Esta reserva já foi aceite ou rejeitada",
|
||||
"go_back_home": "Voltar à Página Inicial",
|
||||
"or_go_back_home": "Ou volte à página inicial",
|
||||
@@ -311,10 +319,12 @@
|
||||
"no_status_bookings_yet_description": "Não tem reservas {{status}}. {{description}}",
|
||||
"event_between_users": "{{eventName}} entre {{host}} e {{attendeeName}}",
|
||||
"bookings": "Reservas",
|
||||
"booking_not_found": "Reserva não encontrada",
|
||||
"bookings_description": "Veja os eventos futuros e passados reservados através das ligações de tipos de eventos.",
|
||||
"upcoming_bookings": "Assim que alguém reservar uma hora, ela aparecerá aqui.",
|
||||
"recurring_bookings": "Assim que alguém reservar uma reunião recorrente, será mostrada aqui.",
|
||||
"past_bookings": "As suas reservas anteriores aparecerão aqui.",
|
||||
"cancelled_bookings": "Suas reservas canceladas aparecerão aqui.",
|
||||
"unconfirmed_bookings": "As suas marcações não confirmadas irão aparecer aqui.",
|
||||
"unconfirmed_bookings_tooltip": "Reservas não confirmadas",
|
||||
"on": "em",
|
||||
@@ -354,9 +364,12 @@
|
||||
"browse_api_documentation": "Consulte a nossa documentação da API",
|
||||
"leverage_our_api": "Tire partido da nossa API para controlo e personalização total.",
|
||||
"create_webhook": "Criar Webhook",
|
||||
"booking_cancelled": "Reserva Cancelada",
|
||||
"booking_rescheduled": "Reserva Reagendada",
|
||||
"recording_ready": "Ligação de transferência da gravação pronta",
|
||||
"booking_created": "Reserva Criada",
|
||||
"booking_rejected": "Reserva Rejeitada",
|
||||
"booking_requested": "Reserva Rejeitada",
|
||||
"meeting_ended": "A reunião terminou",
|
||||
"form_submitted": "Formulário enviado",
|
||||
"event_triggers": "Causadores de eventos",
|
||||
@@ -428,7 +441,6 @@
|
||||
"max_limit_allowed_hint": "Pode ter um máximo de {{limit}} caracteres",
|
||||
"invalid_password_hint": "A palavra-passe deve ter no mínimo {{passwordLength}} caracteres, com pelo menos um número e uma mistura de letras maiúsculas e minúsculas",
|
||||
"incorrect_password": "Palavra-Passe incorreta",
|
||||
"incorrect_username_password": "O nome de utilizador ou a palavra-passe estão incorretos.",
|
||||
"use_setting": "Usar Configuração",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Opções de Hora",
|
||||
@@ -456,6 +468,7 @@
|
||||
"additional_guests": "+ Convidados Adicionais",
|
||||
"your_name": "O seu nome",
|
||||
"your_full_name": "O seu nome completo",
|
||||
"no_name": "Sem nome",
|
||||
"email_address": "Endereço de email",
|
||||
"enter_valid_email": "Insira um e-mail válido",
|
||||
"location": "Localização",
|
||||
@@ -639,10 +652,10 @@
|
||||
"create_new_team": "Criar uma Equipa",
|
||||
"open_invitations": "Convites Abertos",
|
||||
"new_team": "Nova Equipa",
|
||||
"create_first_team_and_invite_others": "Crie a sua primeira equipa e convide outros utilizadores para trabalhar em conjunto.",
|
||||
"create_team_to_get_started": "Criar uma equipa para começar",
|
||||
"teams": "Equipas",
|
||||
"team": "Equipa",
|
||||
"organization": "Organização",
|
||||
"team_billing": "Facturação da equipa",
|
||||
"team_billing_description": "Faça a gestão da faturação da sua equipa",
|
||||
"upgrade_to_flexible_pro_title": "Alterámos os termos de facturação",
|
||||
@@ -807,18 +820,23 @@
|
||||
"installed_apps": "Apps instaladas",
|
||||
"free_to_use_apps": "Grátis",
|
||||
"no_category_apps": "Sem aplicações de {{category}}",
|
||||
"all_apps": "Todas as aplicações",
|
||||
"no_category_apps_description_calendar": "Adicione uma aplicação de calendário para verificar se existem conflitos e assim evitar sobreposição de marcações",
|
||||
"no_category_apps_description_payment": "Adicione uma aplicação de pagamentos para facilitar a transação entre si e os seus clientes",
|
||||
"no_category_apps_description_analytics": "Adicionar uma aplicação de análise às suas páginas de reservas",
|
||||
"no_category_apps_description_automation": "Adicionar uma aplicação de automatização a utilizar",
|
||||
"no_category_apps_description_other": "Adicione qualquer outro tipo de aplicações para fazer todos os tipos de coisas",
|
||||
"no_category_apps_description_web3": "Adicione uma aplicação web3 às suas páginas de reservas",
|
||||
"no_category_apps_description_crm": "Adicione um aplicativo CRM para manter o controle de com quem você encontrou",
|
||||
"installed_app_calendar_description": "Defina o(s) calendário(s) para verificar se existem conflitos e assim evitar marcações sobrepostas.",
|
||||
"installed_app_payment_description": "Configure os serviços de processamento de pagamentos que serão utilizados nas cobranças aos seus clientes.",
|
||||
"installed_app_analytics_description": "Configurar as aplicações de análise a utilizar nas suas páginas de reservas",
|
||||
"installed_app_other_description": "Todas as aplicações instaladas de outras categorias.",
|
||||
"installed_app_conferencing_description": "Configure que aplicativos de conferência utilizar",
|
||||
"installed_app_automation_description": "Configurar as aplicações de automatização a utilizar",
|
||||
"installed_app_web3_description": "Configure as aplicações web3 a utilizar nas suas páginas de reservas",
|
||||
"installed_app_messaging_description": "Configure que aplicativos de mensagens utilizar para configurar notificações e lembretes personalizados",
|
||||
"installed_app_crm_description": "Configure que aplicativos de CRM utilizar para manter o controle de com quem você encontrou",
|
||||
"analytics": "Estatísticas",
|
||||
"empty_installed_apps_headline": "Nenhuma aplicação instalada",
|
||||
"empty_installed_apps_description": "As aplicações permitem-lhe melhorar o seu fluxo de trabalho e os seus agendamentos, significativamente.",
|
||||
@@ -913,6 +931,7 @@
|
||||
"confirm_delete_api_key": "Revogar esta chave de API",
|
||||
"revoke_api_key": "Revogar chave de API",
|
||||
"api_key_copied": "Chave de API copiada!",
|
||||
"api_key_expires_on": "A chave de API expirará em",
|
||||
"delete_api_key_confirm_title": "Remover de forma permanente esta chave de API da sua conta?",
|
||||
"copy": "Copiar",
|
||||
"expire_date": "Data de expiração",
|
||||
@@ -1002,16 +1021,21 @@
|
||||
"event_location": "Localização do evento",
|
||||
"reschedule_optional": "Razão do reagendamento (opcional)",
|
||||
"reschedule_placeholder": "Informe os restantes para que saibam porque precisa de reagendar",
|
||||
"event_cancelled": "Esse evento foi cancelado",
|
||||
"emailed_information_about_cancelled_event": "Enviámos um email com o aviso para si e para os restantes participantes.",
|
||||
"this_input_will_shown_booking_this_event": "Este campo será mostrado na reserva deste evento",
|
||||
"meeting_url_in_conformation_email": "O URL da reunião está no email de confirmação",
|
||||
"url_start_with_https": "O URL tem de começar por http:// ou https://",
|
||||
"number_provided": "Será fornecido um número de telefone",
|
||||
"before_event_trigger": "antes do evento iniciar",
|
||||
"event_cancelled_trigger": "quando o evento for cancelado",
|
||||
"new_event_trigger": "quando um novo evento for reservado",
|
||||
"email_host_action": "enviar email ao anfitrião",
|
||||
"email_attendee_action": "enviar e-mail aos participantes",
|
||||
"sms_attendee_action": "Enviar SMS ao participante",
|
||||
"sms_number_action": "enviar SMS para um número específico",
|
||||
"whatsapp_number_action": "enviar Whatsapp para um número específico",
|
||||
"whatsapp_attendee_action": "enviar Whatsapp ao participante",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
"new_workflow_btn": "Novo fluxo de trabalho",
|
||||
"add_new_workflow": "Adicionar novo fluxo de trabalho",
|
||||
@@ -1073,8 +1097,10 @@
|
||||
"specific_issue": "Tem um problema específico?",
|
||||
"browse_our_docs": "consulte a nossa documentação",
|
||||
"choose_template": "Escolha um modelo",
|
||||
"reminder": "Lembrete",
|
||||
"custom": "Personalizado",
|
||||
"reminder": "Lembrete",
|
||||
"rescheduled": "Reagendado",
|
||||
"completed": "Concluído",
|
||||
"reminder_email": "Lembrete: {{eventType}} com {{name}} em {{date}}",
|
||||
"not_triggering_existing_bookings": "Não será accionado para reservas já existentes, uma vez que será pedido o número de telefone ao utilizador ao reservar o evento.",
|
||||
"minute_one": "{{count}} minuto",
|
||||
@@ -1213,6 +1239,7 @@
|
||||
"conferencing_description": "Adicione as suas aplicações de videoconferência para reuniões",
|
||||
"add_conferencing_app": "Adicionar aplicação de conferência",
|
||||
"password_description": "Faça a gestão das definições para as palavras-passe da sua conta",
|
||||
"set_up_two_factor_authentication": "Configure sua autenticação em duas etapas",
|
||||
"we_just_need_basic_info": "Precisamos apenas de algumas informações básicas para configurar o seu perfil.",
|
||||
"skip": "Ignorar",
|
||||
"do_this_later": "Fazer isto mais tarde",
|
||||
@@ -1226,6 +1253,8 @@
|
||||
"connect_analytics_apps": "Associar aplicações de estatística",
|
||||
"connect_other_apps": "Associe outras aplicações",
|
||||
"connect_web3_apps": "Associe aplicações web3",
|
||||
"connect_messaging_apps": "Conecte aplicações de mensagens",
|
||||
"connect_crm_apps": "Conecte aplicações de CRM",
|
||||
"current_step_of_total": "Passo {{currentStep}} de {{maxSteps}}",
|
||||
"add_variable": "Adicionar variável",
|
||||
"custom_phone_number": "Número do telefone personalizado",
|
||||
@@ -1238,12 +1267,14 @@
|
||||
"location_info": "O local do evento",
|
||||
"additional_notes_info": "As notas adicionais da reserva",
|
||||
"attendee_name_info": "O nome do responsável pela reserva",
|
||||
"organizer_name_info": "Nome do(a) organizador(a)",
|
||||
"to": "Para",
|
||||
"workflow_turned_on_successfully": "Fluxo de trabalho {{workflowName}} {{offOn}} com sucesso",
|
||||
"download_responses": "Descarregar respostas",
|
||||
"download_responses_description": "Descarregue todas as respostas ao seu formulário em formato CSV.",
|
||||
"download": "Descarregar",
|
||||
"download_recording": "Transferir gravação",
|
||||
"recording_from_your_recent_call": "A gravação da sua chamada recente em {{appName}} está pronta para download",
|
||||
"create_your_first_form": "Crie o seu primeiro formulário",
|
||||
"create_your_first_form_description": "Com os Formulários de encaminhamento, pode fazer questões qualitativas e encaminhar para a pessoa ou o tipo de evento correto.",
|
||||
"create_your_first_webhook": "Crie o seu primeiro Webhook",
|
||||
@@ -1252,7 +1283,9 @@
|
||||
"event_one": "evento",
|
||||
"event_other": "eventos",
|
||||
"profile_team_description": "Faça a gestão das definições do seu perfil de equipa",
|
||||
"profile_org_description": "Gerencie as configurações do perfil da sua organização",
|
||||
"members_team_description": "Utilizadores que estão no grupo",
|
||||
"organization_description": "Gerencie os membros e administradores da sua organização",
|
||||
"team_url": "Endereço da equipa",
|
||||
"team_members": "Membros da equipa",
|
||||
"more": "Mais",
|
||||
@@ -1378,6 +1411,7 @@
|
||||
"new_event_type_availability": "Disponibilidade de {{eventTypeTitle}}",
|
||||
"error_editing_availability": "Erro ao editar a disponibilidade",
|
||||
"dont_have_permission": "Não tem permissão para aceder a esta funcionalidade.",
|
||||
"saml_config": "SAML",
|
||||
"saml_configuration_placeholder": "Cole aqui os metadados de SAML do seu fornecedor de identidade",
|
||||
"saml_email_required": "Por favor insira um email para que seja possível encontrar o seu fornecedor de identidade de SAML",
|
||||
"saml_sp_title": "Detalhes do Fornecedor do serviço",
|
||||
@@ -1398,6 +1432,7 @@
|
||||
"your_team_disbanded_successfully": "A sua equipa foi dissolvida com sucesso",
|
||||
"error_creating_team": "Erro ao criar a equipa",
|
||||
"you": "Você",
|
||||
"resend_email": "Reenviar e-mail",
|
||||
"member_already_invited": "O membro já foi convidado",
|
||||
"enter_email_or_username": "Especifique um e-mail ou nome de utilizador",
|
||||
"team_name_taken": "Este nome já está a ser utilizado",
|
||||
@@ -1405,6 +1440,7 @@
|
||||
"team_url_required": "Deve especificar um endereço para a equipa",
|
||||
"url_taken": "Este endereço já está a ser utilizado",
|
||||
"team_publish": "Publicar equipa",
|
||||
"number_text_notifications": "Número de telefone (notificações de texto)",
|
||||
"attendee_email_variable": "E-mail do participante",
|
||||
"attendee_email_info": "O e-mail do responsável pela reserva",
|
||||
"kbar_search_placeholder": "Digite um comando ou pesquise...",
|
||||
@@ -1427,6 +1463,8 @@
|
||||
"find_the_best_person": "Encontre a melhor pessoa disponível e faça um ciclo pela sua equipa.",
|
||||
"fixed_round_robin": "Distribuição equilibrada fixa",
|
||||
"add_one_fixed_attendee": "Adicione um participante fixo e distribua de forma equilibrada a partir de um número de participantes.",
|
||||
"calcom_is_better_with_team": "{{appName}} é melhor com times",
|
||||
"the_calcom_team": "A equipe {{companyName}}",
|
||||
"add_your_team_members": "Adicione os membros da sua equipa aos seus tipos de eventos. Utilize o agendamento coletivo para incluir todos os membros ou encontre a pessoa mais adequada com o agendamento baseado numa distribuição equilibrada.",
|
||||
"booking_limit_reached": "O limite de reservas para este tipo de evento foi atingido",
|
||||
"duration_limit_reached": "O limite de duração para este tipo de evento foi atingido",
|
||||
@@ -1445,6 +1483,7 @@
|
||||
"navigate_installed_apps": "Ir para as aplicações instaladas",
|
||||
"disabled_calendar": "Se tiver outro calendário instalado, as novas reservas serão adicionadas ao mesmo. Se não for o caso, deve associar um novo calendário para garantir que não perde novas reservas.",
|
||||
"enable_apps": "Ativar aplicações",
|
||||
"enable_apps_description": "Ative aplicativos que usuários podem integrar com {{appName}}",
|
||||
"purchase_license": "Adquira uma Licença",
|
||||
"already_have_key": "Eu já tenho uma chave:",
|
||||
"already_have_key_suggestion": "Por favor, copie a sua variável de ambiente CALCOM_LICENSE_KEY existente aqui.",
|
||||
@@ -1458,6 +1497,9 @@
|
||||
"no_available_apps": "Não existem aplicações disponíveis",
|
||||
"no_available_apps_description": "Garanta que existem aplicações na sua instalação em 'packages/app-store'",
|
||||
"no_apps": "Não existem aplicações ativas nesta instância do Cal",
|
||||
"no_apps_configured": "Nenhuma aplicação foi configurada ainda",
|
||||
"enable_in_settings": "Você pode habilitar os aplicatovs nas configurações",
|
||||
"please_contact_admin": "Por favor, contate seu administrador",
|
||||
"apps_settings": "Definições de aplicações",
|
||||
"fill_this_field": "Por favor, preencha este campo",
|
||||
"options": "Opções",
|
||||
@@ -1528,9 +1570,6 @@
|
||||
"choose_license_description": "A Cal.com vem com uma licença AGPLv3 acessível e gratuita, na qual algumas limitações podem ser ultrapassadas a qualquer momento com uma licença Enterprise. Pode atualizar para a versão Enterprise mais tarde e quando quiser.",
|
||||
"license": "Licença",
|
||||
"agplv3_license": "Licença AGPLv3",
|
||||
"ee_enterprise_license": "Licença Enterprise “/ee”",
|
||||
"enterprise_booking_fee": "A partir de {{enterprise_booking_fee}}/mês",
|
||||
"enterprise_license_includes": "Tudo para uma utilização comercial",
|
||||
"no_need_to_keep_your_code_open_source": "Não há necessidade de manter o seu código aberto",
|
||||
"repackage_rebrand_resell": "Republique, personalize a marca e revenda com facilidade",
|
||||
"a_vast_suite_of_enterprise_features": "Um vasto conjunto de funcionalidades empresariais",
|
||||
@@ -1595,6 +1634,7 @@
|
||||
"booking_questions_title": "Questões sobre a reserva",
|
||||
"booking_questions_description": "Personalize as questões colocadas na página da reserva",
|
||||
"add_a_booking_question": "Adicionar uma questão",
|
||||
"identifier": "Identificador",
|
||||
"duplicate_email": "O e-mail está duplicado",
|
||||
"booking_with_payment_cancelled": "Já não é possível pagar para este evento",
|
||||
"booking_with_payment_cancelled_already_paid": "Está a ser processado um reembolso do pagamento desta reserva.",
|
||||
@@ -1641,6 +1681,7 @@
|
||||
"switch_monthly": "Mudar para a vista mensal",
|
||||
"switch_weekly": "Mudar para a vista semanal",
|
||||
"switch_multiday": "Mudar para a vista diária",
|
||||
"switch_columnview": "Mudar para visualização em coluna",
|
||||
"num_locations": "{{num}} opções de localização",
|
||||
"select_on_next_step": "Selecione no próximo passo",
|
||||
"this_meeting_has_not_started_yet": "Esta reunião ainda não começou",
|
||||
@@ -1679,7 +1720,6 @@
|
||||
"managed_event_dialog_title_one": "A URL /{{slug}} já existe para {{count}} membro. Pretende substituir a mesma?",
|
||||
"managed_event_dialog_title_other": "A URL /{{slug}} já existe para {{count}} membros. Pretende substituir a mesma?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> já está a utilizar a URL <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> já estão a utilizar a URL <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Se optar pela substituição, iremos notificá-los. Retroceda e remova-os se não quiser substituir.",
|
||||
"review_event_type": "Rever Tipo de Evento",
|
||||
"looking_for_more_analytics": "À procura de mais análises?",
|
||||
@@ -1695,12 +1735,14 @@
|
||||
"least_booked_members": "Membros com menos reservas",
|
||||
"events_created": "Eventos criados",
|
||||
"events_completed": "Eventos concluídos",
|
||||
"events_cancelled": "Eventos Cancelados",
|
||||
"events_rescheduled": "Eventos reagendados",
|
||||
"from_last_period": "do último período",
|
||||
"from_to_date_period": "De: {{startDate}} A: {{endDate}}",
|
||||
"redirect_url_warning": "Ao adicionar um redirecionamento irá desativar a página de sucesso. Certifique-se que indica que \"A reserva foi confirmada\" na sua página de sucesso personalizada.",
|
||||
"event_trends": "Tendências de evento",
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear": "Limpar",
|
||||
"hold": "Reter",
|
||||
"on_booking_option": "Receber o pagamento na reserva",
|
||||
"hold_option": "Cobrar taxa de não-comparência",
|
||||
@@ -1727,18 +1769,21 @@
|
||||
"complete_your_booking": "Concluir a sua reserva",
|
||||
"complete_your_booking_subject": "Concluir a sua reserva: {{title}} a {{date}}",
|
||||
"confirm_your_details": "Confirme os seus dados",
|
||||
"copy_invite_link": "Copiar link do convite",
|
||||
"edit_invite_link": "Editar configurações do link",
|
||||
"invite_link_copied": "Link do convite copiado",
|
||||
"currency_string": "{{amount, currency}}",
|
||||
"charge_card_dialog_body": "Está em vias de cobrar {{amount, currency}} ao participante. Tem a certeza que pretende continuar?",
|
||||
"charge_attendee": "Cobrar {{amount, currency}} ao participante",
|
||||
"payment_app_commission": "Exigir pagamento ({{paymentFeePercentage}}% + {{fee, currency}} de comissão por transação)",
|
||||
"email_invite_team": "{{email}} foi convidado/a",
|
||||
"email_invite_team_bulk": "Foram convidados {{userCount}} utilizadores",
|
||||
"error_collecting_card": "Erro ao recolher o cartão",
|
||||
"error_collecting_card": "Erro ao processar o cartão",
|
||||
"image_size_limit_exceed": "A imagem enviada não deve exceder o limite de tamanho de 5MB",
|
||||
"inline_embed": "Incorporação em linha",
|
||||
"load_inline_content": "Carrega o seu tipo de evento diretamente e em linha, em conjunto com os conteúdos do seu outro site.",
|
||||
"floating_pop_up_button": "Botão pop-up flutuante",
|
||||
"floating_button_trigger_modal": "Coloca um botão flutuante no seu site que ativa uma modal com o seu tipo de evento.",
|
||||
"floating_button_trigger_modal": "Coloca um botão flutuante no seu site que ativa uma janela modal com o seu tipo de evento.",
|
||||
"pop_up_element_click": "Pop-up via clique no elemento",
|
||||
"open_dialog_with_element_click": "Abrir a sua janela do Cal quando alguém clicar num elemento.",
|
||||
"need_help_embedding": "Precisa de ajuda? Consulte os nossos guias para incorporar o Cal no Wix, Squarespace, ou WordPress. Consulte ainda as nossas perguntas frequentes ou explore opções avançadas de incorporação.",
|
||||
@@ -1755,5 +1800,28 @@
|
||||
"google_workspace_admin_tooltip": "Tem de ser um Administrador do Workspace para utilizar esta funcionalidade",
|
||||
"first_event_type_webhook_description": "Crie o seu primeiro webhook para este tipo de evento",
|
||||
"create_for": "Criar para",
|
||||
"insights_all_org_filter": "Todos as aplicações"
|
||||
"organization_logo": "Logo da Organização",
|
||||
"organization_about_description": "Breve descrição sobre sua organização. Isso vai aparecer na página de perfil pública da sua organização.",
|
||||
"ill_do_this_later": "Farei isso depois",
|
||||
"verify_your_email": "Verifique seu e-mail",
|
||||
"enter_digit_code": "Entre o código de 6 dígitos enviado para {{email}}",
|
||||
"verify_email_organization": "Verifique seu e-mail para criar uma organização",
|
||||
"code_provided_invalid": "O código fornecido inválido, tente novamente",
|
||||
"email_already_used": "E-mail já está sendo usado",
|
||||
"duplicated_slugs_warning": "Os seguintes times não puderam ser criados devido a slugs duplicados: {{slugs}}",
|
||||
"team_names_empty": "Nomes de equipes não podem estar vazios",
|
||||
"team_names_repeated": "Nomes de equipes não podem se repetir",
|
||||
"user_belongs_organization": "Usuário pertence a uma organização",
|
||||
"org_no_teams_yet": "Essaorganização ainda não tem times",
|
||||
"my_profile": "Meu perfil",
|
||||
"my_settings": "Minhas configurações",
|
||||
"crm": "CRM",
|
||||
"messaging": "Mensagens",
|
||||
"org_url": "URL da Organização",
|
||||
"copy_link_org": "Copie o link para organização",
|
||||
"404_the_org": "A organização",
|
||||
"404_the_team": "O time",
|
||||
"insights_all_org_filter": "Todos as aplicações",
|
||||
"insights_team_filter": "Time: {{teamName}}",
|
||||
"insights_user_filter": "Usuário {{userName}}"
|
||||
}
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Trebuie să aibă {{limit}} sau mai puține caractere",
|
||||
"invalid_password_hint": "Parola trebuie să aibă cel puțin {{passwordLength}} caractere, să conțină cel puțin o cifră și o combinație de litere mari și litere mici",
|
||||
"incorrect_password": "Parola este incorectă.",
|
||||
"incorrect_username_password": "Numele de utilizator sau parola este incorectă.",
|
||||
"use_setting": "Folosește setarea",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Opţiuni de timp",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Creează o echipă nouă",
|
||||
"open_invitations": "Deschide invitațiile",
|
||||
"new_team": "Echipă nouă",
|
||||
"create_first_team_and_invite_others": "Creează-ți prima echipă și invită-i pe ceilalți utilizatori să lucreze împreună cu tine.",
|
||||
"create_team_to_get_started": "Creează o echipă pentru a începe",
|
||||
"teams": "Echipe",
|
||||
"team": "Echipă",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Aveți o problemă specifică?",
|
||||
"browse_our_docs": "răsfoiți documentele noastre",
|
||||
"choose_template": "Alegeți un șablon",
|
||||
"reminder": "Memento",
|
||||
"custom": "Personalizat",
|
||||
"reminder": "Memento",
|
||||
"reminder_email": "Memento: {{eventType}} cu {{name}} la {{date}}",
|
||||
"not_triggering_existing_bookings": "Nu se va declanșa pentru rezervările existente, deoarece utilizatorului i se va solicita numărul de telefon atunci când face rezervare pentru eveniment.",
|
||||
"minute_one": "{{count}} minut",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com include o licență AGPLv3 accesibilă și gratuită, cu unele limitări, însă care poate fi actualizată la o licență Enterprise în orice moment. Puteți face upgrade oricând mai târziu.",
|
||||
"license": "Licență",
|
||||
"agplv3_license": "Licență AGPLv3",
|
||||
"ee_enterprise_license": "Licență Enterprise „/ee”",
|
||||
"enterprise_booking_fee": "Începând de la {{enterprise_booking_fee}}/lună",
|
||||
"enterprise_license_includes": "Totul pentru uz comercial",
|
||||
"no_need_to_keep_your_code_open_source": "Nu este nevoie să vă păstrați codul open-source",
|
||||
"repackage_rebrand_resell": "Restructurați și revindeți cu ușurință, sub o nouă marcă",
|
||||
"a_vast_suite_of_enterprise_features": "O gamă largă de caracteristici specifice întreprinderilor",
|
||||
@@ -1679,7 +1674,6 @@
|
||||
"managed_event_dialog_title_one": "URL-ul /{{slug}} există deja pentru {{count}} membru. Doriți să îl înlocuiți?",
|
||||
"managed_event_dialog_title_other": "URL-ul /{{slug}} există deja pentru {{count}} (de) membri. Doriți să îl înlocuiți?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> folosește deja URL-ul <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> folosesc deja URL-ul <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Îi vom anunța dacă alegeți să îl înlocuiți. Reveniți și ștergeți-i dacă nu doriți să îl suprascrieți.",
|
||||
"review_event_type": "Revizuiți tipul de eveniment",
|
||||
"looking_for_more_analytics": "Căutați mai multe analize?",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Не более {{limit}} символов",
|
||||
"invalid_password_hint": "Пароль должен быть не короче {{passwordLength}} символов и содержать заглавные и строчные буквы, а также не менее одной цифры",
|
||||
"incorrect_password": "Неверный пароль",
|
||||
"incorrect_username_password": "Неверное имя пользователя или пароль.",
|
||||
"use_setting": "Использовать настройки",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Настройки времени",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Создание новой команды",
|
||||
"open_invitations": "Открыть список приглашений",
|
||||
"new_team": "Новая команда",
|
||||
"create_first_team_and_invite_others": "Создайте свою первую команду и пригласите других пользователей работать вместе с вами.",
|
||||
"create_team_to_get_started": "Создайте команду, чтобы начать работу",
|
||||
"teams": "Команды",
|
||||
"team": "Команда",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Возникла конкретная проблема?",
|
||||
"browse_our_docs": "просмотрите нашу документацию",
|
||||
"choose_template": "Выбрать шаблон",
|
||||
"reminder": "Напоминание",
|
||||
"custom": "Пользовательский",
|
||||
"reminder": "Напоминание",
|
||||
"reminder_email": "Напоминание: {{eventType}} с {{name}} {{date}}",
|
||||
"not_triggering_existing_bookings": "Не работает для уже созданных бронирований, так как при бронировании события пользователь должен будет ввести номер телефона.",
|
||||
"minute_one": "{{count}} минута",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com предоставляется под доступной и бесплатной лицензией AGPLv3, имеющей некоторые ограничения. Вы можете перейти на лицензию Enterprise сейчас или позже.",
|
||||
"license": "Лицензия",
|
||||
"agplv3_license": "Лицензия AGPLv3",
|
||||
"ee_enterprise_license": "Enterprise-лицензия «/ee»",
|
||||
"enterprise_booking_fee": "От {{enterprise_booking_fee}}/месяц",
|
||||
"enterprise_license_includes": "Всё для коммерческого использования",
|
||||
"no_need_to_keep_your_code_open_source": "Нет необходимости оставлять исходный код открытым",
|
||||
"repackage_rebrand_resell": "Простая переупаковка, ребрендинг и перепродажа",
|
||||
"a_vast_suite_of_enterprise_features": "Множество функций для предприятий",
|
||||
@@ -1676,10 +1671,9 @@
|
||||
"connect_now": "Подключить",
|
||||
"managed_event_dialog_confirm_button_one": "Заменить и уведомить {{count}} участника",
|
||||
"managed_event_dialog_confirm_button_other": "Заменить и уведомить участников ({{count}})",
|
||||
"managed_event_dialog_title_one": "URL-адрес /{{slug}} для {{count}} участника уже существует. Заменить?",
|
||||
"managed_event_dialog_title_other": "URL-адрес /{{slug}} для {{count}} участников уже существует. Заменить?",
|
||||
"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": "В случае замены мы уведомим пользователей. Если вы не хотите выполнять перезапись, вернитесь назад и удалите типы событий.",
|
||||
"review_event_type": "Пересмотреть тип события",
|
||||
"looking_for_more_analytics": "Нужно больше аналитики?",
|
||||
|
||||
@@ -451,7 +451,6 @@
|
||||
"max_limit_allowed_hint": "Mora da ima {{limit}} znakova ili manje",
|
||||
"invalid_password_hint": "Lozinka mora da bude dugačka najmanje {{passwordLength}} znakova, da sadrži najmanje jedan broj i da ima mešavinu velikih i malih slova",
|
||||
"incorrect_password": "Lozinka je netačna.",
|
||||
"incorrect_username_password": "Korisničko ime ili lozinka su netačni.",
|
||||
"use_setting": "Koristi podešavanje",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Vremenske opcije",
|
||||
@@ -662,7 +661,6 @@
|
||||
"create_new_team": "Napravite novi tim",
|
||||
"open_invitations": "Otvoreni pozivi za pridruživanje",
|
||||
"new_team": "Novi Tim",
|
||||
"create_first_team_and_invite_others": "Napravite svoj prvi tim i pozovite druge korisnike da radite zajedno.",
|
||||
"create_team_to_get_started": "Napravite tim da biste počeli",
|
||||
"teams": "Timovi",
|
||||
"team": "Tim",
|
||||
@@ -758,7 +756,7 @@
|
||||
"offset_toggle": "Pomeri vremena početka",
|
||||
"offset_toggle_description": "Pomeranje termina prikazanih osobama koje zakazuju za određeni broj minuta",
|
||||
"offset_start": "Pomeri za",
|
||||
"offset_start_description": "npr. ovo će prikazati termine vašim polaznicima u {{ adjustedTime }} umesto {{ originalTime }}",
|
||||
"offset_start_description": "npr. ovo će prikazati termine vašim korisnicima u {{ adjustedTime }} umesto {{ originalTime }}",
|
||||
"slot_interval": "Intervali vremenskih slotova",
|
||||
"slot_interval_default": "Koristi dužinu događaja (uobičajeno podešavanje)",
|
||||
"delete_event_type": "Izbriši tip događaja?",
|
||||
@@ -1097,8 +1095,8 @@
|
||||
"specific_issue": "Imate konkretan problem?",
|
||||
"browse_our_docs": "pregledajte naša dokumenta",
|
||||
"choose_template": "Izaberite predložak",
|
||||
"reminder": "Podsetnik",
|
||||
"custom": "Prilagođeno",
|
||||
"reminder": "Podsetnik",
|
||||
"reminder_email": "Podsetnik: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"not_triggering_existing_bookings": "Neće pokrenuti za već postojeća zakazivanja jer će korisniku biti tražen broj telefona prilikom rezervacije događaja.",
|
||||
"minute_one": "{{count}} minut",
|
||||
@@ -1557,9 +1555,6 @@
|
||||
"choose_license_description": "Cal.com dolazi sa pristupačnom i besplatnom AGPLv3 licencom čija ograničenja mogu da budu nadograđena na licencu za preduzeća u svakom trenutku. Možete u bilo kom trenutku kasnije da nadogradite.",
|
||||
"license": "Licenca",
|
||||
"agplv3_license": "AGPLv3 licenca",
|
||||
"ee_enterprise_license": "“/ee” Licenca za preduzeća",
|
||||
"enterprise_booking_fee": "Već od {{enterprise_booking_fee}}/mesečno",
|
||||
"enterprise_license_includes": "Sve za komercijalnu upotrebu",
|
||||
"no_need_to_keep_your_code_open_source": "Nije potrebno da vaš kôd bude open-source",
|
||||
"repackage_rebrand_resell": "Prepakujte, rebrendirajte i lako ponovo prodajte",
|
||||
"a_vast_suite_of_enterprise_features": "Ogroman izbor funkcija za preduzeća",
|
||||
@@ -1671,7 +1666,7 @@
|
||||
"timeslot_missing_cta": "Izaberite termin",
|
||||
"switch_monthly": "Pređite na mesečni pregled",
|
||||
"switch_weekly": "Pređite na nedeljni pregled",
|
||||
"switch_multiday": "Pređite na dnevni pregled",
|
||||
"switch_multiday": "Pređi na dnevni pregled",
|
||||
"num_locations": "{{num}} opcije lokacije",
|
||||
"select_on_next_step": "Izaberite na sledećem koraku",
|
||||
"this_meeting_has_not_started_yet": "Ovaj sastanak još nije počeo",
|
||||
@@ -1710,7 +1705,6 @@
|
||||
"managed_event_dialog_title_one": "URL /{{slug}} već postoji za {{count}} člana. Da li želite da ga zamenite?",
|
||||
"managed_event_dialog_title_other": "URL /{{slug}} već postoji za {{count}} članova. Da li želite da ga zamenite?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> već koristi <strong>/{{slug}}</strong> URL.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> već koriste <string>/{{slug}}</strong> URL.",
|
||||
"managed_event_dialog_clarification": "Ako izaberete da ga zamenite, obavestićemo ih. Vratite se i uklonite ih ako ne želite da zamenite postojeće.",
|
||||
"review_event_type": "Pregledaj tip događaja",
|
||||
"looking_for_more_analytics": "Tražite još analitike?",
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"leverage_our_api": "Utnyttja vår API för full kontroll och anpassningsbarhet.",
|
||||
"create_webhook": "Skapa webhook",
|
||||
"booking_rescheduled": "Bokning ombokad",
|
||||
"recording_ready": "Länk för nedladdning av inspelning klar",
|
||||
"recording_ready": "Länk för att ladda ned inspelning är klar",
|
||||
"booking_created": "Bokning skapad",
|
||||
"meeting_ended": "Mötet avslutat",
|
||||
"form_submitted": "Formulär skickat",
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Måste innehålla {{limit}} eller färre tecken",
|
||||
"invalid_password_hint": "Lösenordet måste innehålla minst {{passwordLength}} tecken och har minst en siffra samt en blandning av versaler och gemener",
|
||||
"incorrect_password": "Lösenordet är felaktigt.",
|
||||
"incorrect_username_password": "Felaktigt användarnamn eller lösenord.",
|
||||
"use_setting": "Använd inställning",
|
||||
"am_pm": "am/pm",
|
||||
"time_options": "Tidsinställningar",
|
||||
@@ -451,7 +450,7 @@
|
||||
"friday": "Fredag",
|
||||
"saturday": "Lördag",
|
||||
"sunday": "Söndag",
|
||||
"all_booked_today": "Alla bokade.",
|
||||
"all_booked_today": "Fullbokat.",
|
||||
"slots_load_fail": "Det gick inte att ladda tillgängliga tidsluckor.",
|
||||
"additional_guests": "Lägg till gäster",
|
||||
"your_name": "Ditt namn",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Skapa ett nytt team",
|
||||
"open_invitations": "Öppna inbjudningar",
|
||||
"new_team": "Nytt team",
|
||||
"create_first_team_and_invite_others": "Skapa ditt första team och bjud in andra användare att arbeta tillsammans med dig.",
|
||||
"create_team_to_get_started": "Skapa ett team för att komma igång",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
@@ -735,7 +733,7 @@
|
||||
"offset_toggle": "Flytta fram starttider",
|
||||
"offset_toggle_description": "Flytta fram tidsluckor som visas för bokare med ett angivet antal minuter",
|
||||
"offset_start": "Flytta fram med",
|
||||
"offset_start_description": "detta visar t.ex. tidsluckor för dina bokare vid {{ adjustedTime }} istället för {{ originalTime }}",
|
||||
"offset_start_description": "detta visar t.ex. tidsluckor för dina bokare kl. {{ adjustedTime }} istället för kl. {{ originalTime }}",
|
||||
"slot_interval": "Tid mellan bokningar",
|
||||
"slot_interval_default": "Använd händelsens längd (standard)",
|
||||
"delete_event_type": "Radera händelsetyp?",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Har du ett specifikt problem?",
|
||||
"browse_our_docs": "bläddra i våra dokument",
|
||||
"choose_template": "Välj en mall",
|
||||
"reminder": "Påminnelse",
|
||||
"custom": "Anpassad",
|
||||
"reminder": "Påminnelse",
|
||||
"reminder_email": "Påminnelse: {{eventType}} med {{name}} den {{date}}",
|
||||
"not_triggering_existing_bookings": "Kommer inte utlösa för redan befintliga bokningar eftersom användaren kommer att bli ombedd att uppge telefonnummer när händelsen bokas.",
|
||||
"minute_one": "{{count}} minut",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com har en tillgänglig och gratis AGPLv3-licens med vissa begränsningar som när som helst kan uppgraderas till en Enterprise-licens. Du kan uppgradera när som helst senare.",
|
||||
"license": "Licens",
|
||||
"agplv3_license": "AGPLv3-licens",
|
||||
"ee_enterprise_license": "\"/ee\" Enterprise-licens",
|
||||
"enterprise_booking_fee": "Från {{enterprise_booking_fee}}/månad",
|
||||
"enterprise_license_includes": "Allt för ett kommersiellt användningsfall",
|
||||
"no_need_to_keep_your_code_open_source": "Behöver inte behålla din kod som öppen källkod",
|
||||
"repackage_rebrand_resell": "Lätt att ompaketera, omprofilera och sälja vidare",
|
||||
"a_vast_suite_of_enterprise_features": "En omfattande uppsättning Enterprise-funktioner",
|
||||
@@ -1676,10 +1671,9 @@
|
||||
"connect_now": "Anslut nu",
|
||||
"managed_event_dialog_confirm_button_one": "Ersätt och meddela {{count}} medlem",
|
||||
"managed_event_dialog_confirm_button_other": "Ersätt och meddela {{count}} medlemmar",
|
||||
"managed_event_dialog_title_one": "Webbadressen /{{slug}} finns redan för {{count}} medlem. Vill du ersätta den?",
|
||||
"managed_event_dialog_title_other": "Webbadressen /{{slug}} finns redan för {{count}} medlemmar. Vill du ersätta den?",
|
||||
"managed_event_dialog_title_one": "Webbadressen/{{slug}} finns redan för {{count}} medlem. Vill du ersätta den?",
|
||||
"managed_event_dialog_title_other": "Webbadressen/{{slug}} finns redan för {{count}} medlemmar. Vill du ersätta den?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> använder redan webbadressen <strong>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> använder redan webbadressen <string>/{{slug}}</strong>.",
|
||||
"managed_event_dialog_clarification": "Vi meddelar dem om du byter ut den. Gå tillbaka och ta bort dem om du inte vill skriva över den.",
|
||||
"review_event_type": "Granska händelsetyp",
|
||||
"looking_for_more_analytics": "Vill du ha mer analys?",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "En fazla {{limit}} karakter uzunluğunda olmalıdır",
|
||||
"invalid_password_hint": "Şifre en az {{passwordLength}} karakter uzunluğunda olmalı, en az bir rakam içermeli ve büyük ve küçük harflerin karışımından oluşmalıdır",
|
||||
"incorrect_password": "Şifre hatalı.",
|
||||
"incorrect_username_password": "Kullanıcı adı veya şifre yanlış.",
|
||||
"use_setting": "Ayarı kullan",
|
||||
"am_pm": "öö/ös",
|
||||
"time_options": "Zaman seçenekleri",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Yeni ekip oluştur",
|
||||
"open_invitations": "Açık Davetler",
|
||||
"new_team": "Yeni Ekip",
|
||||
"create_first_team_and_invite_others": "İlk ekibinizi oluşturun ve diğer kullanıcıları sizinle birlikte çalışmaya davet edin.",
|
||||
"create_team_to_get_started": "Başlamak için bir ekip oluşturun",
|
||||
"teams": "Ekipler",
|
||||
"team": "Ekip",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Belirli bir sorununuz mu var?",
|
||||
"browse_our_docs": "belgelerimize göz atın",
|
||||
"choose_template": "Bir şablon seçin",
|
||||
"reminder": "Hatırlatıcı",
|
||||
"custom": "Özel",
|
||||
"reminder": "Hatırlatıcı",
|
||||
"reminder_email": "Hatırlatma: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"not_triggering_existing_bookings": "Etkinlik rezervasyonu yapılırken kullanıcıdan bir telefon numarası belirtmesi isteneceğinden mevcut rezervasyonlar için etkinleştirilmeyecektir.",
|
||||
"minute_one": "{{count}} dakika",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Erişilebilir ve ücretsiz bir AGPLv3 lisansı ile birlikte gelen Cal.com uygulaması bazı sınırlamalara sahip olmakla birlikte uygulamayı istediğiniz zaman Enterprise lisansına yükseltebilirsiniz. Daha sonra istediğiniz zaman yükseltebilirsiniz.",
|
||||
"license": "Lisans",
|
||||
"agplv3_license": "AGPLv3 Lisansı",
|
||||
"ee_enterprise_license": "“/ee” Enterprise Lisansı",
|
||||
"enterprise_booking_fee": "Ayda {{enterprise_booking_fee}} başlangıç fiyatıyla",
|
||||
"enterprise_license_includes": "Ticari bir kullanım durumu için her şey",
|
||||
"no_need_to_keep_your_code_open_source": "Kodunuzu açık kaynak olarak tutmanıza gerek yok",
|
||||
"repackage_rebrand_resell": "Kolayca yeniden paketleyin, yeniden markalalayın ve yeniden satın",
|
||||
"a_vast_suite_of_enterprise_features": "Kapsamlı kurumsal özellikler paketi",
|
||||
@@ -1679,7 +1674,6 @@
|
||||
"managed_event_dialog_title_one": "URL /{{slug}}, {{count}} üye için zaten mevcut. Bunu değiştirmek istiyor musunuz?",
|
||||
"managed_event_dialog_title_other": "URL /{{slug}}, {{count}} üye için zaten mevcut. Bunu değiştirmek istiyor musunuz?",
|
||||
"managed_event_dialog_information_one": "<strong>{{names}}</strong> zaten <strong>/{{slug}}</strong> URL'sini kullanıyor.",
|
||||
"managed_event_dialog_information_other": "<strong>{{names}}</strong> zaten <strong>/{{slug}}</strong> URL'sini kullanıyor.",
|
||||
"managed_event_dialog_clarification": "Değiştirmeyi seçmeniz durumunda onları bilgilendiririz. Üzerine yazmak istemiyorsanız geri dönerek onları kaldırın.",
|
||||
"review_event_type": "Etkinlik Türünü inceleyin",
|
||||
"looking_for_more_analytics": "Daha fazla analiz mi arıyorsunuz?",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"max_limit_allowed_hint": "Максимальна кількість символів: {{limit}}",
|
||||
"invalid_password_hint": "Пароль має містити не менше ніж {{passwordLength}} символів: принаймні одну цифру та комбінацію великих і малих літер",
|
||||
"incorrect_password": "Пароль неправильний.",
|
||||
"incorrect_username_password": "Неправильне ім’я користувача або пароль.",
|
||||
"use_setting": "Використовувати налаштування",
|
||||
"am_pm": "дп/пп",
|
||||
"time_options": "Параметри часу",
|
||||
@@ -639,7 +638,6 @@
|
||||
"create_new_team": "Створити нову команду",
|
||||
"open_invitations": "Відкриті запрошення",
|
||||
"new_team": "Нова команда",
|
||||
"create_first_team_and_invite_others": "Створіть свою першу команду та запросіть інших, щоб працювати разом.",
|
||||
"create_team_to_get_started": "Створіть команду, щоб почати",
|
||||
"teams": "Команди",
|
||||
"team": "Команда",
|
||||
@@ -1073,8 +1071,8 @@
|
||||
"specific_issue": "Виникла конкретна проблема?",
|
||||
"browse_our_docs": "переглянути нашу документацію",
|
||||
"choose_template": "Виберіть шаблон",
|
||||
"reminder": "Нагадування",
|
||||
"custom": "Користувацький",
|
||||
"reminder": "Нагадування",
|
||||
"reminder_email": "Нагадування: {{eventType}} з користувачем {{name}}, {{date}}",
|
||||
"not_triggering_existing_bookings": "Не спрацьовуватиме для бронювань, що вже існують, тому що в користувача під час бронювання запитуватиметься номер телефону.",
|
||||
"minute_one": "{{count}} хв",
|
||||
@@ -1528,9 +1526,6 @@
|
||||
"choose_license_description": "Cal.com містить доступну й безкоштовну ліцензію AGPLv3 з певними обмеженнями, яку можна будь-коли оновити до ліцензії Enterprise.",
|
||||
"license": "Ліцензія",
|
||||
"agplv3_license": "Ліцензія AGPLv3",
|
||||
"ee_enterprise_license": "“/ee” Ліцензія для плану Enterprise",
|
||||
"enterprise_booking_fee": "Від {{enterprise_booking_fee}}/міс.",
|
||||
"enterprise_license_includes": "Усе для комерційного використання",
|
||||
"no_need_to_keep_your_code_open_source": "Немає потреби залишати вихідний код відкритим",
|
||||
"repackage_rebrand_resell": "Легко здійснювати перепакування, ребрендинг і перепродаж",
|
||||
"a_vast_suite_of_enterprise_features": "Великий набір корпоративних функцій",
|
||||
@@ -1679,7 +1674,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": "Якщо ви захочете замінити її, ми повідомимо учасників. Поверніться і вилучіть їх, замість того щоб перезаписувати.",
|
||||
"review_event_type": "Переглянути тип заходу",
|
||||
"looking_for_more_analytics": "Потрібно більше аналітичних даних?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user